mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-27 11:23:02 +00:00
Merge branch 'tapframe:main' into Mal
This commit is contained in:
commit
fa9ce8d8bf
14 changed files with 288 additions and 509 deletions
|
|
@ -22,7 +22,7 @@ const BREAKPOINTS = {
|
||||||
tv: 1440,
|
tv: 1440,
|
||||||
};
|
};
|
||||||
|
|
||||||
const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png';
|
const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/6/69/IMDB_Logo_2016.svg';
|
||||||
|
|
||||||
export const RATING_PROVIDERS = {
|
export const RATING_PROVIDERS = {
|
||||||
imdb: {
|
imdb: {
|
||||||
|
|
@ -163,7 +163,7 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
||||||
imdb: {
|
imdb: {
|
||||||
name: 'IMDb',
|
name: 'IMDb',
|
||||||
icon: { uri: IMDb_LOGO },
|
icon: { uri: IMDb_LOGO },
|
||||||
isImage: true,
|
isImage: false,
|
||||||
color: '#F5C518',
|
color: '#F5C518',
|
||||||
transform: (value: number) => value.toFixed(1)
|
transform: (value: number) => value.toFixed(1)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
||||||
import SkipIntroButton from './overlays/SkipIntroButton';
|
import SkipIntroButton from './overlays/SkipIntroButton';
|
||||||
import UpNextButton from './common/UpNextButton';
|
import UpNextButton from './common/UpNextButton';
|
||||||
import { CustomAlert } from '../CustomAlert';
|
import { CustomAlert } from '../CustomAlert';
|
||||||
import { CreditsInfo } from '../../services/introService';
|
|
||||||
|
|
||||||
|
|
||||||
// Android-specific components
|
// Android-specific components
|
||||||
|
|
@ -147,9 +146,6 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
// Subtitle sync modal state
|
// Subtitle sync modal state
|
||||||
const [showSyncModal, setShowSyncModal] = useState(false);
|
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||||
|
|
||||||
// Credits timing state from API
|
|
||||||
const [creditsInfo, setCreditsInfo] = useState<CreditsInfo | null>(null);
|
|
||||||
|
|
||||||
// Track auto-selection ref to prevent duplicate selections
|
// Track auto-selection ref to prevent duplicate selections
|
||||||
const hasAutoSelectedTracks = useRef(false);
|
const hasAutoSelectedTracks = useRef(false);
|
||||||
|
|
||||||
|
|
@ -173,7 +169,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
}, [uri, episodeId]);
|
}, [uri, episodeId]);
|
||||||
|
|
||||||
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
|
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
|
||||||
const { metadata, cast, tmdbId } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [], tmdbId: null };
|
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
|
||||||
const hasLogo = metadata && metadata.logo;
|
const hasLogo = metadata && metadata.logo;
|
||||||
const openingAnimation = useOpeningAnimation(backdrop, metadata);
|
const openingAnimation = useOpeningAnimation(backdrop, metadata);
|
||||||
|
|
||||||
|
|
@ -933,7 +929,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
|
|
||||||
{/* Buffering Indicator (Visible when controls are hidden) */}
|
{/* Buffering Indicator (Visible when controls are hidden) */}
|
||||||
{playerState.isBuffering && !playerState.showControls && (
|
{playerState.isBuffering && !playerState.showControls && (
|
||||||
<View style={[StyleSheet.absoluteFill, { justifyContent: 'center', alignItems: 'center', zIndex: 15 }]}>
|
<View pointerEvents="none" style={[StyleSheet.absoluteFill, { justifyContent: 'center', alignItems: 'center', zIndex: 15 }]}>
|
||||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1026,10 +1022,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
episode={episode}
|
episode={episode}
|
||||||
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
|
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
|
||||||
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
|
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
|
||||||
tmdbId={tmdbId || undefined}
|
|
||||||
currentTime={playerState.currentTime}
|
currentTime={playerState.currentTime}
|
||||||
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
|
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
|
||||||
onCreditsInfo={setCreditsInfo}
|
|
||||||
controlsVisible={playerState.showControls}
|
controlsVisible={playerState.showControls}
|
||||||
controlsFixedOffset={100}
|
controlsFixedOffset={100}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1055,7 +1049,6 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined}
|
metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined}
|
||||||
controlsVisible={playerState.showControls}
|
controlsVisible={playerState.showControls}
|
||||||
controlsFixedOffset={100}
|
controlsFixedOffset={100}
|
||||||
creditsInfo={creditsInfo}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import ResumeOverlay from './modals/ResumeOverlay';
|
||||||
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
||||||
import SkipIntroButton from './overlays/SkipIntroButton';
|
import SkipIntroButton from './overlays/SkipIntroButton';
|
||||||
import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components';
|
import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components';
|
||||||
import { CreditsInfo } from '../../services/introService';
|
|
||||||
|
|
||||||
// Platform-specific components
|
// Platform-specific components
|
||||||
import { KSPlayerSurface } from './ios/components/KSPlayerSurface';
|
import { KSPlayerSurface } from './ios/components/KSPlayerSurface';
|
||||||
|
|
@ -157,7 +156,7 @@ const KSPlayerCore: React.FC = () => {
|
||||||
const speedControl = useSpeedControl(1.0);
|
const speedControl = useSpeedControl(1.0);
|
||||||
|
|
||||||
// Metadata Hook
|
// Metadata Hook
|
||||||
const { metadata, groupedEpisodes, cast, tmdbId } = useMetadata({ id, type: type as 'movie' | 'series' });
|
const { metadata, groupedEpisodes, cast } = useMetadata({ id, type: type as 'movie' | 'series' });
|
||||||
|
|
||||||
// Trakt Autosync
|
// Trakt Autosync
|
||||||
const traktAutosync = useTraktAutosync({
|
const traktAutosync = useTraktAutosync({
|
||||||
|
|
@ -180,9 +179,6 @@ const KSPlayerCore: React.FC = () => {
|
||||||
// Subtitle sync modal state
|
// Subtitle sync modal state
|
||||||
const [showSyncModal, setShowSyncModal] = useState(false);
|
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||||
|
|
||||||
// Credits timing state from API
|
|
||||||
const [creditsInfo, setCreditsInfo] = useState<CreditsInfo | null>(null);
|
|
||||||
|
|
||||||
// Track auto-selection refs to prevent duplicate selections
|
// Track auto-selection refs to prevent duplicate selections
|
||||||
const hasAutoSelectedTracks = useRef(false);
|
const hasAutoSelectedTracks = useRef(false);
|
||||||
|
|
||||||
|
|
@ -881,7 +877,7 @@ const KSPlayerCore: React.FC = () => {
|
||||||
<View pointerEvents="box-none" style={StyleSheet.absoluteFill}>
|
<View pointerEvents="box-none" style={StyleSheet.absoluteFill}>
|
||||||
{/* Buffering Indicator (Visible when controls are hidden) */}
|
{/* Buffering Indicator (Visible when controls are hidden) */}
|
||||||
{isBuffering && !showControls && (
|
{isBuffering && !showControls && (
|
||||||
<View style={[StyleSheet.absoluteFill, { justifyContent: 'center', alignItems: 'center', zIndex: 15 }]}>
|
<View pointerEvents="none" style={[StyleSheet.absoluteFill, { justifyContent: 'center', alignItems: 'center', zIndex: 15 }]}>
|
||||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1002,10 +998,8 @@ const KSPlayerCore: React.FC = () => {
|
||||||
episode={episode}
|
episode={episode}
|
||||||
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
|
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
|
||||||
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
|
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
|
||||||
tmdbId={tmdbId || undefined}
|
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
onSkip={(endTime) => controls.seekToTime(endTime)}
|
onSkip={(endTime) => controls.seekToTime(endTime)}
|
||||||
onCreditsInfo={setCreditsInfo}
|
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
controlsFixedOffset={126}
|
controlsFixedOffset={126}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1031,7 +1025,6 @@ const KSPlayerCore: React.FC = () => {
|
||||||
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
|
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
controlsFixedOffset={126}
|
controlsFixedOffset={126}
|
||||||
creditsInfo={creditsInfo}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { Animated } from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { CreditsInfo } from '../../../services/introService';
|
|
||||||
|
|
||||||
export interface Insets {
|
export interface Insets {
|
||||||
top: number;
|
top: number;
|
||||||
|
|
@ -34,7 +33,6 @@ interface UpNextButtonProps {
|
||||||
metadata?: { poster?: string; id?: string }; // Added metadata prop
|
metadata?: { poster?: string; id?: string }; // Added metadata prop
|
||||||
controlsVisible?: boolean;
|
controlsVisible?: boolean;
|
||||||
controlsFixedOffset?: number;
|
controlsFixedOffset?: number;
|
||||||
creditsInfo?: CreditsInfo | null; // Add credits info from API
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UpNextButton: React.FC<UpNextButtonProps> = ({
|
const UpNextButton: React.FC<UpNextButtonProps> = ({
|
||||||
|
|
@ -51,7 +49,6 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
|
||||||
metadata,
|
metadata,
|
||||||
controlsVisible = false,
|
controlsVisible = false,
|
||||||
controlsFixedOffset = 100,
|
controlsFixedOffset = 100,
|
||||||
creditsInfo,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const opacity = useRef(new Animated.Value(0)).current;
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
@ -79,19 +76,10 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
|
||||||
|
|
||||||
const shouldShow = useMemo(() => {
|
const shouldShow = useMemo(() => {
|
||||||
if (!nextEpisode || duration <= 0) return false;
|
if (!nextEpisode || duration <= 0) return false;
|
||||||
|
|
||||||
// If we have credits timing from API, use that as primary source
|
|
||||||
if (creditsInfo?.startTime !== null && creditsInfo?.startTime !== undefined) {
|
|
||||||
// Show button when we reach credits start time and stay visible until 10s before end
|
|
||||||
const timeRemaining = duration - currentTime;
|
|
||||||
const isInCredits = currentTime >= creditsInfo.startTime;
|
|
||||||
return isInCredits && timeRemaining > 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Use fixed timing (show when under ~1 minute and above 10s)
|
|
||||||
const timeRemaining = duration - currentTime;
|
const timeRemaining = duration - currentTime;
|
||||||
|
// Be tolerant to timer jitter: show when under ~1 minute and above 10s
|
||||||
return timeRemaining < 61 && timeRemaining > 10;
|
return timeRemaining < 61 && timeRemaining > 10;
|
||||||
}, [nextEpisode, duration, currentTime, creditsInfo]);
|
}, [nextEpisode, duration, currentTime]);
|
||||||
|
|
||||||
// Debug logging removed to reduce console noise
|
// Debug logging removed to reduce console noise
|
||||||
// The state is computed in shouldShow useMemo above
|
// The state is computed in shouldShow useMemo above
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import Animated, {
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { introService, SkipInterval, SkipType, CreditsInfo } from '../../../services/introService';
|
import { introService, SkipInterval, SkipType } from '../../../services/introService';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { useSettings } from '../../../hooks/useSettings';
|
import { useSettings } from '../../../hooks/useSettings';
|
||||||
|
|
@ -22,10 +22,8 @@ interface SkipIntroButtonProps {
|
||||||
episode?: number;
|
episode?: number;
|
||||||
malId?: string;
|
malId?: string;
|
||||||
kitsuId?: string;
|
kitsuId?: string;
|
||||||
tmdbId?: number;
|
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
onSkip: (endTime: number) => void;
|
onSkip: (endTime: number) => void;
|
||||||
onCreditsInfo?: (credits: CreditsInfo | null) => void;
|
|
||||||
controlsVisible?: boolean;
|
controlsVisible?: boolean;
|
||||||
controlsFixedOffset?: number;
|
controlsFixedOffset?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -37,10 +35,8 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
episode,
|
episode,
|
||||||
malId,
|
malId,
|
||||||
kitsuId,
|
kitsuId,
|
||||||
tmdbId,
|
|
||||||
currentTime,
|
currentTime,
|
||||||
onSkip,
|
onSkip,
|
||||||
onCreditsInfo,
|
|
||||||
controlsVisible = false,
|
controlsVisible = false,
|
||||||
controlsFixedOffset = 100,
|
controlsFixedOffset = 100,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -69,22 +65,20 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
|
|
||||||
// Fetch skip data when episode changes
|
// Fetch skip data when episode changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${tmdbId}`;
|
const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`;
|
||||||
|
|
||||||
if (!skipIntroEnabled) {
|
if (!skipIntroEnabled) {
|
||||||
setSkipIntervals([]);
|
setSkipIntervals([]);
|
||||||
setCurrentInterval(null);
|
setCurrentInterval(null);
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
fetchedRef.current = false;
|
fetchedRef.current = false;
|
||||||
if (onCreditsInfo) onCreditsInfo(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if not a series or missing required data (though MAL/Kitsu ID might be enough for some cases, usually need season/ep)
|
// Skip if not a series or missing required data (though MAL/Kitsu ID might be enough for some cases, usually need season/ep)
|
||||||
if (type !== 'series' || (!imdbId && !malId && !kitsuId && !tmdbId) || !season || !episode) {
|
if (type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) {
|
||||||
setSkipIntervals([]);
|
setSkipIntervals([]);
|
||||||
fetchedRef.current = false;
|
fetchedRef.current = false;
|
||||||
if (onCreditsInfo) onCreditsInfo(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,35 +94,24 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
setSkipIntervals([]);
|
setSkipIntervals([]);
|
||||||
|
|
||||||
const fetchSkipData = async () => {
|
const fetchSkipData = async () => {
|
||||||
logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (TMDB: ${tmdbId}, IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`);
|
logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`);
|
||||||
try {
|
try {
|
||||||
const mediaType = type === 'series' ? 'tv' : type === 'movie' ? 'movie' : 'tv';
|
const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId);
|
||||||
const result = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, tmdbId, mediaType);
|
setSkipIntervals(intervals);
|
||||||
setSkipIntervals(result.intervals);
|
|
||||||
|
|
||||||
// Pass credits info to parent via callback
|
if (intervals.length > 0) {
|
||||||
if (onCreditsInfo) {
|
logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals);
|
||||||
onCreditsInfo(result.credits);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.intervals.length > 0) {
|
|
||||||
logger.log(`[SkipIntroButton] ✓ Found ${result.intervals.length} skip intervals:`, result.intervals);
|
|
||||||
} else {
|
} else {
|
||||||
logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`);
|
logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.credits) {
|
|
||||||
logger.log(`[SkipIntroButton] ✓ Found credits timing:`, result.credits);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[SkipIntroButton] Error fetching skip data:', error);
|
logger.error('[SkipIntroButton] Error fetching skip data:', error);
|
||||||
setSkipIntervals([]);
|
setSkipIntervals([]);
|
||||||
if (onCreditsInfo) onCreditsInfo(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchSkipData();
|
fetchSkipData();
|
||||||
}, [imdbId, type, season, episode, malId, kitsuId, tmdbId, skipIntroEnabled, onCreditsInfo]);
|
}, [imdbId, type, season, episode, malId, kitsuId, skipIntroEnabled]);
|
||||||
|
|
||||||
// Determine active interval based on current playback position
|
// Determine active interval based on current playback position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -212,12 +212,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stremioService.getStreams(type, id,
|
await stremioService.getStreams(type, id,
|
||||||
(streams, addonId, addonName, error) => {
|
(streams, addonId, addonName, error, installationId) => {
|
||||||
const processTime = Date.now() - sourceStartTime;
|
const processTime = Date.now() - sourceStartTime;
|
||||||
|
|
||||||
console.log('🔍 [processStremioSource] Callback received:', {
|
console.log('🔍 [processStremioSource] Callback received:', {
|
||||||
addonId,
|
addonId,
|
||||||
addonName,
|
addonName,
|
||||||
|
installationId,
|
||||||
streamCount: streams?.length || 0,
|
streamCount: streams?.length || 0,
|
||||||
error: error?.message || null,
|
error: error?.message || null,
|
||||||
processTime
|
processTime
|
||||||
|
|
@ -276,20 +277,23 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// Use debounced update to prevent rapid state changes
|
// Use debounced update to prevent rapid state changes
|
||||||
debouncedStreamUpdate(() => {
|
debouncedStreamUpdate(() => {
|
||||||
const updateState = (prevState: GroupedStreams): GroupedStreams => {
|
const updateState = (prevState: GroupedStreams): GroupedStreams => {
|
||||||
if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Updating state for addon ${addonName} (${addonId})`);
|
// Use installationId as key to keep multiple installations separate
|
||||||
|
const key = installationId || addonId || 'unknown';
|
||||||
|
if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Updating state for addon ${addonName} (${addonId}) [${installationId}]`);
|
||||||
return {
|
return {
|
||||||
...prevState,
|
...prevState,
|
||||||
[addonId]: {
|
[key]: {
|
||||||
addonName: addonName,
|
addonName: addonName,
|
||||||
streams: optimizedStreams // Use optimized streams
|
streams: optimizedStreams // Use optimized streams
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track response order for addons
|
// Track response order for addons (use installationId to track each installation separately)
|
||||||
setAddonResponseOrder(prevOrder => {
|
setAddonResponseOrder(prevOrder => {
|
||||||
if (!prevOrder.includes(addonId)) {
|
const key = installationId || addonId || 'unknown';
|
||||||
return [...prevOrder, addonId];
|
if (!prevOrder.includes(key)) {
|
||||||
|
return [...prevOrder, key];
|
||||||
}
|
}
|
||||||
return prevOrder;
|
return prevOrder;
|
||||||
});
|
});
|
||||||
|
|
@ -309,20 +313,23 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
debouncedStreamUpdate(() => {
|
debouncedStreamUpdate(() => {
|
||||||
const updateState = (prevState: GroupedStreams): GroupedStreams => {
|
const updateState = (prevState: GroupedStreams): GroupedStreams => {
|
||||||
if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Adding empty provider ${addonName} (${addonId}) to state`);
|
// Use installationId as key to keep multiple installations separate
|
||||||
|
const key = installationId || addonId || 'unknown';
|
||||||
|
if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Adding empty provider ${addonName} (${addonId}) [${installationId}] to state`);
|
||||||
return {
|
return {
|
||||||
...prevState,
|
...prevState,
|
||||||
[addonId]: {
|
[key]: {
|
||||||
addonName: addonName,
|
addonName: addonName,
|
||||||
streams: [] // Empty array for providers with no streams
|
streams: [] // Empty array for providers with no streams
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track response order for addons
|
// Track response order for addons (use installationId to track each installation separately)
|
||||||
setAddonResponseOrder(prevOrder => {
|
setAddonResponseOrder(prevOrder => {
|
||||||
if (!prevOrder.includes(addonId)) {
|
const key = installationId || addonId || 'unknown';
|
||||||
return [...prevOrder, addonId];
|
if (!prevOrder.includes(key)) {
|
||||||
|
return [...prevOrder, key];
|
||||||
}
|
}
|
||||||
return prevOrder;
|
return prevOrder;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@ export interface AppSettings {
|
||||||
// Playback behavior
|
// Playback behavior
|
||||||
alwaysResume: boolean; // If true, resume automatically without prompt when progress < 85%
|
alwaysResume: boolean; // If true, resume automatically without prompt when progress < 85%
|
||||||
skipIntroEnabled: boolean; // Enable/disable Skip Intro overlay (IntroDB)
|
skipIntroEnabled: boolean; // Enable/disable Skip Intro overlay (IntroDB)
|
||||||
introDbSource: 'theintrodb' | 'introdb'; // Preferred IntroDB source: TheIntroDB (new) or IntroDB (legacy)
|
|
||||||
// Downloads
|
// Downloads
|
||||||
enableDownloads: boolean; // Show Downloads tab and enable saving streams
|
enableDownloads: boolean; // Show Downloads tab and enable saving streams
|
||||||
// Theme settings
|
// Theme settings
|
||||||
|
|
@ -148,7 +147,6 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
// Playback behavior defaults
|
// Playback behavior defaults
|
||||||
alwaysResume: true,
|
alwaysResume: true,
|
||||||
skipIntroEnabled: true,
|
skipIntroEnabled: true,
|
||||||
introDbSource: 'theintrodb', // Default to TheIntroDB (new API)
|
|
||||||
// Downloads
|
// Downloads
|
||||||
enableDownloads: false,
|
enableDownloads: false,
|
||||||
useExternalPlayerForDownloads: false,
|
useExternalPlayerForDownloads: false,
|
||||||
|
|
|
||||||
|
|
@ -624,13 +624,40 @@ const AddonsScreen = () => {
|
||||||
try {
|
try {
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
const manifest = await stremioService.getManifest(urlToInstall);
|
const manifest = await stremioService.getManifest(urlToInstall);
|
||||||
|
|
||||||
|
// Check if this addon is already installed
|
||||||
|
const installedAddons = await stremioService.getInstalledAddonsAsync();
|
||||||
|
const existingInstallations = installedAddons.filter(a => a.id === manifest.id);
|
||||||
|
const isAlreadyInstalled = existingInstallations.length > 0;
|
||||||
|
|
||||||
|
// Check if addon provides streams
|
||||||
|
const providesStreams = manifest.resources?.some(resource => {
|
||||||
|
if (typeof resource === 'string') {
|
||||||
|
return resource === 'stream';
|
||||||
|
} else if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
||||||
|
return (resource as any).name === 'stream';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}) || false;
|
||||||
|
|
||||||
|
|
||||||
|
if (isAlreadyInstalled && !providesStreams) {
|
||||||
|
setAlertTitle(t('common.error'));
|
||||||
|
setAlertMessage('This addon is already installed. Multiple installations are only allowed for stream providers.');
|
||||||
|
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
||||||
|
setAlertVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setAddonDetails(manifest);
|
setAddonDetails(manifest);
|
||||||
setAddonUrl(urlToInstall);
|
setAddonUrl(urlToInstall);
|
||||||
setShowConfirmModal(true);
|
setShowConfirmModal(true);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to fetch addon details:', error);
|
logger.error('Failed to fetch addon details:', error);
|
||||||
setAlertTitle(t('common.error'));
|
setAlertTitle(t('common.error'));
|
||||||
setAlertMessage(`${t('addons.fetch_error')} ${urlToInstall}`);
|
|
||||||
|
const errorMessage = error?.message || `${t('addons.fetch_error')} ${urlToInstall}`;
|
||||||
|
setAlertMessage(errorMessage);
|
||||||
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
||||||
setAlertVisible(true);
|
setAlertVisible(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -652,10 +679,12 @@ const AddonsScreen = () => {
|
||||||
setAlertMessage(t('addons.install_success'));
|
setAlertMessage(t('addons.install_success'));
|
||||||
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
||||||
setAlertVisible(true);
|
setAlertVisible(true);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to install addon:', error);
|
logger.error('Failed to install addon:', error);
|
||||||
setAlertTitle(t('common.error'));
|
setAlertTitle(t('common.error'));
|
||||||
setAlertMessage(t('addons.install_error'));
|
// Show specific error message if available, otherwise use generic message
|
||||||
|
const errorMessage = error?.message || t('addons.install_error');
|
||||||
|
setAlertMessage(errorMessage);
|
||||||
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
||||||
setAlertVisible(true);
|
setAlertVisible(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -669,14 +698,14 @@ const AddonsScreen = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveAddonUp = (addon: ExtendedManifest) => {
|
const moveAddonUp = (addon: ExtendedManifest) => {
|
||||||
if (stremioService.moveAddonUp(addon.installationId || addon.id)) {
|
if (addon.installationId && stremioService.moveAddonUp(addon.installationId)) {
|
||||||
// Refresh the list to reflect the new order
|
// Refresh the list to reflect the new order
|
||||||
loadAddons();
|
loadAddons();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveAddonDown = (addon: ExtendedManifest) => {
|
const moveAddonDown = (addon: ExtendedManifest) => {
|
||||||
if (stremioService.moveAddonDown(addon.installationId || addon.id)) {
|
if (addon.installationId && stremioService.moveAddonDown(addon.installationId)) {
|
||||||
// Refresh the list to reflect the new order
|
// Refresh the list to reflect the new order
|
||||||
loadAddons();
|
loadAddons();
|
||||||
}
|
}
|
||||||
|
|
@ -690,10 +719,12 @@ const AddonsScreen = () => {
|
||||||
{
|
{
|
||||||
label: t('addons.uninstall_button'),
|
label: t('addons.uninstall_button'),
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await stremioService.removeAddon(addon.installationId || addon.id);
|
if (addon.installationId) {
|
||||||
setAddons(prev => prev.filter(a => (a.installationId || a.id) !== (addon.installationId || addon.id)));
|
await stremioService.removeAddon(addon.installationId);
|
||||||
// Ensure we re-read from storage/order to avoid reappearing on next load
|
setAddons(prev => prev.filter(a => a.installationId !== addon.installationId));
|
||||||
await loadAddons();
|
// Ensure we re-read from storage/order to avoid reappearing on next load
|
||||||
|
await loadAddons();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
style: { color: colors.error }
|
style: { color: colors.error }
|
||||||
},
|
},
|
||||||
|
|
@ -840,6 +871,11 @@ const AddonsScreen = () => {
|
||||||
// Check if addon is pre-installed
|
// Check if addon is pre-installed
|
||||||
const isPreInstalled = stremioService.isPreInstalledAddon(item.id);
|
const isPreInstalled = stremioService.isPreInstalledAddon(item.id);
|
||||||
|
|
||||||
|
// Check if there are multiple installations of this addon
|
||||||
|
const sameAddonInstallations = addons.filter(a => a.id === item.id);
|
||||||
|
const hasMultipleInstallations = sameAddonInstallations.length > 1;
|
||||||
|
const installationNumber = sameAddonInstallations.findIndex(a => a.installationId === item.installationId) + 1;
|
||||||
|
|
||||||
// Format the types into a simple category text
|
// Format the types into a simple category text
|
||||||
const categoryText = types.length > 0
|
const categoryText = types.length > 0
|
||||||
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
|
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
|
||||||
|
|
@ -890,13 +926,18 @@ const AddonsScreen = () => {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={styles.addonTitleContainer}>
|
<View style={styles.addonTitleContainer}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2, flexWrap: 'wrap' }}>
|
||||||
<Text style={styles.addonName}>{item.name}</Text>
|
<Text style={styles.addonName}>{item.name}</Text>
|
||||||
{isPreInstalled && (
|
{isPreInstalled && (
|
||||||
<View style={[styles.priorityBadge, { marginLeft: 8, backgroundColor: colors.success }]}>
|
<View style={[styles.priorityBadge, { marginLeft: 8, backgroundColor: colors.success }]}>
|
||||||
<Text style={[styles.priorityText, { fontSize: 10 }]}>{t('addons.pre_installed')}</Text>
|
<Text style={[styles.priorityText, { fontSize: 10 }]}>{t('addons.pre_installed')}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{hasMultipleInstallations && (
|
||||||
|
<View style={[styles.priorityBadge, { marginLeft: 8, backgroundColor: colors.primary }]}>
|
||||||
|
<Text style={[styles.priorityText, { fontSize: 10 }]}>#{installationNumber}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.addonMetaContainer}>
|
<View style={styles.addonMetaContainer}>
|
||||||
<Text style={styles.addonVersion}>{t('addons.version', { version: item.version || '1.0.0' })}</Text>
|
<Text style={styles.addonVersion}>{t('addons.version', { version: item.version || '1.0.0' })}</Text>
|
||||||
|
|
@ -935,6 +976,11 @@ const AddonsScreen = () => {
|
||||||
<Text style={styles.addonDescription}>
|
<Text style={styles.addonDescription}>
|
||||||
{description.length > 100 ? description.substring(0, 100) + '...' : description}
|
{description.length > 100 ? description.substring(0, 100) + '...' : description}
|
||||||
</Text>
|
</Text>
|
||||||
|
{hasMultipleInstallations && item.originalUrl && (
|
||||||
|
<Text style={[styles.addonDescription, { fontSize: 11, marginTop: 4, color: colors.mediumGray }]}>
|
||||||
|
{item.originalUrl}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -308,12 +308,9 @@ const CatalogSettingsScreen = () => {
|
||||||
addons.forEach(addon => {
|
addons.forEach(addon => {
|
||||||
if (addon.catalogs && addon.catalogs.length > 0) {
|
if (addon.catalogs && addon.catalogs.length > 0) {
|
||||||
const uniqueCatalogs = new Map<string, CatalogSetting>();
|
const uniqueCatalogs = new Map<string, CatalogSetting>();
|
||||||
// Use installationId if available, otherwise fallback to id
|
|
||||||
const uniqueAddonId = addon.installationId || addon.id;
|
|
||||||
|
|
||||||
addon.catalogs.forEach(catalog => {
|
addon.catalogs.forEach(catalog => {
|
||||||
// Generate a truly unique key using installationId
|
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
||||||
const settingKey = `${uniqueAddonId}:${catalog.type}:${catalog.id}`;
|
|
||||||
let displayName = catalog.name || catalog.id;
|
let displayName = catalog.name || catalog.id;
|
||||||
const catalogType = catalog.type === 'movie' ? 'Movies' : catalog.type === 'series' ? 'TV Shows' : catalog.type.charAt(0).toUpperCase() + catalog.type.slice(1);
|
const catalogType = catalog.type === 'movie' ? 'Movies' : catalog.type === 'series' ? 'TV Shows' : catalog.type.charAt(0).toUpperCase() + catalog.type.slice(1);
|
||||||
|
|
||||||
|
|
@ -338,7 +335,7 @@ const CatalogSettingsScreen = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
uniqueCatalogs.set(settingKey, {
|
uniqueCatalogs.set(settingKey, {
|
||||||
addonId: uniqueAddonId, // Store unique ID here
|
addonId: addon.id,
|
||||||
catalogId: catalog.id,
|
catalogId: catalog.id,
|
||||||
type: catalog.type,
|
type: catalog.type,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
|
|
@ -354,15 +351,11 @@ const CatalogSettingsScreen = () => {
|
||||||
// Group settings by addon name
|
// Group settings by addon name
|
||||||
const grouped: GroupedCatalogs = {};
|
const grouped: GroupedCatalogs = {};
|
||||||
availableCatalogs.forEach(setting => {
|
availableCatalogs.forEach(setting => {
|
||||||
// Find addon by matching either installationId or id
|
const addon = addons.find(a => a.id === setting.addonId);
|
||||||
const addon = addons.find(a => (a.installationId || a.id) === setting.addonId);
|
|
||||||
if (!addon) return;
|
if (!addon) return;
|
||||||
|
|
||||||
// Use the unique addon ID (installationId) as the group key
|
if (!grouped[setting.addonId]) {
|
||||||
const groupKey = setting.addonId;
|
grouped[setting.addonId] = {
|
||||||
|
|
||||||
if (!grouped[groupKey]) {
|
|
||||||
grouped[groupKey] = {
|
|
||||||
name: addon.name,
|
name: addon.name,
|
||||||
catalogs: [],
|
catalogs: [],
|
||||||
expanded: true,
|
expanded: true,
|
||||||
|
|
@ -370,9 +363,9 @@ const CatalogSettingsScreen = () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
grouped[groupKey].catalogs.push(setting);
|
grouped[setting.addonId].catalogs.push(setting);
|
||||||
if (setting.enabled) {
|
if (setting.enabled) {
|
||||||
grouped[groupKey].enabledCount++;
|
grouped[setting.addonId].enabledCount++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -716,10 +716,12 @@ const DebridIntegrationScreen = () => {
|
||||||
setAlertMessage(t('debrid.connected_title'));
|
setAlertMessage(t('debrid.connected_title'));
|
||||||
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
||||||
setAlertVisible(true);
|
setAlertVisible(true);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to install Torbox addon:', error);
|
logger.error('Failed to install Torbox addon:', error);
|
||||||
setAlertTitle(t('common.error'));
|
setAlertTitle(t('common.error'));
|
||||||
setAlertMessage(t('addons.install_error'));
|
|
||||||
|
const errorMessage = error?.message || t('addons.install_error');
|
||||||
|
setAlertMessage(errorMessage);
|
||||||
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
|
||||||
setAlertVisible(true);
|
setAlertVisible(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Dimensions, Image } from 'react-native';
|
import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Dimensions } from 'react-native';
|
||||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
@ -17,7 +17,6 @@ import { SvgXml } from 'react-native-svg';
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
const INTRODB_LOGO_URI = 'https://introdb.app/images/logo-vector.svg';
|
const INTRODB_LOGO_URI = 'https://introdb.app/images/logo-vector.svg';
|
||||||
const THEINTRODB_FAVICON_URI = 'https://theintrodb.org/favicon.ico';
|
|
||||||
|
|
||||||
// Available languages for audio/subtitle selection
|
// Available languages for audio/subtitle selection
|
||||||
const AVAILABLE_LANGUAGES = [
|
const AVAILABLE_LANGUAGES = [
|
||||||
|
|
@ -78,7 +77,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
||||||
const config = useRealtimeConfig();
|
const config = useRealtimeConfig();
|
||||||
|
|
||||||
const [introDbLogoXml, setIntroDbLogoXml] = useState<string | null>(null);
|
const [introDbLogoXml, setIntroDbLogoXml] = useState<string | null>(null);
|
||||||
const [theIntroDbLoaded, setTheIntroDbLoaded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -105,57 +103,20 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Preload TheIntroDB favicon
|
const introDbLogoIcon = introDbLogoXml ? (
|
||||||
useEffect(() => {
|
<SvgXml xml={introDbLogoXml} width={28} height={18} />
|
||||||
let cancelled = false;
|
) : (
|
||||||
const load = async () => {
|
<MaterialIcons name="skip-next" size={18} color={currentTheme.colors.primary} />
|
||||||
try {
|
);
|
||||||
await fetch(THEINTRODB_FAVICON_URI);
|
|
||||||
if (!cancelled) setTheIntroDbLoaded(true);
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setTheIntroDbLoaded(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const introDbLogoIcon = useMemo(() => {
|
|
||||||
const selectedSource = settings?.introDbSource || 'theintrodb';
|
|
||||||
|
|
||||||
if (selectedSource === 'theintrodb') {
|
|
||||||
// Show TheIntroDB favicon
|
|
||||||
return theIntroDbLoaded ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: THEINTRODB_FAVICON_URI }}
|
|
||||||
style={{ width: 20, height: 20 }}
|
|
||||||
resizeMode="contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<MaterialIcons name="skip-next" size={18} color={currentTheme.colors.primary} />
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Show IntroDB logo (legacy)
|
|
||||||
return introDbLogoXml ? (
|
|
||||||
<SvgXml xml={introDbLogoXml} width={28} height={18} />
|
|
||||||
) : (
|
|
||||||
<MaterialIcons name="skip-next" size={18} color={currentTheme.colors.primary} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [settings?.introDbSource, introDbLogoXml, theIntroDbLoaded, currentTheme.colors.primary]);
|
|
||||||
|
|
||||||
// Bottom sheet refs
|
// Bottom sheet refs
|
||||||
const audioLanguageSheetRef = useRef<BottomSheetModal>(null);
|
const audioLanguageSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const subtitleLanguageSheetRef = useRef<BottomSheetModal>(null);
|
const subtitleLanguageSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const subtitleSourceSheetRef = useRef<BottomSheetModal>(null);
|
const subtitleSourceSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const introSourceSheetRef = useRef<BottomSheetModal>(null);
|
|
||||||
|
|
||||||
// Snap points
|
// Snap points
|
||||||
const languageSnapPoints = useMemo(() => ['70%'], []);
|
const languageSnapPoints = useMemo(() => ['70%'], []);
|
||||||
const sourceSnapPoints = useMemo(() => ['45%'], []);
|
const sourceSnapPoints = useMemo(() => ['45%'], []);
|
||||||
const introSourceSnapPoints = useMemo(() => ['35%'], []);
|
|
||||||
|
|
||||||
// Handlers to present sheets - ensure only one is open at a time
|
// Handlers to present sheets - ensure only one is open at a time
|
||||||
const openAudioLanguageSheet = useCallback(() => {
|
const openAudioLanguageSheet = useCallback(() => {
|
||||||
|
|
@ -176,13 +137,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
||||||
setTimeout(() => subtitleSourceSheetRef.current?.present(), 100);
|
setTimeout(() => subtitleSourceSheetRef.current?.present(), 100);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openIntroSourceSheet = useCallback(() => {
|
|
||||||
audioLanguageSheetRef.current?.dismiss();
|
|
||||||
subtitleLanguageSheetRef.current?.dismiss();
|
|
||||||
subtitleSourceSheetRef.current?.dismiss();
|
|
||||||
setTimeout(() => introSourceSheetRef.current?.present(), 100);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isItemVisible = (itemId: string) => {
|
const isItemVisible = (itemId: string) => {
|
||||||
if (!config?.items) return true;
|
if (!config?.items) return true;
|
||||||
const item = config.items[itemId];
|
const item = config.items[itemId];
|
||||||
|
|
@ -234,17 +188,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
||||||
subtitleSourceSheetRef.current?.dismiss();
|
subtitleSourceSheetRef.current?.dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectIntroSource = (value: 'theintrodb' | 'introdb') => {
|
|
||||||
updateSetting('introDbSource', value);
|
|
||||||
introSourceSheetRef.current?.dismiss();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIntroSourceLabel = (value: string) => {
|
|
||||||
if (value === 'theintrodb') return 'TheIntroDB';
|
|
||||||
if (value === 'introdb') return 'IntroDB';
|
|
||||||
return 'TheIntroDB';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasVisibleItems(['video_player']) && (
|
{hasVisibleItems(['video_player']) && (
|
||||||
|
|
@ -269,7 +212,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
||||||
<SettingsCard title={t('player.section_playback', { defaultValue: 'Playback' })} isTablet={isTablet}>
|
<SettingsCard title={t('player.section_playback', { defaultValue: 'Playback' })} isTablet={isTablet}>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title={t('player.skip_intro_settings_title', { defaultValue: 'Skip Intro' })}
|
title={t('player.skip_intro_settings_title', { defaultValue: 'Skip Intro' })}
|
||||||
description={getIntroSourceLabel(settings?.introDbSource || 'theintrodb')}
|
description={t('player.powered_by_introdb', { defaultValue: 'Powered by IntroDB' })}
|
||||||
customIcon={introDbLogoIcon}
|
customIcon={introDbLogoIcon}
|
||||||
renderControl={() => (
|
renderControl={() => (
|
||||||
<CustomSwitch
|
<CustomSwitch
|
||||||
|
|
@ -277,19 +220,9 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
||||||
onValueChange={(value) => updateSetting('skipIntroEnabled', value)}
|
onValueChange={(value) => updateSetting('skipIntroEnabled', value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
isLast
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
/>
|
/>
|
||||||
{settings?.skipIntroEnabled && (
|
|
||||||
<SettingItem
|
|
||||||
title="Intro Source"
|
|
||||||
description={`Using ${getIntroSourceLabel(settings?.introDbSource || 'theintrodb')}`}
|
|
||||||
icon="database"
|
|
||||||
renderControl={() => <ChevronRight />}
|
|
||||||
onPress={openIntroSourceSheet}
|
|
||||||
isLast
|
|
||||||
isTablet={isTablet}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
{/* Audio & Subtitle Preferences */}
|
{/* Audio & Subtitle Preferences */}
|
||||||
|
|
@ -509,67 +442,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
||||||
})}
|
})}
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
|
|
||||||
{/* Intro Source Bottom Sheet */}
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={introSourceSheetRef}
|
|
||||||
index={0}
|
|
||||||
snapPoints={introSourceSnapPoints}
|
|
||||||
enableDynamicSizing={false}
|
|
||||||
enablePanDownToClose={true}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
backgroundStyle={{ backgroundColor: '#1a1a1a' }}
|
|
||||||
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
|
|
||||||
>
|
|
||||||
<View style={styles.sheetHeader}>
|
|
||||||
<Text style={styles.sheetTitle}>Skip Intro Source</Text>
|
|
||||||
</View>
|
|
||||||
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
|
|
||||||
{[
|
|
||||||
{ value: 'theintrodb', label: 'TheIntroDB', description: 'theintrodb.org - Supports skip recap and end credits if available', logo: THEINTRODB_FAVICON_URI },
|
|
||||||
{ value: 'introdb', label: 'IntroDB', description: 'Skip Intro database by introdb.app', logo: INTRODB_LOGO_URI }
|
|
||||||
].map((option) => {
|
|
||||||
const isSelected = option.value === (settings?.introDbSource || 'theintrodb');
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={option.value}
|
|
||||||
style={[
|
|
||||||
styles.sourceItem,
|
|
||||||
isSelected && { backgroundColor: currentTheme.colors.primary + '20', borderColor: currentTheme.colors.primary }
|
|
||||||
]}
|
|
||||||
onPress={() => handleSelectIntroSource(option.value as 'theintrodb' | 'introdb')}
|
|
||||||
>
|
|
||||||
<View style={styles.sourceItemContent}>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
||||||
{option.value === 'theintrodb' ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: option.logo }}
|
|
||||||
style={{ width: 20, height: 20 }}
|
|
||||||
resizeMode="contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
introDbLogoXml ? (
|
|
||||||
<SvgXml xml={introDbLogoXml} width={28} height={18} />
|
|
||||||
) : (
|
|
||||||
<MaterialIcons name="skip-next" size={18} color={currentTheme.colors.primary} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<Text style={[styles.sourceLabel, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
|
|
||||||
{option.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.sourceDescription}>
|
|
||||||
{option.description}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{isSelected && (
|
|
||||||
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</BottomSheetScrollView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -224,10 +224,7 @@ export const useStreamsScreen = () => {
|
||||||
|
|
||||||
const getProviderPriority = (addonId: string): number => {
|
const getProviderPriority = (addonId: string): number => {
|
||||||
const installedAddons = stremioService.getInstalledAddons();
|
const installedAddons = stremioService.getInstalledAddons();
|
||||||
// Check installationId first, then id for backward compatibility
|
const addonIndex = installedAddons.findIndex(addon => addon.id === addonId);
|
||||||
const addonIndex = installedAddons.findIndex(addon =>
|
|
||||||
(addon.installationId && addon.installationId === addonId) || addon.id === addonId
|
|
||||||
);
|
|
||||||
if (addonIndex !== -1) {
|
if (addonIndex !== -1) {
|
||||||
return 50 - addonIndex;
|
return 50 - addonIndex;
|
||||||
}
|
}
|
||||||
|
|
@ -763,9 +760,7 @@ export const useStreamsScreen = () => {
|
||||||
const pluginProviders: string[] = [];
|
const pluginProviders: string[] = [];
|
||||||
|
|
||||||
Array.from(allProviders).forEach(provider => {
|
Array.from(allProviders).forEach(provider => {
|
||||||
const isInstalledAddon = installedAddons.some(addon =>
|
const isInstalledAddon = installedAddons.some(addon => addon.installationId === provider || addon.id === provider);
|
||||||
(addon.installationId && addon.installationId === provider) || addon.id === provider
|
|
||||||
);
|
|
||||||
if (isInstalledAddon) {
|
if (isInstalledAddon) {
|
||||||
addonProviders.push(provider);
|
addonProviders.push(provider);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -777,19 +772,20 @@ export const useStreamsScreen = () => {
|
||||||
|
|
||||||
addonProviders
|
addonProviders
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const indexA = installedAddons.findIndex(addon =>
|
const indexA = installedAddons.findIndex(addon => addon.installationId === a || addon.id === a);
|
||||||
(addon.installationId && addon.installationId === a) || addon.id === a
|
const indexB = installedAddons.findIndex(addon => addon.installationId === b || addon.id === b);
|
||||||
);
|
|
||||||
const indexB = installedAddons.findIndex(addon =>
|
|
||||||
(addon.installationId && addon.installationId === b) || addon.id === b
|
|
||||||
);
|
|
||||||
return indexA - indexB;
|
return indexA - indexB;
|
||||||
})
|
})
|
||||||
.forEach(provider => {
|
.forEach(provider => {
|
||||||
const installedAddon = installedAddons.find(addon =>
|
const installedAddon = installedAddons.find(addon => addon.installationId === provider || addon.id === provider);
|
||||||
(addon.installationId && addon.installationId === provider) || addon.id === provider
|
// For multiple installations of same addon, show URL to differentiate
|
||||||
);
|
const sameAddonInstallations = installedAddons.filter(a => installedAddon && a.id === installedAddon.id);
|
||||||
filterChips.push({ id: provider, name: installedAddon?.name || provider });
|
const hasMultiple = sameAddonInstallations.length > 1;
|
||||||
|
const installationNumber = hasMultiple ? sameAddonInstallations.findIndex(a => a.installationId === installedAddon?.installationId) + 1 : 0;
|
||||||
|
const displayName = hasMultiple && installationNumber > 0
|
||||||
|
? `${installedAddon?.name} #${installationNumber}`
|
||||||
|
: (installedAddon?.name || provider);
|
||||||
|
filterChips.push({ id: provider, name: displayName });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group plugins by repository
|
// Group plugins by repository
|
||||||
|
|
@ -818,12 +814,8 @@ export const useStreamsScreen = () => {
|
||||||
{ id: 'all', name: 'All Providers' },
|
{ id: 'all', name: 'All Providers' },
|
||||||
...Array.from(allProviders)
|
...Array.from(allProviders)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const indexA = installedAddons.findIndex(addon =>
|
const indexA = installedAddons.findIndex(addon => addon.installationId === a || addon.id === a);
|
||||||
(addon.installationId && addon.installationId === a) || addon.id === a
|
const indexB = installedAddons.findIndex(addon => addon.installationId === b || addon.id === b);
|
||||||
);
|
|
||||||
const indexB = installedAddons.findIndex(addon =>
|
|
||||||
(addon.installationId && addon.installationId === b) || addon.id === b
|
|
||||||
);
|
|
||||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||||
if (indexA !== -1) return -1;
|
if (indexA !== -1) return -1;
|
||||||
if (indexB !== -1) return 1;
|
if (indexB !== -1) return 1;
|
||||||
|
|
@ -831,11 +823,17 @@ export const useStreamsScreen = () => {
|
||||||
})
|
})
|
||||||
.map(provider => {
|
.map(provider => {
|
||||||
const addonInfo = streams[provider];
|
const addonInfo = streams[provider];
|
||||||
const installedAddon = installedAddons.find(addon =>
|
const installedAddon = installedAddons.find(addon => addon.installationId === provider || addon.id === provider);
|
||||||
(addon.installationId && addon.installationId === provider) || addon.id === provider
|
|
||||||
);
|
|
||||||
let displayName = provider;
|
let displayName = provider;
|
||||||
if (installedAddon) displayName = installedAddon.name;
|
if (installedAddon) {
|
||||||
|
// For multiple installations of same addon, show # to differentiate
|
||||||
|
const sameAddonInstallations = installedAddons.filter(a => a.id === installedAddon.id);
|
||||||
|
const hasMultiple = sameAddonInstallations.length > 1;
|
||||||
|
const installationNumber = hasMultiple ? sameAddonInstallations.findIndex(a => a.installationId === installedAddon.installationId) + 1 : 0;
|
||||||
|
displayName = hasMultiple && installationNumber > 0
|
||||||
|
? `${installedAddon.name} #${installationNumber}`
|
||||||
|
: installedAddon.name;
|
||||||
|
}
|
||||||
else if (addonInfo?.addonName) displayName = addonInfo.addonName;
|
else if (addonInfo?.addonName) displayName = addonInfo.addonName;
|
||||||
return { id: provider, name: displayName };
|
return { id: provider, name: displayName };
|
||||||
}),
|
}),
|
||||||
|
|
@ -847,7 +845,7 @@ export const useStreamsScreen = () => {
|
||||||
const streams = selectedEpisode ? episodeStreams : groupedStreams;
|
const streams = selectedEpisode ? episodeStreams : groupedStreams;
|
||||||
const installedAddons = stremioService.getInstalledAddons();
|
const installedAddons = stremioService.getInstalledAddons();
|
||||||
|
|
||||||
const filteredEntries = Object.entries(streams).filter(([addonId]) => {
|
const filteredEntries = Object.entries(streams).filter(([key]) => {
|
||||||
if (selectedProvider === 'all') return true;
|
if (selectedProvider === 'all') return true;
|
||||||
|
|
||||||
// Handle repository-based filtering (repo-{repoId})
|
// Handle repository-based filtering (repo-{repoId})
|
||||||
|
|
@ -855,33 +853,27 @@ export const useStreamsScreen = () => {
|
||||||
const repoId = selectedProvider.replace('repo-', '');
|
const repoId = selectedProvider.replace('repo-', '');
|
||||||
if (!repoId) return false;
|
if (!repoId) return false;
|
||||||
|
|
||||||
const isInstalledAddon = installedAddons.some(addon => addon.id === addonId);
|
const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key);
|
||||||
if (isInstalledAddon) return false; // Not a plugin
|
if (isInstalledAddon) return false; // Not a plugin
|
||||||
|
|
||||||
// Check if this plugin belongs to the selected repository
|
// Check if this plugin belongs to the selected repository
|
||||||
const repoInfo = localScraperService.getScraperRepository(addonId);
|
const repoInfo = localScraperService.getScraperRepository(key);
|
||||||
return !!(repoInfo && (repoInfo.id === repoId || repoInfo.id?.toLowerCase() === repoId?.toLowerCase()));
|
return !!(repoInfo && (repoInfo.id === repoId || repoInfo.id?.toLowerCase() === repoId?.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: handle old grouped-plugins filter (fallback)
|
// Legacy: handle old grouped-plugins filter (fallback)
|
||||||
if (settings.streamDisplayMode === 'grouped' && selectedProvider === 'grouped-plugins') {
|
if (settings.streamDisplayMode === 'grouped' && selectedProvider === 'grouped-plugins') {
|
||||||
const isInstalledAddon = installedAddons.some(addon =>
|
const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key);
|
||||||
(addon.installationId && addon.installationId === addonId) || addon.id === addonId
|
|
||||||
);
|
|
||||||
return !isInstalledAddon;
|
return !isInstalledAddon;
|
||||||
}
|
}
|
||||||
|
|
||||||
return addonId === selectedProvider;
|
return key === selectedProvider;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort entries: installed addons first (in their installation order), then plugins
|
// Sort entries: installed addons first (in their installation order), then plugins
|
||||||
const sortedEntries = filteredEntries.sort(([addonIdA], [addonIdB]) => {
|
const sortedEntries = filteredEntries.sort(([keyA], [keyB]) => {
|
||||||
const isAddonA = installedAddons.some(addon =>
|
const isAddonA = installedAddons.some(addon => addon.installationId === keyA || addon.id === keyA);
|
||||||
(addon.installationId && addon.installationId === addonIdA) || addon.id === addonIdA
|
const isAddonB = installedAddons.some(addon => addon.installationId === keyB || addon.id === keyB);
|
||||||
);
|
|
||||||
const isAddonB = installedAddons.some(addon =>
|
|
||||||
(addon.installationId && addon.installationId === addonIdB) || addon.id === addonIdB
|
|
||||||
);
|
|
||||||
|
|
||||||
// Addons always come before plugins
|
// Addons always come before plugins
|
||||||
if (isAddonA && !isAddonB) return -1;
|
if (isAddonA && !isAddonB) return -1;
|
||||||
|
|
@ -889,18 +881,14 @@ export const useStreamsScreen = () => {
|
||||||
|
|
||||||
// Both are addons - sort by installation order
|
// Both are addons - sort by installation order
|
||||||
if (isAddonA && isAddonB) {
|
if (isAddonA && isAddonB) {
|
||||||
const indexA = installedAddons.findIndex(addon =>
|
const indexA = installedAddons.findIndex(addon => addon.installationId === keyA || addon.id === keyA);
|
||||||
(addon.installationId && addon.installationId === addonIdA) || addon.id === addonIdA
|
const indexB = installedAddons.findIndex(addon => addon.installationId === keyB || addon.id === keyB);
|
||||||
);
|
|
||||||
const indexB = installedAddons.findIndex(addon =>
|
|
||||||
(addon.installationId && addon.installationId === addonIdB) || addon.id === addonIdB
|
|
||||||
);
|
|
||||||
return indexA - indexB;
|
return indexA - indexB;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both are plugins - sort by response order
|
// Both are plugins - sort by response order
|
||||||
const responseIndexA = addonResponseOrder.indexOf(addonIdA);
|
const responseIndexA = addonResponseOrder.indexOf(keyA);
|
||||||
const responseIndexB = addonResponseOrder.indexOf(addonIdB);
|
const responseIndexB = addonResponseOrder.indexOf(keyB);
|
||||||
if (responseIndexA !== -1 && responseIndexB !== -1) return responseIndexA - responseIndexB;
|
if (responseIndexA !== -1 && responseIndexB !== -1) return responseIndexA - responseIndexB;
|
||||||
if (responseIndexA !== -1) return -1;
|
if (responseIndexA !== -1) return -1;
|
||||||
if (responseIndexB !== -1) return 1;
|
if (responseIndexB !== -1) return 1;
|
||||||
|
|
@ -911,10 +899,8 @@ export const useStreamsScreen = () => {
|
||||||
const addonStreams: Stream[] = [];
|
const addonStreams: Stream[] = [];
|
||||||
const pluginStreams: Stream[] = [];
|
const pluginStreams: Stream[] = [];
|
||||||
|
|
||||||
sortedEntries.forEach(([addonId, { streams: providerStreams }]) => {
|
sortedEntries.forEach(([key, { streams: providerStreams }]) => {
|
||||||
const isInstalledAddon = installedAddons.some(addon =>
|
const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key);
|
||||||
(addon.installationId && addon.installationId === addonId) || addon.id === addonId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isInstalledAddon) {
|
if (isInstalledAddon) {
|
||||||
addonStreams.push(...providerStreams);
|
addonStreams.push(...providerStreams);
|
||||||
|
|
@ -960,10 +946,9 @@ export const useStreamsScreen = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortedEntries
|
return sortedEntries
|
||||||
.map(([addonId, { addonName, streams: providerStreams }]) => {
|
.map(([key, { addonName, streams: providerStreams }]) => {
|
||||||
const isInstalledAddon = installedAddons.some(addon =>
|
const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key);
|
||||||
(addon.installationId && addon.installationId === addonId) || addon.id === addonId
|
const installedAddon = installedAddons.find(addon => addon.installationId === key || addon.id === key);
|
||||||
);
|
|
||||||
let filteredStreams = providerStreams;
|
let filteredStreams = providerStreams;
|
||||||
|
|
||||||
if (!isInstalledAddon) {
|
if (!isInstalledAddon) {
|
||||||
|
|
@ -978,9 +963,20 @@ export const useStreamsScreen = () => {
|
||||||
processedStreams = sortStreamsByQuality(filteredStreams);
|
processedStreams = sortStreamsByQuality(filteredStreams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For multiple installations of same addon, add # to section title
|
||||||
|
let sectionTitle = addonName;
|
||||||
|
if (installedAddon) {
|
||||||
|
const sameAddonInstallations = installedAddons.filter(a => a.id === installedAddon.id);
|
||||||
|
const hasMultiple = sameAddonInstallations.length > 1;
|
||||||
|
const installationNumber = hasMultiple ? sameAddonInstallations.findIndex(a => a.installationId === installedAddon.installationId) + 1 : 0;
|
||||||
|
sectionTitle = hasMultiple && installationNumber > 0
|
||||||
|
? `${addonName} #${installationNumber}`
|
||||||
|
: addonName;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: addonName,
|
title: sectionTitle,
|
||||||
addonId,
|
addonId: key,
|
||||||
data: processedStreams,
|
data: processedStreams,
|
||||||
isEmptyDueToQualityFilter: false,
|
isEmptyDueToQualityFilter: false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { tmdbService } from './tmdbService';
|
import { tmdbService } from './tmdbService';
|
||||||
import { mmkvStorage } from './mmkvStorage';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IntroDB API service for fetching TV show intro timestamps
|
* IntroDB API service for fetching TV show intro timestamps
|
||||||
|
|
@ -9,7 +8,6 @@ import { mmkvStorage } from './mmkvStorage';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const INTRODB_API_URL = process.env.EXPO_PUBLIC_INTRODB_API_URL;
|
const INTRODB_API_URL = process.env.EXPO_PUBLIC_INTRODB_API_URL;
|
||||||
const THEINTRODB_API_URL = 'https://api.theintrodb.org/v1';
|
|
||||||
const ANISKIP_API_URL = 'https://api.aniskip.com/v2';
|
const ANISKIP_API_URL = 'https://api.aniskip.com/v2';
|
||||||
const KITSU_API_URL = 'https://kitsu.io/api/edge';
|
const KITSU_API_URL = 'https://kitsu.io/api/edge';
|
||||||
const ARM_IMDB_URL = 'https://arm.haglund.dev/api/v2/imdb';
|
const ARM_IMDB_URL = 'https://arm.haglund.dev/api/v2/imdb';
|
||||||
|
|
@ -20,31 +18,10 @@ export interface SkipInterval {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
type: SkipType;
|
type: SkipType;
|
||||||
provider: 'introdb' | 'aniskip' | 'theintrodb';
|
provider: 'introdb' | 'aniskip';
|
||||||
skipId?: string;
|
skipId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreditsInfo {
|
|
||||||
startTime: number | null;
|
|
||||||
endTime: number | null;
|
|
||||||
confidence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TheIntroDBTimestamp {
|
|
||||||
start_ms: number | null;
|
|
||||||
end_ms: number | null;
|
|
||||||
confidence: number;
|
|
||||||
submission_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TheIntroDBResponse {
|
|
||||||
tmdb_id: number;
|
|
||||||
type: 'movie' | 'tv';
|
|
||||||
intro?: TheIntroDBTimestamp;
|
|
||||||
recap?: TheIntroDBTimestamp;
|
|
||||||
credits?: TheIntroDBTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntroTimestamps {
|
export interface IntroTimestamps {
|
||||||
imdb_id: string;
|
imdb_id: string;
|
||||||
season: number;
|
season: number;
|
||||||
|
|
@ -175,75 +152,6 @@ async function fetchFromAniSkip(malId: string, episode: number): Promise<SkipInt
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFromTheIntroDb(
|
|
||||||
tmdbId: number,
|
|
||||||
type: 'movie' | 'tv',
|
|
||||||
season?: number,
|
|
||||||
episode?: number
|
|
||||||
): Promise<{ intervals: SkipInterval[], credits: CreditsInfo | null }> {
|
|
||||||
try {
|
|
||||||
const params: any = { tmdb_id: tmdbId };
|
|
||||||
if (type === 'tv' && season !== undefined && episode !== undefined) {
|
|
||||||
params.season = season;
|
|
||||||
params.episode = episode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.get<TheIntroDBResponse>(`${THEINTRODB_API_URL}/media`, {
|
|
||||||
params,
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const intervals: SkipInterval[] = [];
|
|
||||||
let credits: CreditsInfo | null = null;
|
|
||||||
|
|
||||||
// Add intro skip interval if available
|
|
||||||
if (response.data.intro && response.data.intro.end_ms !== null) {
|
|
||||||
intervals.push({
|
|
||||||
startTime: response.data.intro.start_ms !== null ? response.data.intro.start_ms / 1000 : 0,
|
|
||||||
endTime: response.data.intro.end_ms / 1000,
|
|
||||||
type: 'intro',
|
|
||||||
provider: 'theintrodb'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add recap skip interval if available
|
|
||||||
if (response.data.recap && response.data.recap.start_ms !== null && response.data.recap.end_ms !== null) {
|
|
||||||
intervals.push({
|
|
||||||
startTime: response.data.recap.start_ms / 1000,
|
|
||||||
endTime: response.data.recap.end_ms / 1000,
|
|
||||||
type: 'recap',
|
|
||||||
provider: 'theintrodb'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store credits info for next episode button timing
|
|
||||||
if (response.data.credits && response.data.credits.start_ms !== null) {
|
|
||||||
credits = {
|
|
||||||
startTime: response.data.credits.start_ms / 1000,
|
|
||||||
endTime: response.data.credits.end_ms !== null ? response.data.credits.end_ms / 1000 : null,
|
|
||||||
confidence: response.data.credits.confidence
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intervals.length > 0 || credits) {
|
|
||||||
logger.log(`[IntroService] TheIntroDB found data for TMDB ${tmdbId}:`, {
|
|
||||||
intervals: intervals.length,
|
|
||||||
hasCredits: !!credits
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { intervals, credits };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
|
||||||
logger.log(`[IntroService] No TheIntroDB data for TMDB ${tmdbId}`);
|
|
||||||
return { intervals: [], credits: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error('[IntroService] Error fetching from TheIntroDB:', error?.message || error);
|
|
||||||
return { intervals: [], credits: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromIntroDb(imdbId: string, season: number, episode: number): Promise<SkipInterval[]> {
|
async function fetchFromIntroDb(imdbId: string, season: number, episode: number): Promise<SkipInterval[]> {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<IntroTimestamps>(`${INTRODB_API_URL}/intro`, {
|
const response = await axios.get<IntroTimestamps>(`${INTRODB_API_URL}/intro`, {
|
||||||
|
|
@ -287,50 +195,17 @@ export async function getSkipTimes(
|
||||||
season: number,
|
season: number,
|
||||||
episode: number,
|
episode: number,
|
||||||
malId?: string,
|
malId?: string,
|
||||||
kitsuId?: string,
|
kitsuId?: string
|
||||||
tmdbId?: number,
|
): Promise<SkipInterval[]> {
|
||||||
type?: 'movie' | 'tv'
|
// 1. Try IntroDB (TV Shows) first
|
||||||
): Promise<{ intervals: SkipInterval[], credits: CreditsInfo | null }> {
|
if (imdbId) {
|
||||||
// Get user preference for intro source
|
const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode);
|
||||||
const introDbSource = mmkvStorage.getString('introDbSource') || 'theintrodb';
|
if (introDbIntervals.length > 0) {
|
||||||
|
return introDbIntervals;
|
||||||
if (introDbSource === 'theintrodb') {
|
|
||||||
// User prefers TheIntroDB (new API)
|
|
||||||
// 1. Try TheIntroDB (Primary) - Supports both movies and TV shows
|
|
||||||
if (tmdbId && type) {
|
|
||||||
const theIntroDbResult = await fetchFromTheIntroDb(tmdbId, type, season, episode);
|
|
||||||
if (theIntroDbResult.intervals.length > 0 || theIntroDbResult.credits) {
|
|
||||||
return theIntroDbResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try old IntroDB (Fallback for TV Shows)
|
|
||||||
if (imdbId) {
|
|
||||||
const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode);
|
|
||||||
if (introDbIntervals.length > 0) {
|
|
||||||
return { intervals: introDbIntervals, credits: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// User prefers IntroDB (legacy)
|
|
||||||
// 1. Try old IntroDB first
|
|
||||||
if (imdbId) {
|
|
||||||
const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode);
|
|
||||||
if (introDbIntervals.length > 0) {
|
|
||||||
return { intervals: introDbIntervals, credits: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try TheIntroDB as fallback
|
|
||||||
if (tmdbId && type) {
|
|
||||||
const theIntroDbResult = await fetchFromTheIntroDb(tmdbId, type, season, episode);
|
|
||||||
if (theIntroDbResult.intervals.length > 0 || theIntroDbResult.credits) {
|
|
||||||
return theIntroDbResult;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Try AniSkip (Anime) if we have MAL ID or Kitsu ID
|
// 2. Try AniSkip (Anime) if we have MAL ID or Kitsu ID
|
||||||
let finalMalId = malId;
|
let finalMalId = malId;
|
||||||
|
|
||||||
// If we have Kitsu ID but no MAL ID, try to resolve it
|
// If we have Kitsu ID but no MAL ID, try to resolve it
|
||||||
|
|
@ -357,11 +232,11 @@ export async function getSkipTimes(
|
||||||
const aniSkipIntervals = await fetchFromAniSkip(finalMalId, episode);
|
const aniSkipIntervals = await fetchFromAniSkip(finalMalId, episode);
|
||||||
if (aniSkipIntervals.length > 0) {
|
if (aniSkipIntervals.length > 0) {
|
||||||
logger.log(`[IntroService] Found ${aniSkipIntervals.length} skip intervals from AniSkip`);
|
logger.log(`[IntroService] Found ${aniSkipIntervals.length} skip intervals from AniSkip`);
|
||||||
return { intervals: aniSkipIntervals, credits: null };
|
return aniSkipIntervals;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { intervals: [], credits: null };
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -149,9 +149,9 @@ export interface SubtitleResponse {
|
||||||
addonName: string;
|
addonName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modify the callback signature to include addon ID
|
// Modify the callback signature to include addon ID and installation ID
|
||||||
interface StreamCallback {
|
interface StreamCallback {
|
||||||
(streams: Stream[] | null, addonId: string | null, addonName: string | null, error: Error | null): void;
|
(streams: Stream[] | null, addonId: string | null, addonName: string | null, error: Error | null, installationId?: string | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogFilter {
|
interface CatalogFilter {
|
||||||
|
|
@ -187,6 +187,7 @@ interface ResourceObject {
|
||||||
|
|
||||||
export interface Manifest {
|
export interface Manifest {
|
||||||
id: string;
|
id: string;
|
||||||
|
installationId?: string; // Unique ID for this installation (allows multiple installs of same addon)
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -209,7 +210,6 @@ export interface Manifest {
|
||||||
background?: string; // Background image URL
|
background?: string; // Background image URL
|
||||||
logo?: string; // Logo URL
|
logo?: string; // Logo URL
|
||||||
contactEmail?: string; // Contact email
|
contactEmail?: string; // Contact email
|
||||||
installationId?: string; // Unique ID for this specific installation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config object for addon configuration per protocol
|
// Config object for addon configuration per protocol
|
||||||
|
|
@ -264,8 +264,8 @@ export interface AddonCapabilities {
|
||||||
|
|
||||||
class StremioService {
|
class StremioService {
|
||||||
private static instance: StremioService;
|
private static instance: StremioService;
|
||||||
private installedAddons: Map<string, Manifest> = new Map();
|
private installedAddons: Map<string, Manifest> = new Map(); // Key is installationId
|
||||||
private addonOrder: string[] = [];
|
private addonOrder: string[] = []; // Array of installationIds
|
||||||
private readonly STORAGE_KEY = 'stremio-addons';
|
private readonly STORAGE_KEY = 'stremio-addons';
|
||||||
private readonly ADDON_ORDER_KEY = 'stremio-addon-order';
|
private readonly ADDON_ORDER_KEY = 'stremio-addon-order';
|
||||||
private readonly MAX_CONCURRENT_REQUESTS = 3;
|
private readonly MAX_CONCURRENT_REQUESTS = 3;
|
||||||
|
|
@ -279,6 +279,29 @@ class StremioService {
|
||||||
this.initializationPromise = this.initialize();
|
this.initializationPromise = this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate a unique installation ID for an addon
|
||||||
|
private generateInstallationId(addonId: string): string {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).substring(2, 9);
|
||||||
|
return `${addonId}-${timestamp}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private addonProvidesStreams(manifest: Manifest): boolean {
|
||||||
|
if (!manifest.resources || !Array.isArray(manifest.resources)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest.resources.some(resource => {
|
||||||
|
if (typeof resource === 'string') {
|
||||||
|
return resource === 'stream';
|
||||||
|
} else if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
||||||
|
return (resource as ResourceObject).name === 'stream';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Dynamic validator for content IDs based on installed addon capabilities
|
// Dynamic validator for content IDs based on installed addon capabilities
|
||||||
public async isValidContentId(type: string, id: string | null | undefined): Promise<boolean> {
|
public async isValidContentId(type: string, id: string | null | undefined): Promise<boolean> {
|
||||||
// Ensure addons are initialized before checking types
|
// Ensure addons are initialized before checking types
|
||||||
|
|
@ -420,13 +443,13 @@ class StremioService {
|
||||||
if (storedAddons) {
|
if (storedAddons) {
|
||||||
const parsed = JSON.parse(storedAddons);
|
const parsed = JSON.parse(storedAddons);
|
||||||
|
|
||||||
// Convert to Map
|
// Convert to Map using installationId as key
|
||||||
this.installedAddons = new Map();
|
this.installedAddons = new Map();
|
||||||
for (const addon of parsed) {
|
for (const addon of parsed) {
|
||||||
if (addon && addon.id) {
|
if (addon && addon.id) {
|
||||||
// Migration: Ensure installationId exists
|
// Generate installationId for existing addons that don't have one (migration)
|
||||||
if (!addon.installationId) {
|
if (!addon.installationId) {
|
||||||
addon.installationId = addon.id;
|
addon.installationId = this.generateInstallationId(addon.id);
|
||||||
}
|
}
|
||||||
this.installedAddons.set(addon.installationId, addon);
|
this.installedAddons.set(addon.installationId, addon);
|
||||||
}
|
}
|
||||||
|
|
@ -437,21 +460,19 @@ class StremioService {
|
||||||
const cinemetaId = 'com.linvo.cinemeta';
|
const cinemetaId = 'com.linvo.cinemeta';
|
||||||
const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
|
const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
|
||||||
|
|
||||||
// Check if any installed addon has this ID (using valid values iteration)
|
// Check if Cinemeta is already installed (by checking addon.id, not installationId)
|
||||||
const isCinemetaInstalled = Array.from(this.installedAddons.values()).some(a => a.id === cinemetaId);
|
const hasCinemeta = Array.from(this.installedAddons.values()).some(addon => addon.id === cinemetaId);
|
||||||
|
|
||||||
if (!isCinemetaInstalled && !hasUserRemovedCinemeta) {
|
if (!hasCinemeta && !hasUserRemovedCinemeta) {
|
||||||
try {
|
try {
|
||||||
const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
|
const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
|
||||||
// For default addons, we can use the ID as installationId to keep it clean (or generate one)
|
cinemetaManifest.installationId = this.generateInstallationId(cinemetaId);
|
||||||
// Using ID ensures only one instance of default addon is auto-installed
|
this.installedAddons.set(cinemetaManifest.installationId, cinemetaManifest);
|
||||||
cinemetaManifest.installationId = cinemetaId;
|
|
||||||
this.installedAddons.set(cinemetaId, cinemetaManifest);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fallback to minimal manifest if fetch fails
|
// Fallback to minimal manifest if fetch fails
|
||||||
const fallbackManifest: Manifest = {
|
const fallbackManifest: Manifest = {
|
||||||
id: cinemetaId,
|
id: cinemetaId,
|
||||||
installationId: cinemetaId,
|
installationId: this.generateInstallationId(cinemetaId),
|
||||||
name: 'Cinemeta',
|
name: 'Cinemeta',
|
||||||
version: '3.0.13',
|
version: '3.0.13',
|
||||||
description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.',
|
description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.',
|
||||||
|
|
@ -488,7 +509,7 @@ class StremioService {
|
||||||
configurable: false
|
configurable: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.installedAddons.set(cinemetaId, fallbackManifest);
|
this.installedAddons.set(fallbackManifest.installationId!, fallbackManifest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,17 +517,18 @@ class StremioService {
|
||||||
const opensubsId = 'org.stremio.opensubtitlesv3';
|
const opensubsId = 'org.stremio.opensubtitlesv3';
|
||||||
const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId);
|
const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId);
|
||||||
|
|
||||||
const isOpenSubsInstalled = Array.from(this.installedAddons.values()).some(a => a.id === opensubsId);
|
// Check if OpenSubtitles is already installed (by checking addon.id, not installationId)
|
||||||
|
const hasOpenSubs = Array.from(this.installedAddons.values()).some(addon => addon.id === opensubsId);
|
||||||
|
|
||||||
if (!isOpenSubsInstalled && !hasUserRemovedOpenSubtitles) {
|
if (!hasOpenSubs && !hasUserRemovedOpenSubtitles) {
|
||||||
try {
|
try {
|
||||||
const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json');
|
const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json');
|
||||||
opensubsManifest.installationId = opensubsId;
|
opensubsManifest.installationId = this.generateInstallationId(opensubsId);
|
||||||
this.installedAddons.set(opensubsId, opensubsManifest);
|
this.installedAddons.set(opensubsManifest.installationId, opensubsManifest);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fallbackManifest: Manifest = {
|
const fallbackManifest: Manifest = {
|
||||||
id: opensubsId,
|
id: opensubsId,
|
||||||
installationId: opensubsId,
|
installationId: this.generateInstallationId(opensubsId),
|
||||||
name: 'OpenSubtitles v3',
|
name: 'OpenSubtitles v3',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'OpenSubtitles v3 Addon for Stremio',
|
description: 'OpenSubtitles v3 Addon for Stremio',
|
||||||
|
|
@ -525,7 +547,7 @@ class StremioService {
|
||||||
configurable: false
|
configurable: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.installedAddons.set(opensubsId, fallbackManifest);
|
this.installedAddons.set(fallbackManifest.installationId!, fallbackManifest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -535,27 +557,30 @@ class StremioService {
|
||||||
if (!storedOrder) storedOrder = await mmkvStorage.getItem(`@user:local:${this.ADDON_ORDER_KEY}`);
|
if (!storedOrder) storedOrder = await mmkvStorage.getItem(`@user:local:${this.ADDON_ORDER_KEY}`);
|
||||||
if (storedOrder) {
|
if (storedOrder) {
|
||||||
this.addonOrder = JSON.parse(storedOrder);
|
this.addonOrder = JSON.parse(storedOrder);
|
||||||
// Filter out any ids that aren't in installedAddons
|
// Filter out any installationIds that aren't in installedAddons
|
||||||
this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id));
|
this.addonOrder = this.addonOrder.filter(installationId => this.installedAddons.has(installationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Cinemeta to order only if user hasn't removed it and it's installed
|
// Add Cinemeta to order only if user hasn't removed it
|
||||||
const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
|
const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
|
||||||
// We check if the installationId (which is cinemetaId for default) is in the map
|
const cinemetaInstallation = Array.from(this.installedAddons.values()).find(addon => addon.id === cinemetaId);
|
||||||
if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) {
|
if (cinemetaInstallation && cinemetaInstallation.installationId &&
|
||||||
this.addonOrder.push(cinemetaId);
|
!this.addonOrder.includes(cinemetaInstallation.installationId) && !hasUserRemovedCinemetaOrder) {
|
||||||
|
this.addonOrder.push(cinemetaInstallation.installationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only add OpenSubtitles to order if user hasn't removed it
|
// Only add OpenSubtitles to order if user hasn't removed it
|
||||||
const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId);
|
const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId);
|
||||||
if (!this.addonOrder.includes(opensubsId) && this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitlesOrder) {
|
const opensubsInstallation = Array.from(this.installedAddons.values()).find(addon => addon.id === opensubsId);
|
||||||
this.addonOrder.push(opensubsId);
|
if (opensubsInstallation && opensubsInstallation.installationId &&
|
||||||
|
!this.addonOrder.includes(opensubsInstallation.installationId) && !hasUserRemovedOpenSubtitlesOrder) {
|
||||||
|
this.addonOrder.push(opensubsInstallation.installationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any missing addons to the order
|
// Add any missing addons to the order (use installationIds)
|
||||||
const installedIds = Array.from(this.installedAddons.keys());
|
const installedInstallationIds = Array.from(this.installedAddons.keys());
|
||||||
const missingIds = installedIds.filter(id => !this.addonOrder.includes(id));
|
const missingInstallationIds = installedInstallationIds.filter(installationId => !this.addonOrder.includes(installationId));
|
||||||
this.addonOrder = [...this.addonOrder, ...missingIds];
|
this.addonOrder = [...this.addonOrder, ...missingInstallationIds];
|
||||||
|
|
||||||
// Ensure order and addons are saved
|
// Ensure order and addons are saved
|
||||||
await this.saveAddonOrder();
|
await this.saveAddonOrder();
|
||||||
|
|
@ -669,49 +694,58 @@ class StremioService {
|
||||||
async installAddon(url: string): Promise<void> {
|
async installAddon(url: string): Promise<void> {
|
||||||
const manifest = await this.getManifest(url);
|
const manifest = await this.getManifest(url);
|
||||||
if (manifest && manifest.id) {
|
if (manifest && manifest.id) {
|
||||||
// Generate a unique installation ID
|
// Check if this addon is already installed
|
||||||
const installationId = `${manifest.id}-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
const existingInstallations = Array.from(this.installedAddons.values()).filter(a => a.id === manifest.id);
|
||||||
manifest.installationId = installationId;
|
const isAlreadyInstalled = existingInstallations.length > 0;
|
||||||
|
|
||||||
this.installedAddons.set(installationId, manifest);
|
// Only allow multiple installations for stream-providing addons
|
||||||
|
if (isAlreadyInstalled && !this.addonProvidesStreams(manifest)) {
|
||||||
|
throw new Error('This addon is already installed. Multiple installations are only allowed for stream providers.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique installation ID for this installation
|
||||||
|
manifest.installationId = this.generateInstallationId(manifest.id);
|
||||||
|
|
||||||
|
// Store using installationId as key (allows multiple installations of same addon)
|
||||||
|
this.installedAddons.set(manifest.installationId, manifest);
|
||||||
|
|
||||||
// If addon was previously removed by user, unmark it on reinstall and clean up
|
// If addon was previously removed by user, unmark it on reinstall and clean up
|
||||||
await this.unmarkAddonAsRemovedByUser(manifest.id);
|
await this.unmarkAddonAsRemovedByUser(manifest.id);
|
||||||
|
await this.cleanupRemovedAddonFromStorage(manifest.id);
|
||||||
|
|
||||||
// Note: cleanupRemovedAddonFromStorage takes an installationId (from addonOrder context),
|
// Add installationId to order (new addons go to the end)
|
||||||
// but here we are dealing with a new installationId, strictly speaking.
|
if (!this.addonOrder.includes(manifest.installationId)) {
|
||||||
// However, we might want to cleanup any stray legacy entries for this addon ID if we wanted strict uniqueness,
|
this.addonOrder.push(manifest.installationId);
|
||||||
// but we clearly support duplicates now, so we don't need to clean up other instances.
|
}
|
||||||
// We ONLY keep the unmarkAddonAsRemovedByUser to allow re-auto-install of defaults if user manually installs them.
|
|
||||||
|
|
||||||
// Add to order (new addons go to the end)
|
|
||||||
this.addonOrder.push(installationId);
|
|
||||||
|
|
||||||
await this.saveInstalledAddons();
|
await this.saveInstalledAddons();
|
||||||
await this.saveAddonOrder();
|
await this.saveAddonOrder();
|
||||||
// Emit an event that an addon was added
|
// Emit an event that an addon was added (include both ids for compatibility)
|
||||||
addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, installationId);
|
addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, { installationId: manifest.installationId, addonId: manifest.id });
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid addon manifest');
|
throw new Error('Invalid addon manifest');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAddon(installationId: string): Promise<void> {
|
async removeAddon(installationId: string): Promise<void> {
|
||||||
// Allow removal of any addon
|
// Allow removal of any addon installation, including pre-installed ones like Cinemeta
|
||||||
if (this.installedAddons.has(installationId)) {
|
if (this.installedAddons.has(installationId)) {
|
||||||
const addon = this.installedAddons.get(installationId);
|
const addon = this.installedAddons.get(installationId);
|
||||||
this.installedAddons.delete(installationId);
|
this.installedAddons.delete(installationId);
|
||||||
// Remove from order
|
// Remove from order using installationId
|
||||||
this.addonOrder = this.addonOrder.filter(id => id !== installationId);
|
this.addonOrder = this.addonOrder.filter(id => id !== installationId);
|
||||||
|
|
||||||
// Track user explicit removal for the addon ID (tombstone to prevent auto-reinstall of defaults)
|
// Track user explicit removal only if this is the last installation of this addon
|
||||||
if (addon && addon.id) {
|
if (addon) {
|
||||||
await this.markAddonAsRemovedByUser(addon.id);
|
const remainingInstallations = Array.from(this.installedAddons.values()).filter(a => a.id === addon.id);
|
||||||
|
if (remainingInstallations.length === 0) {
|
||||||
|
// This was the last installation, mark addon as removed by user
|
||||||
|
await this.markAddonAsRemovedByUser(addon.id);
|
||||||
|
// Proactively clean up any persisted orders/legacy keys for this addon
|
||||||
|
await this.cleanupRemovedAddonFromStorage(addon.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up this specific installation from storage keys
|
|
||||||
await this.cleanupRemovedAddonFromStorage(installationId);
|
|
||||||
|
|
||||||
// Persist removals before app possibly exits
|
// Persist removals before app possibly exits
|
||||||
await this.saveInstalledAddons();
|
await this.saveInstalledAddons();
|
||||||
await this.saveAddonOrder();
|
await this.saveAddonOrder();
|
||||||
|
|
@ -721,10 +755,10 @@ class StremioService {
|
||||||
}
|
}
|
||||||
|
|
||||||
getInstalledAddons(): Manifest[] {
|
getInstalledAddons(): Manifest[] {
|
||||||
// Return addons in the specified order
|
// Return addons in the specified order (using installationIds)
|
||||||
const result = this.addonOrder
|
const result = this.addonOrder
|
||||||
.filter(id => this.installedAddons.has(id))
|
.filter(installationId => this.installedAddons.has(installationId))
|
||||||
.map(id => this.installedAddons.get(id)!);
|
.map(installationId => this.installedAddons.get(installationId)!);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1434,7 +1468,7 @@ class StremioService {
|
||||||
try {
|
try {
|
||||||
if (!addon.url) {
|
if (!addon.url) {
|
||||||
logger.warn(`⚠️ [getStreams] Addon ${addon.id} has no URL`);
|
logger.warn(`⚠️ [getStreams] Addon ${addon.id} has no URL`);
|
||||||
if (callback) callback(null, addon.installationId || addon.id, addon.name, new Error('Addon has no URL'));
|
if (callback) callback(null, addon.id, addon.name, new Error('Addon has no URL'), addon.installationId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1442,7 +1476,7 @@ class StremioService {
|
||||||
const encodedId = encodeURIComponent(activeId);
|
const encodedId = encodeURIComponent(activeId);
|
||||||
const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`;
|
const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`;
|
||||||
|
|
||||||
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
|
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}) [${addon.installationId}]: ${url}`);
|
||||||
|
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(url, safeAxiosConfig);
|
return await axios.get(url, safeAxiosConfig);
|
||||||
|
|
@ -1450,22 +1484,21 @@ class StremioService {
|
||||||
|
|
||||||
let processedStreams: Stream[] = [];
|
let processedStreams: Stream[] = [];
|
||||||
if (response.data && response.data.streams) {
|
if (response.data && response.data.streams) {
|
||||||
logger.log(`✅ [getStreams] Got ${response.data.streams.length} streams from ${addon.name} (${addon.id})`);
|
logger.log(`✅ [getStreams] Got ${response.data.streams.length} streams from ${addon.name} (${addon.id}) [${addon.installationId}]`);
|
||||||
processedStreams = this.processStreams(response.data.streams, addon);
|
processedStreams = this.processStreams(response.data.streams, addon);
|
||||||
logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id})`);
|
logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id}) [${addon.installationId}]`);
|
||||||
} else {
|
} else {
|
||||||
logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id})`);
|
logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id}) [${addon.installationId}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
// Call callback with processed streams (can be empty array)
|
// Call callback with processed streams (can be empty array), include installationId
|
||||||
// Use installationId if available to distinct between multiple installations of same addon
|
callback(processedStreams, addon.id, addon.name, null, addon.installationId);
|
||||||
callback(processedStreams, addon.installationId || addon.id, addon.name, null);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
// Call callback with error
|
// Call callback with error, include installationId
|
||||||
callback(null, addon.installationId || addon.id, addon.name, error as Error);
|
callback(null, addon.id, addon.name, error as Error, addon.installationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})(); // Immediately invoke the async function
|
})(); // Immediately invoke the async function
|
||||||
|
|
@ -1646,7 +1679,7 @@ class StremioService {
|
||||||
name: name,
|
name: name,
|
||||||
title: displayTitle,
|
title: displayTitle,
|
||||||
addonName: addon.name,
|
addonName: addon.name,
|
||||||
addonId: addon.installationId || addon.id,
|
addonId: addon.id,
|
||||||
|
|
||||||
// Include description as-is to preserve full details
|
// Include description as-is to preserve full details
|
||||||
description: stream.description,
|
description: stream.description,
|
||||||
|
|
@ -1837,9 +1870,9 @@ class StremioService {
|
||||||
return deduped;
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add methods to move addons in the order
|
// Add methods to move addons in the order (using installationIds)
|
||||||
moveAddonUp(id: string): boolean {
|
moveAddonUp(installationId: string): boolean {
|
||||||
const index = this.addonOrder.indexOf(id);
|
const index = this.addonOrder.indexOf(installationId);
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
// Swap with the previous item
|
// Swap with the previous item
|
||||||
[this.addonOrder[index - 1], this.addonOrder[index]] =
|
[this.addonOrder[index - 1], this.addonOrder[index]] =
|
||||||
|
|
@ -1852,8 +1885,8 @@ class StremioService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
moveAddonDown(id: string): boolean {
|
moveAddonDown(installationId: string): boolean {
|
||||||
const index = this.addonOrder.indexOf(id);
|
const index = this.addonOrder.indexOf(installationId);
|
||||||
if (index >= 0 && index < this.addonOrder.length - 1) {
|
if (index >= 0 && index < this.addonOrder.length - 1) {
|
||||||
// Swap with the next item
|
// Swap with the next item
|
||||||
[this.addonOrder[index], this.addonOrder[index + 1]] =
|
[this.addonOrder[index], this.addonOrder[index + 1]] =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue