diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 82d52009..82097f41 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -31,7 +31,7 @@ import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUt import { styles } from './utils/playerStyles'; import { SubtitleModals } from './modals/SubtitleModals'; import { AudioTrackModal } from './modals/AudioTrackModal'; -import ResumeOverlay from './modals/ResumeOverlay'; +// Removed ResumeOverlay usage when alwaysResume is enabled import PlayerControls from './controls/PlayerControls'; import CustomSubtitles from './subtitles/CustomSubtitles'; import { SourcesModal } from './modals/SourcesModal'; @@ -118,6 +118,9 @@ const AndroidVideoPlayer: React.FC = () => { const [showResumeOverlay, setShowResumeOverlay] = useState(false); const [resumePosition, setResumePosition] = useState(null); const [savedDuration, setSavedDuration] = useState(null); + const initialSeekTargetRef = useRef(null); + const initialSeekVerifiedRef = useRef(false); + const isSourceSeekableRef = useRef(null); const fadeAnim = useRef(new Animated.Value(1)).current; const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); const openingFadeAnim = useRef(new Animated.Value(0)).current; @@ -185,12 +188,9 @@ const AndroidVideoPlayer: React.FC = () => { const errorTimeoutRef = useRef(null); // Get metadata to access logo (only if we have a valid id) const shouldLoadMetadata = Boolean(id && type); - const metadataResult = useMetadata({ - id: id || 'placeholder', - type: type || 'movie' - }); + const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) }); + const { settings: appSettings } = useSettings(); const { metadata, loading: metadataLoading } = shouldLoadMetadata ? metadataResult : { metadata: null, loading: false }; - const { settings } = useSettings(); // Logo animation values const logoScaleAnim = useRef(new Animated.Value(0.8)).current; @@ -369,10 +369,17 @@ const AndroidVideoPlayer: React.FC = () => { if (progressPercent < 85) { setResumePosition(savedProgress.currentTime); - setSavedDuration(savedProgress.duration); - logger.log(`[AndroidVideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`); - setShowResumeOverlay(true); - logger.log(`[AndroidVideoPlayer] Showing resume overlay`); + setSavedDuration(savedProgress.duration); + setInitialPosition(savedProgress.currentTime); + initialSeekTargetRef.current = savedProgress.currentTime; + logger.log(`[AndroidVideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`); + if (appSettings.alwaysResume) { + logger.log(`[AndroidVideoPlayer] AlwaysResume enabled. Auto-seeking to ${savedProgress.currentTime}`); + seekToTime(savedProgress.currentTime); + } else { + setShowResumeOverlay(true); + logger.log(`[AndroidVideoPlayer] Showing resume overlay`); + } } else { logger.log(`[AndroidVideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`); } @@ -387,7 +394,7 @@ const AndroidVideoPlayer: React.FC = () => { } }; loadWatchProgress(); - }, [id, type, episodeId]); + }, [id, type, episodeId, appSettings.alwaysResume]); const saveWatchProgress = async () => { if (id && type && currentTime > 0 && duration > 0) { @@ -944,7 +951,7 @@ const AndroidVideoPlayer: React.FC = () => { } setIsLoadingSubtitleList(true); try { - // Fetch from installed OpenSubtitles v3 addon via Stremio only + // Fetch from all installed subtitle-capable addons via Stremio const stremioType = type === 'series' ? 'series' : 'movie'; const stremioVideoId = stremioType === 'series' && season && episode ? `series:${targetImdbId}:${season}:${episode}` @@ -956,37 +963,28 @@ const AndroidVideoPlayer: React.FC = () => { flagUrl: '', format: 'srt', encoding: 'utf-8', - media: 'opensubtitles', + media: sub.addonName || sub.addon || '', display: sub.lang || 'Unknown', language: (sub.lang || '').toLowerCase(), isHearingImpaired: false, - source: sub.addonName || 'OpenSubtitles v3', + source: sub.addonName || sub.addon || 'Addon', })); - - // De-duplicate by language - const uniqueSubtitles = stremioSubs.reduce((acc, current) => { - const exists = acc.find(item => item.language === current.language); - if (!exists) { - acc.push(current); - } - return acc; - }, [] as WyzieSubtitle[]); - // Sort with English languages first, then alphabetical + // Sort with English languages first, then alphabetical over full list const isEnglish = (s: WyzieSubtitle) => { const lang = (s.language || '').toLowerCase(); const disp = (s.display || '').toLowerCase(); return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english'); }; - uniqueSubtitles.sort((a, b) => { + stremioSubs.sort((a, b) => { const aIsEn = isEnglish(a); const bIsEn = isEnglish(b); if (aIsEn && !bIsEn) return -1; if (!aIsEn && bIsEn) return 1; return (a.display || '').localeCompare(b.display || ''); }); - setAvailableSubtitles(uniqueSubtitles); + setAvailableSubtitles(stremioSubs); if (autoSelectEnglish) { - const englishSubtitle = uniqueSubtitles.find(sub => + const englishSubtitle = stremioSubs.find(sub => sub.language.toLowerCase() === 'eng' || sub.language.toLowerCase() === 'en' || sub.display.toLowerCase().includes('english') @@ -1333,6 +1331,32 @@ const AndroidVideoPlayer: React.FC = () => { } }; + useEffect(() => { + if (isVideoLoaded && initialPosition && !isInitialSeekComplete && duration > 0) { + logger.log(`[AndroidVideoPlayer] Post-load initial seek to: ${initialPosition}s`); + seekToTime(initialPosition); + setIsInitialSeekComplete(true); + // Verify whether the seek actually took effect (detect non-seekable sources) + if (!initialSeekVerifiedRef.current) { + initialSeekVerifiedRef.current = true; + const target = initialSeekTargetRef.current ?? initialPosition; + setTimeout(() => { + const delta = Math.abs(currentTime - (target || 0)); + if (target && (currentTime < target - 1.5)) { + logger.warn(`[AndroidVideoPlayer] Initial seek appears ignored (delta=${delta.toFixed(2)}). Treating source as non-seekable; starting from 0`); + isSourceSeekableRef.current = false; + // Reset resume intent and continue from 0 + setInitialPosition(null); + setResumePosition(null); + setShowResumeOverlay(false); + } else { + isSourceSeekableRef.current = true; + } + }, 1200); + } + } + }, [isVideoLoaded, initialPosition, duration]); + return ( { lineHeightMultiplier={subtitleLineHeightMultiplier} /> - + {/* Resume overlay removed when AlwaysResume is enabled; overlay component omitted */} diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index b805a3c3..90ca57e0 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -33,7 +33,7 @@ import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUt import { styles } from './utils/playerStyles'; import { SubtitleModals } from './modals/SubtitleModals'; import { AudioTrackModal } from './modals/AudioTrackModal'; -import ResumeOverlay from './modals/ResumeOverlay'; +// Removed ResumeOverlay usage when alwaysResume is enabled import PlayerControls from './controls/PlayerControls'; import CustomSubtitles from './subtitles/CustomSubtitles'; import { SourcesModal } from './modals/SourcesModal'; @@ -95,8 +95,8 @@ const VideoPlayer: React.FC = () => { episodeId: episodeId }); - // Get the Trakt autosync settings to use the user-configured sync frequency - const { settings: traktSettings } = useTraktAutosyncSettings(); + // App settings + const { settings: appSettings } = useSettings(); safeDebugLog("Component mounted with props", { uri, title, season, episode, episodeTitle, quality, year, @@ -401,10 +401,17 @@ const VideoPlayer: React.FC = () => { if (progressPercent < 85) { setResumePosition(savedProgress.currentTime); - setSavedDuration(savedProgress.duration); - logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`); - setShowResumeOverlay(true); - logger.log(`[VideoPlayer] Showing resume overlay`); + setSavedDuration(savedProgress.duration); + setInitialPosition(savedProgress.currentTime); + logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`); + if (appSettings.alwaysResume) { + logger.log(`[VideoPlayer] AlwaysResume enabled. Auto-seeking to ${savedProgress.currentTime}`); + // Seek immediately after load + seekToTime(savedProgress.currentTime); + } else { + setShowResumeOverlay(true); + logger.log(`[VideoPlayer] Showing resume overlay`); + } } else { logger.log(`[VideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`); } @@ -419,7 +426,7 @@ const VideoPlayer: React.FC = () => { } }; loadWatchProgress(); - }, [id, type, episodeId]); + }, [id, type, episodeId, appSettings.alwaysResume]); const saveWatchProgress = async () => { if (id && type && currentTime > 0 && duration > 0) { @@ -919,7 +926,7 @@ const VideoPlayer: React.FC = () => { } setIsLoadingSubtitleList(true); try { - // Fetch from installed OpenSubtitles v3 addon via Stremio only + // Fetch from all installed subtitle-capable addons via Stremio const stremioType = type === 'series' ? 'series' : 'movie'; const stremioVideoId = stremioType === 'series' && season && episode ? `series:${targetImdbId}:${season}:${episode}` @@ -931,37 +938,28 @@ const VideoPlayer: React.FC = () => { flagUrl: '', format: 'srt', encoding: 'utf-8', - media: 'opensubtitles', + media: sub.addonName || sub.addon || '', display: sub.lang || 'Unknown', language: (sub.lang || '').toLowerCase(), isHearingImpaired: false, - source: sub.addonName || 'OpenSubtitles v3', + source: sub.addonName || sub.addon || 'Addon', })); - - // De-duplicate by language - const uniqueSubtitles = stremioSubs.reduce((acc, current) => { - const exists = acc.find(item => item.language === current.language); - if (!exists) { - acc.push(current); - } - return acc; - }, [] as WyzieSubtitle[]); - // Sort with English languages first, then alphabetical + // Sort with English languages first, then alphabetical over full list const isEnglish = (s: WyzieSubtitle) => { const lang = (s.language || '').toLowerCase(); const disp = (s.display || '').toLowerCase(); return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english'); }; - uniqueSubtitles.sort((a, b) => { + stremioSubs.sort((a, b) => { const aIsEn = isEnglish(a); const bIsEn = isEnglish(b); if (aIsEn && !bIsEn) return -1; if (!aIsEn && bIsEn) return 1; return (a.display || '').localeCompare(b.display || ''); }); - setAvailableSubtitles(uniqueSubtitles); + setAvailableSubtitles(stremioSubs); if (autoSelectEnglish) { - const englishSubtitle = uniqueSubtitles.find(sub => + const englishSubtitle = stremioSubs.find(sub => sub.language.toLowerCase() === 'eng' || sub.language.toLowerCase() === 'en' || sub.display.toLowerCase().includes('english') @@ -1277,6 +1275,14 @@ const VideoPlayer: React.FC = () => { } }; + useEffect(() => { + if (isVideoLoaded && initialPosition && !isInitialSeekComplete && duration > 0) { + logger.log(`[VideoPlayer] Post-load initial seek to: ${initialPosition}s`); + seekToTime(initialPosition); + setIsInitialSeekComplete(true); + } + }, [isVideoLoaded, initialPosition, duration]); + return ( { lineHeightMultiplier={subtitleLineHeightMultiplier} /> - + {/* Resume overlay removed when AlwaysResume is enabled; overlay component omitted */} diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx index 8ae92add..cad3b90e 100644 --- a/src/components/player/modals/SubtitleModals.tsx +++ b/src/components/player/modals/SubtitleModals.tsx @@ -102,12 +102,12 @@ export const SubtitleModals: React.FC = ({ subtitleOffsetSec, setSubtitleOffsetSec, }) => { - // Track which specific online subtitle is currently loaded + // Track which specific addon subtitle is currently loaded const [selectedOnlineSubtitleId, setSelectedOnlineSubtitleId] = React.useState(null); - // Track which online subtitle is currently loading to show spinner per-item + // Track which addon subtitle is currently loading to show spinner per-item const [loadingSubtitleId, setLoadingSubtitleId] = React.useState(null); // Active tab for better organization - const [activeTab, setActiveTab] = React.useState<'built-in' | 'online' | 'appearance'>(useCustomSubtitles ? 'online' : 'built-in'); + const [activeTab, setActiveTab] = React.useState<'built-in' | 'addon' | 'appearance'>(useCustomSubtitles ? 'addon' : 'built-in'); // Responsive tuning const isCompact = width < 360 || height < 640; const sectionPad = isCompact ? 12 : 16; @@ -122,7 +122,7 @@ export const SubtitleModals: React.FC = ({ } }, [showSubtitleModal]); - // Reset selected online subtitle when switching to built-in tracks + // Reset selected addon subtitle when switching to built-in tracks React.useEffect(() => { if (!useCustomSubtitles) { setSelectedOnlineSubtitleId(null); @@ -138,7 +138,7 @@ export const SubtitleModals: React.FC = ({ // Keep tab in sync with current usage React.useEffect(() => { - setActiveTab(useCustomSubtitles ? 'online' : 'built-in'); + setActiveTab(useCustomSubtitles ? 'addon' : 'built-in'); }, [useCustomSubtitles]); const handleClose = () => { @@ -214,7 +214,7 @@ export const SubtitleModals: React.FC = ({ Subtitles - {useCustomSubtitles ? 'Online in use' : 'Built‑in in use'} + {useCustomSubtitles ? 'Addon in use' : 'Built‑in in use'} @@ -238,7 +238,7 @@ export const SubtitleModals: React.FC = ({ {([ { key: 'built-in', label: 'Built‑in' }, - { key: 'online', label: 'Online' }, + { key: 'addon', label: 'Addons' }, { key: 'appearance', label: 'Appearance' }, ] as const).map(tab => ( = ({ )} - {activeTab === 'online' && ( + {activeTab === 'addon' && ( = ({ textTransform: 'uppercase', letterSpacing: 0.5, }}> - Online Subtitles + Addon Subtitles = ({ marginTop: 8, textAlign: 'center', }}> - Tap to search online + Tap to fetch from addons ) : isLoadingSubtitleList ? ( @@ -426,7 +426,7 @@ export const SubtitleModals: React.FC = ({ {sub.display} - {formatLanguage(sub.language)} + {formatLanguage(sub.language)}{sub.source ? ` · ${sub.source}` : ''} {(isLoadingSubtitles && loadingSubtitleId === sub.id) ? ( diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index ec13038f..19c6f20e 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -43,9 +43,11 @@ export interface AppSettings { enableScraperUrlValidation: boolean; // Enable/disable URL validation for scrapers streamDisplayMode: 'separate' | 'grouped'; // How to display streaming links - separately by provider or grouped under one name streamSortMode: 'scraper-then-quality' | 'quality-then-scraper'; // How to sort streams - by scraper first or quality first - showScraperLogos: boolean; // Show/hide scraper logos next to streaming links + showScraperLogos: boolean; // Show scraper logos next to streaming links // Quality filtering settings excludedQualities: string[]; // Array of quality strings to exclude (e.g., ['2160p', '4K', '1080p', '720p']) + // Playback behavior + alwaysResume: boolean; // If true, resume automatically without prompt when progress < 85% } export const DEFAULT_SETTINGS: AppSettings = { @@ -74,6 +76,8 @@ export const DEFAULT_SETTINGS: AppSettings = { showScraperLogos: true, // Show scraper logos by default // Quality filtering defaults excludedQualities: [], // No qualities excluded by default + // Playback behavior defaults + alwaysResume: false, }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx index 31a18bab..e61d078f 100644 --- a/src/screens/PlayerSettingsScreen.tsx +++ b/src/screens/PlayerSettingsScreen.tsx @@ -265,18 +265,51 @@ const PlayerSettingsScreen: React.FC = () => { { color: currentTheme.colors.textMuted }, ]} > - Automatically play the highest quality stream when available + Automatically start the highest quality stream available. updateSetting('autoplayBestStream', value)} - trackColor={{ - false: 'rgba(255,255,255,0.2)', - true: currentTheme.colors.primary + '40' - }} - thumbColor={settings.autoplayBestStream ? currentTheme.colors.primary : 'rgba(255,255,255,0.8)'} - ios_backgroundColor="rgba(255,255,255,0.2)" + thumbColor={settings.autoplayBestStream ? currentTheme.colors.primary : undefined} + /> + + + + + + + + + + + Always Resume + + + Skip the resume prompt and automatically continue where you left off (if less than 85% watched). + + + updateSetting('alwaysResume', value)} + thumbColor={settings.alwaysResume ? currentTheme.colors.primary : undefined} /> diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 3feb5f9b..ad5f76d4 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -265,6 +265,40 @@ class StremioService { logger.log('✅ Cinemeta pre-installed with fallback manifest'); } } + + // Ensure OpenSubtitles v3 is always installed as a pre-installed addon + const opensubsId = 'org.stremio.opensubtitlesv3'; + if (!this.installedAddons.has(opensubsId)) { + try { + const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json'); + this.installedAddons.set(opensubsId, opensubsManifest); + logger.log('✅ OpenSubtitles v3 pre-installed as default subtitles addon'); + } catch (error) { + logger.error('Failed to fetch OpenSubtitles manifest, using fallback:', error); + const fallbackManifest: Manifest = { + id: opensubsId, + name: 'OpenSubtitles v3', + version: '1.0.0', + description: 'OpenSubtitles v3 Addon for Stremio', + url: 'https://opensubtitles-v3.strem.io', + originalUrl: 'https://opensubtitles-v3.strem.io/manifest.json', + types: ['movie', 'series'], + catalogs: [], + resources: [ + { + name: 'subtitles', + types: ['movie', 'series'], + idPrefixes: ['tt'] + } + ], + behaviorHints: { + configurable: false + } + }; + this.installedAddons.set(opensubsId, fallbackManifest); + logger.log('✅ OpenSubtitles v3 pre-installed with fallback manifest'); + } + } // Load addon order if exists const storedOrder = await AsyncStorage.getItem(this.ADDON_ORDER_KEY); @@ -285,6 +319,21 @@ class StremioService { this.addonOrder.unshift(cinemetaId); } } + + // Ensure OpenSubtitles v3 is present right after Cinemeta (if not already ordered) + const ensureOpensubsPosition = () => { + const idx = this.addonOrder.indexOf(opensubsId); + const cinIdx = this.addonOrder.indexOf(cinemetaId); + if (idx === -1) { + // Insert after Cinemeta + this.addonOrder.splice(cinIdx + 1, 0, opensubsId); + } else if (idx <= cinIdx) { + // Move it to right after Cinemeta + this.addonOrder.splice(idx, 1); + this.addonOrder.splice(cinIdx + 1, 0, opensubsId); + } + }; + ensureOpensubsPosition(); // Add any missing addons to the order const installedIds = Array.from(this.installedAddons.keys()); @@ -1103,52 +1152,59 @@ class StremioService { async getSubtitles(type: string, id: string, videoId?: string): Promise { await this.ensureInitialized(); - - // Find the OpenSubtitles v3 addon - const openSubtitlesAddon = this.getInstalledAddons().find( - addon => addon.id === 'org.stremio.opensubtitlesv3' - ); - - if (!openSubtitlesAddon) { - logger.warn('OpenSubtitles v3 addon not found'); + // Collect from all installed addons that expose a subtitles resource + const addons = this.getInstalledAddons(); + const subtitleAddons = addons.filter(addon => { + if (!addon.resources) return false; + return addon.resources.some((resource: any) => { + if (typeof resource === 'string') return resource === 'subtitles'; + return resource && resource.name === 'subtitles'; + }); + }); + + if (subtitleAddons.length === 0) { + logger.warn('No subtitle-capable addons installed'); return []; } - - try { - const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || '').baseUrl; - - // Construct the query URL with the correct format - // For series episodes, use the videoId directly which includes series ID + episode info - let url = ''; - if (type === 'series' && videoId) { - // For series, extract the IMDB ID and episode info from videoId (series:tt12345:1:2) - // and construct the proper URL format: /subtitles/series/tt12345:1:2.json - const episodeInfo = videoId.replace('series:', ''); - url = `${baseUrl}/subtitles/series/${episodeInfo}.json`; - } else { - // For movies, the format is /subtitles/movie/tt12345.json - url = `${baseUrl}/subtitles/${type}/${id}.json`; + + const requests = subtitleAddons.map(async (addon) => { + if (!addon.url) return [] as Subtitle[]; + try { + const { baseUrl } = this.getAddonBaseURL(addon.url || ''); + let url = ''; + if (type === 'series' && videoId) { + const episodeInfo = videoId.replace('series:', ''); + url = `${baseUrl}/subtitles/series/${episodeInfo}.json`; + } else { + url = `${baseUrl}/subtitles/${type}/${id}.json`; + } + logger.log(`Fetching subtitles from ${addon.name}: ${url}`); + const response = await this.retryRequest(async () => axios.get(url, { timeout: 10000 })); + if (response.data && Array.isArray(response.data.subtitles)) { + return response.data.subtitles.map((sub: any) => ({ + ...sub, + addon: addon.id, + addonName: addon.name, + })) as Subtitle[]; + } + } catch (error) { + logger.error(`Failed to fetch subtitles from ${addon.name}:`, error); } - - logger.log(`Fetching subtitles from: ${url}`); - - const response = await this.retryRequest(async () => { - return await axios.get(url, { timeout: 10000 }); - }); - - if (response.data && response.data.subtitles) { - // Process and return the subtitles - return response.data.subtitles.map((sub: any) => ({ - ...sub, - addon: openSubtitlesAddon.id, - addonName: openSubtitlesAddon.name - })); - } - } catch (error) { - logger.error('Failed to fetch subtitles:', error); - } - - return []; + return [] as Subtitle[]; + }); + + const all = await Promise.all(requests); + // Flatten and de-duplicate by URL + const merged = ([] as Subtitle[]).concat(...all); + const seen = new Set(); + const deduped = merged.filter(s => { + const key = s.url; + if (!key) return false; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return deduped; } // Add methods to move addons in the order