Merge branch 'tapframe:main' into main

This commit is contained in:
aicon 2026-03-07 10:18:21 +01:00 committed by GitHub
commit 2d52b9916e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3612 additions and 990 deletions

217
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,217 @@
name: Bug report
description: Report a reproducible bug (one per issue).
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug.
If we can reproduce it, we can usually fix it. This form is just to get the basics in one place.
- type: markdown
attributes:
value: |
## Quick checks
- type: checkboxes
id: checks
attributes:
label: Pre-flight checks
options:
- label: I searched existing issues and this is not a duplicate.
required: true
- label: I can reproduce this on the latest release or latest main build.
required: false
- label: This issue is limited to a single bug (not multiple unrelated problems).
required: true
- type: markdown
attributes:
value: |
## Version & device
- type: input
id: app_version
attributes:
label: App version / OTA update ID
description: Release version, commit hash, or OTA update ID. You can find your OTA update ID in Settings > App updates > Current version (hold to copy).
placeholder: "e.g. 1.2.3, main@abc1234, or an OTA ID"
validations:
required: true
- type: dropdown
id: install_method
attributes:
label: Install method
options:
- GitHub Release APK / IPA
- Expo Go
- Built from source
- Other (please describe below)
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- Android phone/tablet
- iOS (iPhone/iPad)
- Android emulator
- iOS Simulator
- Other (please describe below)
validations:
required: true
- type: input
id: device_model
attributes:
label: Device model
description: "Example: iPhone 15 Pro, Pixel 8, Galaxy S23 Ultra, iPad Pro, etc."
placeholder: "e.g. iPhone 15 Pro"
validations:
required: true
- type: input
id: os_version
attributes:
label: OS version
placeholder: "e.g. Android 14, iOS 17.2"
validations:
required: true
- type: dropdown
id: player_mode
attributes:
label: Player mode
description: If you are using an external player, most playback issues must be reported to that player instead.
options:
- Internal player (iOS: KSPlayer)
- Internal player (Android: ExoPlayer)
- Internal player (Android: MPV)
- External player
- Ask every time
- Not sure
validations:
required: true
- type: markdown
attributes:
value: |
## What happened?
- type: dropdown
id: area
attributes:
label: Area (tag)
description: Pick the closest match. It helps triage.
options:
- Playback (start/stop/buffering)
- Streams / Sources (selection, loading, errors)
- Next Episode / Auto-play
- Watch Progress (resume, watched state, history)
- Subtitles (styling, sync)
- Audio tracks
- UI / Layout / Animations
- Settings
- Sync (Trakt / SIMKL / remote)
- Downloads
- Other
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
placeholder: |
1. Open ...
2. Navigate to ...
3. Press ...
4. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
placeholder: "What you expected to happen."
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
placeholder: "What actually happened (include any on-screen error text/codes)."
validations:
required: true
- type: dropdown
id: frequency
attributes:
label: Frequency
options:
- Always
- Often (more than 50%)
- Sometimes
- Rarely
- Once
validations:
required: true
- type: dropdown
id: regression
attributes:
label: Did this work before?
options:
- Not sure
- Yes, it used to work
- No, it never worked
validations:
required: true
- type: markdown
attributes:
value: |
## Extra context (optional)
- type: textarea
id: media_details
attributes:
label: Media details (optional)
description: Only include what you can safely share.
placeholder: |
- Content type: series/movie
- Season/Episode: S1E2
- Stream/source: (addon name / source label)
- Video format: (if known)
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs (optional but helpful)
description: |
Not required, but super helpful for playback/crash issues.
If you can, include a short snippet from Metro bundler, Xcode, or `adb logcat`.
render: shell
placeholder: |
adb logcat -d | tail -n 300
validations:
required: false
- type: textarea
id: extra
attributes:
label: Anything else? (optional)
description: Screenshots/recordings, related issues, workarounds, etc.
validations:
required: false

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Downloads / Releases
url: https://github.com/tapframe/NuvioMobile/releases
about: Grab the latest GitHub Release APK/IPA here.
- name: Documentation
url: https://github.com/tapframe/NuvioMobile/blob/main/README.md
about: Read the README for setup and usage details.

View file

@ -0,0 +1,78 @@
name: Feature request
description: Suggest an improvement or new feature.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
One feature request per issue, please. The more real-world your use case is, the easier it is to evaluate.
Feature requests are reviewed as product proposals first.
Please do not open a pull request for a new feature, major UX change, or broad cosmetic update unless a maintainer has explicitly approved it first.
Unapproved feature PRs will usually be closed.
- type: dropdown
id: area
attributes:
label: Area (tag)
options:
- Playback
- Streams / Sources
- Next Episode / Auto-play
- Watch Progress
- Subtitles
- Audio
- UI / Layout / Animations
- Settings
- Sync (Trakt / SIMKL / remote)
- Downloads
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem statement
description: What problem are you trying to solve?
placeholder: "I want to be able to..."
validations:
required: true
- type: textarea
id: proposed
attributes:
label: Proposed solution
description: What would you like the app to do?
validations:
required: true
- type: dropdown
id: contribution_plan
attributes:
label: Are you planning to implement this yourself?
description: Major features are usually implemented in-house unless approved first.
options:
- No, this is only a proposal
- Maybe, but only if approved first
- Yes, but I understand implementation still needs maintainer approval
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered (optional)
description: Any workarounds or other approaches you considered.
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional context (optional)
description: Mockups, examples from other apps, etc.
validations:
required: false

43
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,43 @@
## Summary
<!-- What changed in this PR? -->
## PR type
<!-- Pick one and delete the others -->
- Bug fix
- Small maintenance improvement
- Docs fix
- Approved larger change (link approval below)
## Why
<!-- Why this change is needed. Link bug/issue/context. -->
## Policy check
<!-- Confirm these before requesting review -->
- [ ] This PR is not cosmetic only.
- [ ] This PR does not add a new major feature without prior approval.
- [ ] This PR is small in scope and focused on one problem.
- [ ] If this is a larger or directional change, I linked the issue where it was approved.
<!-- PRs that do not match this policy will usually be closed without merge. -->
## Testing
<!-- What you tested and how (manual + automated). -->
- [ ] iOS tested
- [ ] Android tested
## Screenshots / Video (UI changes only)
<!-- If UI changed, add before/after screenshots or a short clip. -->
## Breaking changes
<!-- Any breaking behavior/config/schema changes? If none, write: None -->
## Linked issues
<!-- Example: Fixes #123 -->

80
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,80 @@
# Contributing
Thanks for helping improve NuvioMobile.
## PR policy
Pull requests are currently intended for:
- Reproducible bug fixes
- Small stability improvements
- Minor maintenance work
- Small documentation fixes that improve accuracy
Pull requests are generally **not** accepted for:
- New major features
- Product direction changes
- Large UX / UI redesigns
- Cosmetic-only changes
- Refactors without a clear user-facing or maintenance benefit
For feature ideas and bigger changes, please open an issue first. Feature implementation is usually kept in-house unless it has been discussed and explicitly approved beforehand.
## Where to ask questions
- Use **Issues** for bugs, feature requests, setup help, and general support.
## Bug reports (rules)
To keep issues fixable, bug reports should include:
- App version or OTA update ID (Settings > App updates > Current version, hold to copy)
- Platform + device model + OS version (Android/iOS)
- Install method (release APK/IPA / Expo Go / built from source)
- Steps to reproduce (exact steps)
- Expected vs actual behavior
- Frequency (always/sometimes/once)
Logs are **optional**, but they help a lot for playback/crash issues.
### How to capture logs (optional)
If you can, reproduce the issue once, then attach a short log snippet from around the time it happened:
For Android:
```sh
adb logcat -d | tail -n 300
```
For iOS/Metro:
```sh
# Copy from your Metro bundler output or Xcode console
```
If the issue is a crash, also include any stack trace shown by Android Studio, Xcode, or `adb logcat`.
## Feature requests (rules)
Please include:
- The problem you are solving (use case)
- Your proposed solution
- Alternatives considered (if any)
Opening a feature request does **not** mean a pull request will be accepted for it. If the feature affects product scope, UX direction, or adds a significant new surface area, do not start implementation unless a maintainer explicitly approves it first.
## Before opening a PR
Please make sure your PR is all of the following:
- Small in scope
- Focused on one problem
- Clearly aligned with the current direction of the project
- Not cosmetic-only
- Not a new major feature unless it was discussed and approved first
PRs that do not fit this policy will usually be closed without merge so review time can stay focused on bugs, regressions, and small improvements.
## One issue per problem
Please open separate issues for separate bugs/features. It makes tracking, fixing, and closing issues much faster.

View file

@ -54,7 +54,7 @@ Nuvio functions solely as a client-side interface for browsing metadata and play
Nuvio is not affiliated with any third-party extensions, catalogs, sources, or content providers. It does not host, store, or distribute any media content.
For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://tapframe.github.io/NuvioStreaming/#legal)**.
For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://nuvioapp.space/legal)**.
## Built With

View file

@ -11,7 +11,7 @@
"apps": [
{
"name": "Nuvio",
"bundleIdentifier": "com.nuvio.app",
"bundleIdentifier": "com.nuvio.hub",
"developerName": "Tapframe",
"subtitle": "Media player and discovery app",
"localizedDescription": "Nuvio is a media player and metadata discovery application for user-provided and user-installed sources.",

View file

@ -13,6 +13,7 @@ import {
} from 'react-native';
import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { TMDBService } from '../../services/tmdbService';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
@ -440,35 +441,68 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
thumbnailOpacity.value = withTiming(1, { duration: 300 });
try {
// Extract year from metadata
const year = currentItem.releaseInfo
? parseInt(currentItem.releaseInfo.split('-')[0], 10)
: new Date().getFullYear();
// Extract TMDB ID if available
const tmdbId = currentItem.id?.startsWith('tmdb:')
? currentItem.id.replace('tmdb:', '')
: undefined;
if (!tmdbId) {
logger.info('[AppleTVHero] No TMDB ID for:', currentItem.name, '- skipping trailer');
setTrailerUrl(null);
setTrailerLoading(false);
return;
}
const contentType = currentItem.type === 'series' ? 'tv' : 'movie';
logger.info('[AppleTVHero] Fetching trailer for:', currentItem.name, year, tmdbId);
logger.info('[AppleTVHero] Fetching TMDB videos for:', currentItem.name, 'tmdbId:', tmdbId);
const url = await TrailerService.getTrailerUrl(
currentItem.name,
year,
tmdbId,
contentType
// Fetch video list from TMDB to get the YouTube video ID
const tmdbApiKey = await TMDBService.getInstance().getApiKey();
const videosRes = await fetch(
`https://api.themoviedb.org/3/${contentType}/${tmdbId}/videos?api_key=${tmdbApiKey}`
);
if (!alive) return;
if (!videosRes.ok) {
logger.warn('[AppleTVHero] TMDB videos fetch failed:', videosRes.status);
setTrailerUrl(null);
setTrailerLoading(false);
return;
}
const videosData = await videosRes.json();
const results: any[] = videosData.results ?? [];
// Pick best YouTube trailer: any trailer > teaser > any YouTube video
const pick =
results.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ??
results.find((v) => v.site === 'YouTube' && v.type === 'Teaser') ??
results.find((v) => v.site === 'YouTube');
if (!alive) return;
if (!pick) {
logger.info('[AppleTVHero] No YouTube video found for:', currentItem.name);
setTrailerUrl(null);
setTrailerLoading(false);
return;
}
logger.info('[AppleTVHero] Extracting stream for videoId:', pick.key, currentItem.name);
const url = await TrailerService.getTrailerFromVideoId(
pick.key,
currentItem.name
);
if (!alive) return;
if (url) {
const bestUrl = TrailerService.getBestFormatUrl(url);
setTrailerUrl(bestUrl);
// logger.info('[AppleTVHero] Trailer URL loaded:', bestUrl);
setTrailerUrl(url);
} else {
logger.info('[AppleTVHero] No trailer found for:', currentItem.name);
logger.info('[AppleTVHero] No stream extracted for:', currentItem.name);
setTrailerUrl(null);
}
} catch (error) {
@ -491,10 +525,17 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
}, [currentItem, currentIndex]); // Removed settings?.showTrailers from dependencies
// Handle trailer preloaded
// FIX: Set global trailer playing to true HERE — before the visible player mounts —
// so that when the visible player's autoPlay prop is evaluated it is already true,
// eliminating the race condition that previously caused the global state effect in
// TrailerPlayer to immediately pause the video on first render.
const handleTrailerPreloaded = useCallback(() => {
if (isFocused && !isOutOfView && !trailerShouldBePaused) {
setTrailerPlaying(true);
}
setTrailerPreloaded(true);
logger.info('[AppleTVHero] Trailer preloaded successfully');
}, []);
}, [isFocused, isOutOfView, trailerShouldBePaused, setTrailerPlaying]);
// Handle trailer ready to play
const handleTrailerReady = useCallback(() => {
@ -1078,7 +1119,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
key={`visible-${trailerUrl}`}
ref={trailerVideoRef}
trailerUrl={trailerUrl}
autoPlay={globalTrailerPlaying}
autoPlay={!trailerShouldBePaused}
muted={trailerMuted}
style={StyleSheet.absoluteFillObject}
hideLoadingSpinner={true}

View file

@ -33,6 +33,7 @@ import { stremioService } from '../../services/stremioService';
import { streamCacheService } from '../../services/streamCacheService';
import { useSettings } from '../../hooks/useSettings';
import { useBottomSheetBackHandler } from '../../hooks/useBottomSheetBackHandler';
import { watchedService } from '../../services/watchedService';
// Define interface for continue watching items
@ -293,8 +294,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
currentEpisode: number,
videos: any[],
watchedSet?: Set<string>,
showId?: string
) => {
showId?: string,
localWatchedMap?: Map<string, number>,
baseTimestamp: number = 0
): { video: any; lastWatched: number } | null => {
if (!videos || !Array.isArray(videos)) return null;
const sortedVideos = [...videos].sort((a, b) => {
@ -302,11 +305,27 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
return a.episode - b.episode;
});
const isAlreadyWatched = (season: number, episode: number): boolean => {
if (!watchedSet || !showId) return false;
let latestWatchedTimestamp = baseTimestamp;
if (localWatchedMap && showId) {
const cleanShowId = showId.startsWith('tt') ? showId : `tt${showId}`;
return watchedSet.has(`${cleanShowId}:${season}:${episode}`) ||
watchedSet.has(`${showId}:${season}:${episode}`);
for (const video of sortedVideos) {
const sig1 = `${cleanShowId}:${video.season}:${video.episode}`;
const sig2 = `${showId}:${video.season}:${video.episode}`;
const t1 = localWatchedMap.get(sig1) || 0;
const t2 = localWatchedMap.get(sig2) || 0;
latestWatchedTimestamp = Math.max(latestWatchedTimestamp, t1, t2);
}
}
const isAlreadyWatched = (season: number, episode: number): boolean => {
if (!showId) return false;
const cleanShowId = showId.startsWith('tt') ? showId : `tt${showId}`;
const sig1 = `${cleanShowId}:${season}:${episode}`;
const sig2 = `${showId}:${season}:${episode}`;
if (watchedSet && (watchedSet.has(sig1) || watchedSet.has(sig2))) return true;
if (localWatchedMap && (localWatchedMap.has(sig1) || localWatchedMap.has(sig2))) return true;
return false;
};
for (const video of sortedVideos) {
@ -316,7 +335,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (isAlreadyWatched(video.season, video.episode)) continue;
if (isEpisodeReleased(video)) {
return video;
return { video, lastWatched: latestWatchedTimestamp };
}
}
@ -371,16 +390,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
};
const compareCwItems = (a: ContinueWatchingItem, b: ContinueWatchingItem): number => {
const aProgress = a.progress ?? 0;
const bProgress = b.progress ?? 0;
const aIsUpNext = a.type === 'series' && aProgress <= 0;
const bIsUpNext = b.type === 'series' && bProgress <= 0;
// Keep active in-progress items ahead of "Up Next" placeholders.
if (aIsUpNext !== bIsUpNext) {
return aIsUpNext ? 1 : -1;
}
// Sort purely by recency — most recently watched first.
// "Up Next" placeholders (progress=0) carry the timestamp of the last watched episode
// so they naturally bubble up next to the other recently-watched items.
return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0);
};
@ -498,8 +510,103 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
logger.log(`[CW] Providers authed: trakt=${isTraktAuthed} simkl=${isSimklAuthed}`);
// Declare groupPromises outside the if block
let groupPromises: Promise<void>[] = [];
const allLocalItems: ContinueWatchingItem[] = [];
// Fetch Trakt watched movies once and reuse
const traktMoviesSetPromise = (async () => {
try {
if (!isTraktAuthed) return new Set<string>();
if (typeof (traktService as any).getWatchedMovies === 'function') {
const watched = await (traktService as any).getWatchedMovies();
const watchedSet = new Set<string>();
if (Array.isArray(watched)) {
watched.forEach((movie: any) => {
const ids = movie?.movie?.ids;
if (!ids) return;
const imdb = ids.imdb;
if (imdb) {
watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`);
}
if (ids.tmdb) {
watchedSet.add(ids.tmdb.toString());
}
});
}
return watchedSet;
}
return new Set<string>();
} catch {
return new Set<string>();
}
})();
// Fetch Trakt watched shows once and reuse
const traktShowsSetPromise = (async () => {
try {
if (!isTraktAuthed) return new Set<string>();
if (typeof (traktService as any).getWatchedShows === 'function') {
const watched = await (traktService as any).getWatchedShows();
const watchedSet = new Set<string>();
if (Array.isArray(watched)) {
watched.forEach((show: any) => {
const ids = show?.show?.ids;
if (!ids) return;
const imdbId = ids.imdb;
const tmdbId = ids.tmdb;
if (show.seasons && Array.isArray(show.seasons)) {
show.seasons.forEach((season: any) => {
if (season.episodes && Array.isArray(season.episodes)) {
season.episodes.forEach((episode: any) => {
if (imdbId) {
const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`);
}
if (tmdbId) {
watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`);
}
});
}
});
}
});
}
return watchedSet;
}
return new Set<string>();
} catch {
return new Set<string>();
}
})();
// Fetch local supervised watched items
const localWatchedShowsMapPromise = (async () => {
try {
const watched = await watchedService.getAllWatchedItems();
const watchedMap = new Map<string, number>();
watched.forEach(item => {
if (item.content_id) {
const cleanId = item.content_id.startsWith('tt') ? item.content_id : `tt${item.content_id}`;
if (item.season != null && item.episode != null) {
watchedMap.set(`${cleanId}:${item.season}:${item.episode}`, item.watched_at);
watchedMap.set(`${item.content_id}:${item.season}:${item.episode}`, item.watched_at);
} else {
watchedMap.set(cleanId, item.watched_at);
watchedMap.set(item.content_id, item.watched_at);
}
}
});
return watchedMap;
} catch {
return new Map<string, number>();
}
})();
// In Trakt mode, CW is sourced from Trakt only, but we still want to overlay local progress
// when local is ahead (scrobble lag/offline playback).
@ -579,79 +686,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent });
}
// Fetch Trakt watched movies once and reuse
const traktMoviesSetPromise = (async () => {
try {
if (!isTraktAuthed) return new Set<string>();
if (typeof (traktService as any).getWatchedMovies === 'function') {
const watched = await (traktService as any).getWatchedMovies();
const watchedSet = new Set<string>();
// (Promises are now declared at the top of the function)
if (Array.isArray(watched)) {
watched.forEach((w: any) => {
const ids = w?.movie?.ids;
if (!ids) return;
if (ids.imdb) {
const imdb = ids.imdb;
watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`);
}
if (ids.tmdb) {
watchedSet.add(ids.tmdb.toString());
}
});
}
return watchedSet;
}
return new Set<string>();
} catch {
return new Set<string>();
}
})();
// Fetch Trakt watched shows once and reuse
const traktShowsSetPromise = (async () => {
try {
if (!isTraktAuthed) return new Set<string>();
if (typeof (traktService as any).getWatchedShows === 'function') {
const watched = await (traktService as any).getWatchedShows();
const watchedSet = new Set<string>();
if (Array.isArray(watched)) {
watched.forEach((show: any) => {
const ids = show?.show?.ids;
if (!ids) return;
const imdbId = ids.imdb;
const tmdbId = ids.tmdb;
if (show.seasons && Array.isArray(show.seasons)) {
show.seasons.forEach((season: any) => {
if (season.episodes && Array.isArray(season.episodes)) {
season.episodes.forEach((episode: any) => {
if (imdbId) {
const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`);
}
if (tmdbId) {
watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`);
}
});
}
});
}
});
}
return watchedSet;
}
return new Set<string>();
} catch {
return new Set<string>();
}
})();
// Process each content group concurrently, merging results as they arrive
groupPromises = Object.values(contentGroups).map(async (group) => {
try {
if (!isSupportedId(group.id)) return;
@ -711,20 +747,24 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// If we have valid season/episode info, find the next episode
if (completedSeason !== undefined && completedEpisode !== undefined && metadata?.videos) {
const watchedEpisodesSet = await traktShowsSetPromise;
const nextEpisode = findNextEpisode(
const localWatchedMap = await localWatchedShowsMapPromise;
const nextEpisodeResult = findNextEpisode(
completedSeason,
completedEpisode,
metadata.videos,
watchedEpisodesSet,
group.id
group.id,
localWatchedMap,
progress.lastUpdated
);
if (nextEpisode) {
if (nextEpisodeResult) {
const nextEpisode = nextEpisodeResult.video;
logger.log(`📺 [ContinueWatching] Found next episode: S${nextEpisode.season}E${nextEpisode.episode} for ${basicContent.name}`);
batch.push({
...basicContent,
progress: 0, // Up next - no progress yet
lastUpdated: progress.lastUpdated, // Keep the timestamp from completed episode
lastUpdated: nextEpisodeResult.lastWatched, // Keep the timestamp from completed episode or watched item
season: nextEpisode.season,
episode: nextEpisode.episode,
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
@ -764,13 +804,20 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Check if this specific episode is watched on Trakt
if (season !== undefined && episodeNumber !== undefined) {
const watchedEpisodesSet = await traktShowsSetPromise;
// Try with both raw ID and tt-prefixed ID, and TMDB ID (which is just the ID string)
const localWatchedMap = await localWatchedShowsMapPromise;
const rawId = group.id.replace(/^tt/, '');
const ttId = `tt${rawId}`;
if (watchedEpisodesSet.has(`${ttId}:${season}:${episodeNumber}`) ||
watchedEpisodesSet.has(`${rawId}:${season}:${episodeNumber}`) ||
watchedEpisodesSet.has(`${group.id}:${season}:${episodeNumber}`)) {
const sig1 = `${ttId}:${season}:${episodeNumber}`;
const sig2 = `${rawId}:${season}:${episodeNumber}`;
const sig3 = `${group.id}:${season}:${episodeNumber}`;
if (watchedEpisodesSet.has(sig1) ||
watchedEpisodesSet.has(sig2) ||
watchedEpisodesSet.has(sig3) ||
localWatchedMap.has(sig1) ||
localWatchedMap.has(sig2) ||
localWatchedMap.has(sig3)) {
isWatchedOnTrakt = true;
// Update local storage to reflect watched status
@ -808,7 +855,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} as ContinueWatchingItem);
}
if (batch.length > 0) await mergeBatchIntoState(batch);
if (batch.length > 0) allLocalItems.push(...batch);
} catch (error) {
// Continue processing other groups even if one fails
}
@ -855,6 +902,36 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const traktBatch: ContinueWatchingItem[] = [];
// Pre-fetch watched shows so both Step 1 and Step 2 can use the watched episode sets
// This fixes "Up Next" suggesting already-watched episodes when the watched set is missing
let watchedShowsData: Awaited<ReturnType<typeof traktService.getWatchedShows>> = [];
// Map from showImdb -> Set of "imdb:season:episode" strings
const watchedEpisodeSetByShow = new Map<string, Set<string>>();
try {
watchedShowsData = await traktService.getWatchedShows();
for (const ws of watchedShowsData) {
if (!ws.show?.ids?.imdb) continue;
const imdb = ws.show.ids.imdb.startsWith('tt') ? ws.show.ids.imdb : `tt${ws.show.ids.imdb}`;
const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0;
const episodeSet = new Set<string>();
if (ws.seasons) {
for (const season of ws.seasons) {
for (const episode of season.episodes) {
// Respect reset_at: skip episodes watched before the reset
if (resetAt > 0) {
const watchedAt = new Date(episode.last_watched_at).getTime();
if (watchedAt < resetAt) continue;
}
episodeSet.add(`${imdb}:${season.number}:${episode.number}`);
}
}
}
watchedEpisodeSetByShow.set(imdb, episodeSet);
}
} catch {
// Non-fatal — fall back to no watched set
}
// STEP 1: Process playback progress items (in-progress, paused)
// These have actual progress percentage from Trakt
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
@ -918,22 +995,28 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (item.progress >= 85) {
const metadata = cachedData.metadata;
if (metadata?.videos) {
const nextEpisode = findNextEpisode(
// Use pre-fetched watched set so already-watched episodes are skipped
const watchedSetForShow = watchedEpisodeSetByShow.get(showImdb);
const localWatchedMap = await localWatchedShowsMapPromise;
const nextEpisodeResult = findNextEpisode(
item.episode.season,
item.episode.number,
metadata.videos,
undefined, // No watched set needed, findNextEpisode handles it
showImdb
watchedSetForShow,
showImdb,
localWatchedMap,
pausedAt
);
if (nextEpisode) {
if (nextEpisodeResult) {
const nextEpisode = nextEpisodeResult.video;
logger.log(`📺 [TraktPlayback] Episode completed, adding next: S${nextEpisode.season}E${nextEpisode.episode} for ${item.show.title}`);
traktBatch.push({
...cachedData.basicContent,
id: showImdb,
type: 'series',
progress: 0, // Up next - no progress yet
lastUpdated: pausedAt,
lastUpdated: nextEpisodeResult.lastWatched,
season: nextEpisode.season,
episode: nextEpisode.episode,
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
@ -965,13 +1048,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
}
// STEP 2: Get watched shows and find "Up Next" episodes
// This handles cases where episodes are fully completed and removed from playback progress
// STEP 2: Find "Up Next" episodes using pre-fetched watched shows data
// Reuses watchedShowsData fetched before Step 1 — no extra API call
// Also respects reset_at (Bug 4 fix) and uses pre-built watched episode sets (Bug 3 fix)
try {
const watchedShows = await traktService.getWatchedShows();
const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000);
for (const watchedShow of watchedShows) {
for (const watchedShow of watchedShowsData) {
try {
if (!watchedShow.show?.ids?.imdb) continue;
@ -987,7 +1070,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const showKey = `series:${showImdb}`;
if (recentlyRemovedRef.current.has(showKey)) continue;
// Find the last watched episode
const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0;
// Find the last watched episode (respecting reset_at)
let lastWatchedSeason = 0;
let lastWatchedEpisode = 0;
let latestEpisodeTimestamp = 0;
@ -996,6 +1081,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
for (const season of watchedShow.seasons) {
for (const episode of season.episodes) {
const episodeTimestamp = new Date(episode.last_watched_at).getTime();
// Skip episodes watched before the user reset their progress
if (resetAt > 0 && episodeTimestamp < resetAt) continue;
if (episodeTimestamp > latestEpisodeTimestamp) {
latestEpisodeTimestamp = episodeTimestamp;
lastWatchedSeason = season.number;
@ -1011,33 +1098,30 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const cachedData = await getCachedMetadata('series', showImdb);
if (!cachedData?.basicContent || !cachedData?.metadata?.videos) continue;
// Build a set of watched episodes for this show
const watchedEpisodeSet = new Set<string>();
if (watchedShow.seasons) {
for (const season of watchedShow.seasons) {
for (const episode of season.episodes) {
watchedEpisodeSet.add(`${showImdb}:${season.number}:${episode.number}`);
}
}
}
// Use pre-built watched episode set (already respects reset_at)
const watchedEpisodeSet = watchedEpisodeSetByShow.get(showImdb) ?? new Set<string>();
const localWatchedMap = await localWatchedShowsMapPromise;
// Find the next unwatched episode
const nextEpisode = findNextEpisode(
const nextEpisodeResult = findNextEpisode(
lastWatchedSeason,
lastWatchedEpisode,
cachedData.metadata.videos,
watchedEpisodeSet,
showImdb
showImdb,
localWatchedMap,
latestEpisodeTimestamp
);
if (nextEpisode) {
if (nextEpisodeResult) {
const nextEpisode = nextEpisodeResult.video;
logger.log(`📺 [TraktWatched] Found Up Next: ${watchedShow.show.title} S${nextEpisode.season}E${nextEpisode.episode}`);
traktBatch.push({
...cachedData.basicContent,
id: showImdb,
type: 'series',
progress: 0, // Up next - no progress yet
lastUpdated: latestEpisodeTimestamp,
lastUpdated: nextEpisodeResult.lastWatched,
season: nextEpisode.season,
episode: nextEpisode.episode,
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
@ -1054,13 +1138,24 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Trakt mode: show ONLY Trakt items, but override progress with local if local is higher.
if (traktBatch.length > 0) {
// Dedupe (keep most recent per show/movie)
// Dedupe (keep in-progress over "Up Next"; then prefer most recent)
const deduped = new Map<string, ContinueWatchingItem>();
for (const item of traktBatch) {
const key = `${item.type}:${item.id}`;
const existing = deduped.get(key);
if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
if (!existing) {
deduped.set(key, item);
} else {
const existingHasProgress = (existing.progress ?? 0) > 0;
const candidateHasProgress = (item.progress ?? 0) > 0;
if (candidateHasProgress && !existingHasProgress) {
// Always prefer actual in-progress over "Up Next" placeholder
deduped.set(key, item);
} else if (!candidateHasProgress && existingHasProgress) {
// Keep existing in-progress item
} else if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
deduped.set(key, item);
}
}
}
@ -1159,13 +1254,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (!mostRecentLocal || !highestLocal) return it;
// IMPORTANT:
// In Trakt-auth mode, the "most recently watched" ordering should reflect local playback,
// not Trakt's paused_at (which can be stale or even appear newer than local).
// So: if we have any local match, use its timestamp for ordering.
const mergedLastUpdated = (mostRecentLocal.lastUpdated ?? 0) > 0
? (mostRecentLocal.lastUpdated ?? 0)
: (it.lastUpdated ?? 0);
// Use the most recent timestamp between local and Trakt.
// Always preferring local was wrong: if you watched on another device,
// Trakt's paused_at is newer and should win for ordering purposes.
const mergedLastUpdated = Math.max(
(mostRecentLocal.lastUpdated ?? 0),
(it.lastUpdated ?? 0)
);
try {
logger.log('[CW][Trakt][Overlay] item/local summary', {
@ -1399,21 +1494,26 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (item.progress >= 85) {
const metadata = cachedData.metadata;
if (metadata?.videos) {
const nextEpisode = findNextEpisode(
const watchedEpisodesSet = await traktShowsSetPromise;
const localWatchedMap = await localWatchedShowsMapPromise;
const nextEpisodeResult = findNextEpisode(
item.episode.season,
episodeNum,
metadata.videos,
undefined,
showImdb
watchedEpisodesSet,
showImdb,
localWatchedMap,
pausedAt
);
if (nextEpisode) {
if (nextEpisodeResult) {
const nextEpisode = nextEpisodeResult.video;
simklBatch.push({
...cachedData.basicContent,
id: showImdb,
type: 'series',
progress: 0,
lastUpdated: pausedAt,
lastUpdated: nextEpisodeResult.lastWatched,
season: nextEpisode.season,
episode: nextEpisode.episode,
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
@ -1540,6 +1640,37 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Wait for all groups and provider merges to settle, then finalize loading state
await Promise.allSettled([...groupPromises, traktMergePromise, simklMergePromise]);
if (allLocalItems.length > 0) {
const map = new Map<string, ContinueWatchingItem>();
for (const it of allLocalItems) {
const key = `${it.type}:${it.id}`;
const existing = map.get(key);
if (!existing || shouldPreferCandidate(it, existing)) {
map.set(key, it);
}
}
const sorted = Array.from(map.values());
sorted.sort(compareCwItems);
// Filter removed items
const filtered: ContinueWatchingItem[] = [];
for (const it of sorted) {
const key = it.type === 'series' && it.season && it.episode
? `${it.type}:${it.id}:${it.season}:${it.episode}`
: `${it.type}:${it.id}`;
if (recentlyRemovedRef.current.has(key)) continue;
const removeId = it.type === 'series' && it.season && it.episode
? `${it.id}:${it.season}:${it.episode}`
: it.id;
const isRemoved = await storageService.isContinueWatchingRemoved(removeId, it.type);
if (!isRemoved) filtered.push(it);
}
setContinueWatchingItems(filtered);
}
} catch (error) {
// Continue even if loading fails
} finally {
@ -2223,14 +2354,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
</View>
<FlatList
data={[...continueWatchingItems].sort((a, b) => {
const aProgress = a.progress ?? 0;
const bProgress = b.progress ?? 0;
const aIsUpNext = a.type === 'series' && aProgress <= 0;
const bIsUpNext = b.type === 'series' && bProgress <= 0;
if (aIsUpNext !== bIsUpNext) return aIsUpNext ? 1 : -1;
return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0);
})}
data={continueWatchingItems}
renderItem={renderContinueWatchingItem}
keyExtractor={keyExtractor}
horizontal

View file

@ -1129,12 +1129,14 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
useEffect(() => {
let alive = true as boolean;
let timerId: any = null;
const fetchTrailer = async () => {
if (!metadata?.name || !metadata?.year || !settings?.showTrailers || !isFocused) return;
// If we expect TMDB ID but don't have it yet, wait a bit more
if (!metadata?.tmdbId && metadata?.id?.startsWith('tmdb:')) {
logger.info('HeroSection', `Waiting for TMDB ID for ${metadata.name}`);
const fetchTrailer = async () => {
if (!metadata?.name || !settings?.showTrailers || !isFocused) return;
// Need a TMDB ID to look up the YouTube video ID
const resolvedTmdbId = tmdbId ? String(tmdbId) : undefined;
if (!resolvedTmdbId) {
logger.info('HeroSection', `No TMDB ID for ${metadata.name} - skipping trailer`);
return;
}
@ -1142,52 +1144,68 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
setTrailerError(false);
setTrailerReady(false);
setTrailerPreloaded(false);
startedOnReadyRef.current = false;
// Small delay to avoid blocking the UI render
timerId = setTimeout(async () => {
if (!alive) return;
try {
// Use requestIdleCallback or setTimeout to prevent blocking main thread
const fetchWithDelay = () => {
// Extract TMDB ID if available
const tmdbIdString = tmdbId ? String(tmdbId) : undefined;
const contentType = type === 'series' ? 'tv' : 'movie';
// Debug logging to see what we have
logger.info('HeroSection', `Trailer request for ${metadata.name}:`, {
hasTmdbId: !!tmdbId,
tmdbId: tmdbId,
contentType,
metadataKeys: Object.keys(metadata || {}),
metadataId: metadata?.id
});
logger.info('HeroSection', `Fetching TMDB videos for ${metadata.name} (tmdbId: ${resolvedTmdbId})`);
TrailerService.getTrailerUrl(metadata.name, metadata.year, tmdbIdString, contentType)
.then(url => {
if (url) {
const bestUrl = TrailerService.getBestFormatUrl(url);
setTrailerUrl(bestUrl);
logger.info('HeroSection', `Trailer URL loaded for ${metadata.name}${tmdbId ? ` (TMDB: ${tmdbId})` : ''}`);
} else {
logger.info('HeroSection', `No trailer found for ${metadata.name}`);
// Fetch video list from TMDB to get the YouTube video ID
const tmdbApiKey = await TMDBService.getInstance().getApiKey();
const videosRes = await fetch(
`https://api.themoviedb.org/3/${contentType}/${resolvedTmdbId}/videos?api_key=${tmdbApiKey}`
);
if (!alive) return;
if (!videosRes.ok) {
logger.warn('HeroSection', `TMDB videos fetch failed: ${videosRes.status} for ${metadata.name}`);
setTrailerLoading(false);
return;
}
})
.catch(error => {
const videosData = await videosRes.json();
const results: any[] = videosData.results ?? [];
// Pick best YouTube trailer: any trailer > teaser > any YouTube video
const pick =
results.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ??
results.find((v) => v.site === 'YouTube' && v.type === 'Teaser') ??
results.find((v) => v.site === 'YouTube');
if (!alive) return;
if (!pick) {
logger.info('HeroSection', `No YouTube video found for ${metadata.name}`);
setTrailerLoading(false);
return;
}
logger.info('HeroSection', `Extracting stream for videoId: ${pick.key} (${metadata.name})`);
const url = await TrailerService.getTrailerFromVideoId(pick.key, metadata.name);
if (!alive) return;
if (url) {
setTrailerUrl(url);
logger.info('HeroSection', `Trailer loaded for ${metadata.name}`);
} else {
logger.info('HeroSection', `No stream extracted for ${metadata.name}`);
}
} catch (error) {
if (!alive) return;
logger.error('HeroSection', 'Error fetching trailer:', error);
setTrailerError(true);
})
.finally(() => {
setTrailerLoading(false);
});
};
// Delay trailer fetch to prevent blocking UI
timerId = setTimeout(() => {
if (!alive) return;
fetchWithDelay();
}, 100);
} catch (error) {
logger.error('HeroSection', 'Error in trailer fetch setup:', error);
setTrailerError(true);
setTrailerLoading(false);
} finally {
if (alive) setTrailerLoading(false);
}
}, 100);
};
fetchTrailer();
@ -1195,7 +1213,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
alive = false;
try { if (timerId) clearTimeout(timerId); } catch (_e) { }
};
}, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]);
}, [metadata?.name, tmdbId, settings?.showTrailers, isFocused]);
// Shimmer animation removed
@ -1595,29 +1613,13 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
</Animated.View>
)}
{/* Hidden preload trailer player - loads in background */}
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && (
<View style={[staticStyles.absoluteFill, { opacity: 0, pointerEvents: 'none' }]}>
<TrailerPlayer
key={`preload-${trailerUrl}`}
trailerUrl={trailerUrl}
autoPlay={false}
muted={true}
style={staticStyles.absoluteFill}
hideLoadingSpinner={true}
onLoad={handleTrailerPreloaded}
onError={handleTrailerError}
/>
</View>
)}
{/* Visible trailer player - rendered on top with fade transition and parallax */}
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && (
{/* Single trailer player - starts hidden (opacity 0), fades in when ready */}
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && (
<Animated.View style={[staticStyles.absoluteFill, {
opacity: trailerOpacity
}, trailerParallaxStyle]}>
<TrailerPlayer
key={`visible-${trailerUrl}`}
key={`trailer-${trailerUrl}`}
ref={trailerVideoRef}
trailerUrl={trailerUrl}
autoPlay={globalTrailerPlaying}

View file

@ -159,35 +159,19 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
const handleVideoError = useCallback((error: any) => {
logger.error('TrailerModal', 'Video error:', error);
// Check if this is a permission/network error that might benefit from retry
const errorCode = error?.error?.code;
const isRetryableError = errorCode === -1102 || errorCode === -1009 || errorCode === -1005;
if (isRetryableError && retryCount < 2) {
// Silent retry - increment count and try again
logger.info('TrailerModal', `Retrying video load (attempt ${retryCount + 1}/2)`);
if (retryCount < 2) {
logger.info('TrailerModal', `Re-extracting trailer (attempt ${retryCount + 1}/2)`);
setRetryCount(prev => prev + 1);
// Small delay before retry to avoid rapid-fire attempts
setTimeout(() => {
if (videoRef.current) {
// Force video to reload by changing the source briefly
setTrailerUrl(null);
setTimeout(() => {
if (trailerUrl) {
setTrailerUrl(trailerUrl);
}
}, 100);
}
}, 1000);
// Invalidate cache so loadTrailer gets a fresh URL, not the same bad one
if (trailer?.key) TrailerService.invalidateCache(trailer.key);
loadTrailer();
return;
}
// After 2 retries or for non-retryable errors, show the error
logger.error('TrailerModal', 'Video error after retries or non-retryable:', error);
logger.error('TrailerModal', 'Video error after retries:', error);
setError('Unable to play trailer. Please try again.');
setLoading(false);
}, [retryCount, trailerUrl]);
}, [retryCount, loadTrailer, trailer?.key]);
const handleTrailerEnd = useCallback(() => {
setIsPlaying(false);
@ -270,7 +254,18 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
<View style={styles.playerWrapper}>
<Video
ref={videoRef}
source={{ uri: trailerUrl }}
source={(() => {
const lower = (trailerUrl || '').toLowerCase();
const looksLikeHls = /\.m3u8(\b|$)/.test(lower) || /hls|playlist|m3u/.test(lower);
if (Platform.OS === 'android') {
const headers = { 'User-Agent': 'Nuvio/1.0 (Android)' };
if (looksLikeHls) {
return { uri: trailerUrl, type: 'm3u8', headers } as any;
}
return { uri: trailerUrl, headers } as any;
}
return { uri: trailerUrl } as any;
})()}
style={styles.player}
controls={true}
paused={!isPlaying}

View file

@ -19,6 +19,7 @@ import { useSettings } from '../../hooks/useSettings';
import { useTrailer } from '../../contexts/TrailerContext';
import { logger } from '../../utils/logger';
import TrailerService from '../../services/trailerService';
import { TMDBService } from '../../services/tmdbService';
import TrailerModal from './TrailerModal';
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
@ -175,10 +176,13 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
try {
logger.info('TrailersSection', `Fetching trailers for TMDB ID: ${tmdbId}, type: ${type}`);
// Resolve user-configured TMDB API key (falls back to default if not set)
const tmdbApiKey = await TMDBService.getInstance().getApiKey();
// First check if the movie/TV show exists
const basicEndpoint = type === 'movie'
? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`
: `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`;
? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${tmdbApiKey}`
: `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=${tmdbApiKey}`;
const basicResponse = await fetch(basicEndpoint);
if (!basicResponse.ok) {
@ -197,7 +201,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
if (type === 'movie') {
// For movies, just fetch the main videos endpoint
const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=${tmdbApiKey}`;
logger.info('TrailersSection', `Fetching movie videos from: ${videosEndpoint}`);
@ -228,7 +232,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
logger.info('TrailersSection', `TV show has ${numberOfSeasons} seasons`);
// Fetch main TV show videos
const tvVideosEndpoint = `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
const tvVideosEndpoint = `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=${tmdbApiKey}`;
const tvResponse = await fetch(tvVideosEndpoint);
if (tvResponse.ok) {
@ -247,7 +251,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
const seasonPromises = [];
for (let seasonNum = 1; seasonNum <= numberOfSeasons; seasonNum++) {
seasonPromises.push(
fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNum}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`)
fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNum}/videos?api_key=${tmdbApiKey}`)
.then(res => res.json())
.then(data => ({
seasonNumber: seasonNum,

View file

@ -75,6 +75,11 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
const [isFullscreen, setIsFullscreen] = useState(false);
const [isComponentMounted, setIsComponentMounted] = useState(true);
// FIX: Track whether this player has ever been in a playing state.
// This prevents the globalTrailerPlaying effect from suppressing the
// very first play attempt before the global state has been set to true.
const hasBeenPlayingRef = useRef(false);
// Animated values
const controlsOpacity = useSharedValue(0);
const loadingOpacity = useSharedValue(1);
@ -150,6 +155,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
useEffect(() => {
if (isComponentMounted && paused === undefined) {
setIsPlaying(autoPlay);
if (autoPlay) hasBeenPlayingRef.current = true;
}
}, [autoPlay, isComponentMounted, paused]);
@ -163,18 +169,23 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
// Handle external paused prop to override playing state (highest priority)
useEffect(() => {
if (paused !== undefined) {
setIsPlaying(!paused);
logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${!paused}`);
const shouldPlay = !paused;
setIsPlaying(shouldPlay);
if (shouldPlay) hasBeenPlayingRef.current = true;
logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${shouldPlay}`);
}
}, [paused]);
// Respond to global trailer state changes (e.g., when modal opens)
// Only apply if no external paused prop is controlling this
// Only apply if no external paused prop is controlling this.
// FIX: Only pause if this player has previously been in a playing state.
// This avoids the race condition where globalTrailerPlaying is still false
// at mount time (before the parent has called setTrailerPlaying(true)),
// which was causing the trailer to be immediately paused on every load.
useEffect(() => {
if (isComponentMounted && paused === undefined) {
// Always sync with global trailer state when pausing
// This ensures all trailers pause when one screen loses focus
if (!globalTrailerPlaying) {
if (!globalTrailerPlaying && hasBeenPlayingRef.current) {
// Only suppress if the player was previously playing — not on initial mount
logger.info('TrailerPlayer', 'Global trailer paused - pausing this trailer');
setIsPlaying(false);
}
@ -363,26 +374,18 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
<Video
ref={videoRef}
source={(() => {
const androidHeaders = Platform.OS === 'android' ? { 'User-Agent': 'Nuvio/1.0 (Android)' } : {} as any;
// Help ExoPlayer select proper MediaSource
const lower = (trailerUrl || '').toLowerCase();
const looksLikeHls = /\.m3u8(\b|$)/.test(lower) || /hls|applehlsencryption|playlist|m3u/.test(lower);
const looksLikeDash = /\.mpd(\b|$)/.test(lower) || /dash|manifest/.test(lower);
if (Platform.OS === 'android') {
const androidHeaders = { 'User-Agent': 'Nuvio/1.0 (Android)' };
if (looksLikeHls) {
return { uri: trailerUrl, type: 'm3u8', headers: androidHeaders } as any;
}
if (looksLikeDash) {
return { uri: trailerUrl, type: 'mpd', headers: androidHeaders } as any;
}
return { uri: trailerUrl, headers: androidHeaders } as any;
}
return { uri: trailerUrl } as any;
})()}
style={[
styles.video,
contentType === 'movie' && styles.movieVideoScale,
]}
style={styles.video}
resizeMode="cover"
paused={!isPlaying}
repeat={false}
@ -513,9 +516,7 @@ const styles = StyleSheet.create({
width: '100%',
height: '100%',
},
movieVideoScale: {
transform: [{ scale: 1.30 }], // Custom scale for movies to crop black bars
},
videoOverlay: {
position: 'absolute',
top: 0,

View file

@ -10,7 +10,7 @@ interface TrailerContextValue {
const TrailerContext = createContext<TrailerContextValue | undefined>(undefined);
export const TrailerProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isTrailerPlaying, setIsTrailerPlaying] = useState(true);
const [isTrailerPlaying, setIsTrailerPlaying] = useState(false);
const pauseTrailer = useCallback(() => {
setIsTrailerPlaying(false);

View file

@ -68,7 +68,38 @@ export const useCalendarData = (): UseCalendarDataReturn => {
);
if (cachedData) {
// Apply watched filter even on cached data
if (traktAuthenticated && watchedShows && watchedShows.length > 0) {
const cachedWatchedSet = new Set<string>();
for (const ws of watchedShows) {
const imdb = ws.show?.ids?.imdb;
if (!imdb) continue;
const showId = imdb.startsWith('tt') ? imdb : `tt${imdb}`;
const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0;
if (ws.seasons) {
for (const season of ws.seasons) {
for (const episode of season.episodes) {
if (resetAt > 0 && new Date(episode.last_watched_at).getTime() < resetAt) continue;
cachedWatchedSet.add(`${showId}:${season.number}:${episode.number}`);
}
}
}
}
const filtered = cachedData.map(section => {
if (section.title !== 'This Week') return section;
return {
...section,
data: section.data.filter((ep: any) => {
const showId = ep.seriesId?.startsWith('tt') ? ep.seriesId : `tt${ep.seriesId}`;
return !cachedWatchedSet.has(`${showId}:${ep.season}:${ep.episode}`) &&
!cachedWatchedSet.has(`${ep.seriesId}:${ep.season}:${ep.episode}`);
})
};
});
setCalendarData(filtered);
} else {
setCalendarData(cachedData);
}
setLoading(false);
return;
}
@ -314,6 +345,29 @@ export const useCalendarData = (): UseCalendarDataReturn => {
logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`);
// Build a set of watched episodes from Trakt so we can filter them out of This Week
const watchedEpisodeSet = new Set<string>();
if (traktAuthenticated && watchedShows) {
for (const ws of watchedShows) {
const imdb = ws.show?.ids?.imdb;
if (!imdb) continue;
const showId = imdb.startsWith('tt') ? imdb : `tt${imdb}`;
const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0;
if (ws.seasons) {
for (const season of ws.seasons) {
for (const episode of season.episodes) {
// Respect reset_at
if (resetAt > 0) {
const watchedAt = new Date(episode.last_watched_at).getTime();
if (watchedAt < resetAt) continue;
}
watchedEpisodeSet.add(`${showId}:${season.number}:${episode.number}`);
}
}
}
}
}
// Use memory-efficient filtering with error handling
const thisWeekEpisodes = await memoryManager.filterLargeArray(
allEpisodes,
@ -321,8 +375,18 @@ export const useCalendarData = (): UseCalendarDataReturn => {
try {
if (!ep.releaseDate) return false;
const parsed = parseISO(ep.releaseDate);
// Show all episodes for this week, including released ones
return isThisWeek(parsed);
if (!isThisWeek(parsed)) return false;
// Filter out already-watched episodes when Trakt is authenticated
if (traktAuthenticated && watchedEpisodeSet.size > 0) {
const showId = ep.seriesId?.startsWith('tt') ? ep.seriesId : `tt${ep.seriesId}`;
if (
watchedEpisodeSet.has(`${showId}:${ep.season}:${ep.episode}`) ||
watchedEpisodeSet.has(`${ep.seriesId}:${ep.season}:${ep.episode}`)
) {
return false;
}
}
return true;
} catch (error) {
logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error);
return false;

View file

@ -217,7 +217,7 @@ let settingsCacheTimestamp = 0;
const SETTINGS_CACHE_TTL = 60000; // 1 minute
export const useSettings = () => {
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const [settings, setSettings] = useState<AppSettings>(() => cachedSettings || DEFAULT_SETTINGS);
const [isLoaded, setIsLoaded] = useState<boolean>(false);
useEffect(() => {
@ -289,10 +289,32 @@ export const useSettings = () => {
value: AppSettings[K],
emitEvent: boolean = true
) => {
const newSettings = { ...settings, [key]: value };
try {
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`;
// Always merge against the latest persisted/cached settings to avoid stale-overwrite races
// when multiple screens update settings concurrently.
let baseSettings: AppSettings = cachedSettings || DEFAULT_SETTINGS;
if (!cachedSettings) {
const [scopedJson, legacyJson] = await Promise.all([
mmkvStorage.getItem(scopedKey),
mmkvStorage.getItem(SETTINGS_STORAGE_KEY),
]);
const parsedScoped = scopedJson ? JSON.parse(scopedJson) : null;
const parsedLegacy = legacyJson ? JSON.parse(legacyJson) : null;
const merged = parsedScoped || parsedLegacy;
if (merged) {
baseSettings = { ...DEFAULT_SETTINGS, ...merged };
}
}
const newSettings = { ...baseSettings, [key]: value };
// Update cache/UI immediately so subsequent updates in the same tick see fresh state.
cachedSettings = newSettings;
settingsCacheTimestamp = Date.now();
setSettings(newSettings);
// Write to both scoped key (multi-user aware) and legacy key for backward compatibility
await Promise.all([
mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)),
@ -300,12 +322,6 @@ export const useSettings = () => {
]);
// Ensure a current scope exists to avoid future loads missing the chosen scope
await mmkvStorage.setItem('@user:current', scope);
// Update cache
cachedSettings = newSettings;
settingsCacheTimestamp = Date.now();
setSettings(newSettings);
if (__DEV__) console.log(`Setting updated: ${key}`, value);
// Notify all subscribers that settings have changed (if requested)
@ -317,7 +333,7 @@ export const useSettings = () => {
} catch (error) {
if (__DEV__) console.error('Failed to save settings:', error);
}
}, [settings]);
}, []);
return {
settings,

1492
src/i18n/locales/ja.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ import nlNL from './locales/nl-NL.json';
import ro from './locales/ro.json';
import sq from './locales/sq.json';
import ca from './locales/ca.json';
import ja from './locales/ja.json';
export const resources = {
en: { translation: en },
@ -51,4 +52,5 @@ export const resources = {
ro: { translation: ro },
sq: { translation: sq },
ca: { translation: ca },
ja: { translation: ja },
};

View file

@ -406,6 +406,7 @@ const AuthScreen: React.FC = () => {
placeholder="Password (min 6 characters)"
placeholderTextColor="rgba(255,255,255,0.4)"
style={[styles.input, { color: currentTheme.colors.white }]}
autoCapitalize="none"
secureTextEntry={!showPassword}
value={password}
onChangeText={setPassword}
@ -454,6 +455,7 @@ const AuthScreen: React.FC = () => {
placeholder="Confirm password"
placeholderTextColor="rgba(255,255,255,0.4)"
style={[styles.input, { color: currentTheme.colors.white }]}
autoCapitalize="none"
secureTextEntry={!showConfirm}
value={confirmPassword}
onChangeText={setConfirmPassword}

View file

@ -289,7 +289,7 @@ const DonorCard: React.FC<DonorCardProps> = ({ donor, currentTheme, isTablet })
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletContributions
]}>
{donor.amount.toFixed(2)} {donor.currency} · {formatDonationDate(donor.date)}
{formatDonationDate(donor.date)}
</Text>
{donor.message ? (
<Text style={[styles.donorMessage, { color: currentTheme.colors.mediumEmphasis }]}>
@ -876,7 +876,7 @@ const ContributorsScreen: React.FC = () => {
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletContributions
]}>
{entry.total.toFixed(2)} {entry.currency} · {entry.count} {entry.count === 1 ? 'donation' : 'donations'}
{entry.count} {entry.count === 1 ? 'donation' : 'donations'}
</Text>
<Text style={[styles.donorMessage, { color: currentTheme.colors.mediumEmphasis }]}>
Rank #{entry.rank} · Last: {formatDonationDate(entry.lastDate)}

View file

@ -11,7 +11,8 @@ import {
Platform,
useColorScheme,
Animated,
Dimensions
Dimensions,
ActivityIndicator
} from 'react-native';
import { useSettings } from '../hooks/useSettings';
import { LinearGradient } from 'expo-linear-gradient';
@ -110,7 +111,7 @@ const SectionHeader: React.FC<{ title: string; isDarkMode: boolean; colors: any
const HomeScreenSettings: React.FC = () => {
const { t } = useTranslation();
const { settings, updateSetting } = useSettings();
const { settings, updateSetting, isLoaded } = useSettings();
const systemColorScheme = useColorScheme();
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
@ -264,6 +265,20 @@ const HomeScreenSettings: React.FC = () => {
/>
);
if (!isLoaded) {
return (
<SafeAreaView style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={[
styles.container,
@ -711,6 +726,11 @@ const styles = StyleSheet.create({
marginLeft: 6,
fontWeight: '600',
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
export default HomeScreenSettings;

View file

@ -11,7 +11,6 @@ import {
StatusBar,
Platform,
Dimensions,
Linking,
FlatList,
} from 'react-native';
import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet';
@ -914,41 +913,6 @@ const SettingsScreen: React.FC = () => {
/>
</TouchableOpacity>
<View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
<TouchableOpacity
style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={() => Linking.openURL('https://discord.gg/KVgDTjhA4H')}
activeOpacity={0.7}
>
<View style={styles.discordButtonContent}>
<FastImage
source={{ uri: 'https://pngimg.com/uploads/discord/discord_PNG3.png' }}
style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
Discord
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]}
onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
activeOpacity={0.7}
>
<View style={styles.discordButtonContent}>
<FastImage
source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[styles.discordButtonText, { color: '#FF4500' }]}>
Reddit
</Text>
</View>
</TouchableOpacity>
</View>
</View>
{/* Monkey Animation */}
@ -1189,19 +1153,6 @@ const styles = StyleSheet.create({
borderRadius: 10,
maxWidth: 200,
},
discordButtonContent: {
flexDirection: 'row',
alignItems: 'center',
},
discordLogo: {
width: 18,
height: 18,
marginRight: 10,
},
discordButtonText: {
fontSize: 14,
fontWeight: '600',
},
kofiImage: {
height: 34,
width: 155,

View file

@ -1155,7 +1155,8 @@ const TMDBSettingsScreen = () => {
{ code: 'fr', label: 'Français', native: 'French' },
{ code: 'de', label: 'Deutsch', native: 'German' },
{ code: 'it', label: 'Italiano', native: 'Italian' },
{ code: 'pt', label: 'Português', native: 'Portuguese' },
{ code: 'pt-BR', label: 'Português (Brasil)', native: 'Português (Brasil)' },
{ code: 'pt', label: 'Português (Portugal)', native: 'Português' },
{ code: 'ru', label: 'Русский', native: 'Russian' },
{ code: 'tr', label: 'Türkçe', native: 'Turkish' },
{ code: 'ja', label: '日本語', native: 'Japanese' },

View file

@ -341,41 +341,6 @@ export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ dis
/>
</TouchableOpacity>
<View style={styles.socialRow}>
<TouchableOpacity
style={[styles.socialButton, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={() => Linking.openURL('https://discord.gg/KVgDTjhA4H')}
activeOpacity={0.7}
>
<View style={styles.socialButtonContent}>
<FastImage
source={{ uri: 'https://pngimg.com/uploads/discord/discord_PNG3.png' }}
style={styles.socialLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[styles.socialButtonText, { color: currentTheme.colors.highEmphasis }]}>
Discord
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.socialButton, { backgroundColor: '#FF4500' + '15' }]}
onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
activeOpacity={0.7}
>
<View style={styles.socialButtonContent}>
<FastImage
source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
style={styles.socialLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[styles.socialButtonText, { color: '#FF4500' }]}>
Reddit
</Text>
</View>
</TouchableOpacity>
</View>
</View>
{/* Monkey Animation */}
@ -469,30 +434,6 @@ const styles = StyleSheet.create({
width: 200,
height: 50,
},
socialRow: {
flexDirection: 'row',
gap: 12,
flexWrap: 'wrap',
justifyContent: 'center',
},
socialButton: {
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 12,
},
socialButtonContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
socialLogo: {
width: 24,
height: 24,
},
socialButtonText: {
fontSize: 15,
fontWeight: '600',
},
monkeyContainer: {
alignItems: 'center',
marginTop: 32,

View file

@ -500,7 +500,10 @@ class StorageService {
traktProgress: highestTraktProgress,
currentTime: highestCurrentTime,
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
// preserveTimestamp: true prevents lastUpdated from being bumped to Date.now(),
// which would make getUnsyncedProgress() think the entry needs re-syncing and
// re-add already-watched movies/episodes back to Trakt history.
await this.setWatchProgress(id, type, updatedProgress, episodeId, { preserveTimestamp: true });
}
} catch (error) {
logger.error('Error updating Trakt sync status:', error);
@ -540,7 +543,9 @@ class StorageService {
simklProgress: highestSimklProgress,
currentTime: highestCurrentTime,
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
// preserveTimestamp: true prevents lastUpdated from being bumped to Date.now(),
// which would make getUnsyncedProgress() treat synced entries as needing re-sync.
await this.setWatchProgress(id, type, updatedProgress, episodeId, { preserveTimestamp: true });
}
} catch (error) {
logger.error('Error updating Simkl sync status:', error);

View file

@ -404,23 +404,23 @@ class SupabaseSyncService {
watchedRows,
deviceRows,
] = await Promise.all([
this.request<Array<{ id: string }>>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
@ -1092,7 +1092,7 @@ class SupabaseSyncService {
if (!ownerId) return;
const rows = await this.request<PluginRow[]>(
`/rest/v1/plugins?select=url,name,enabled,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&order=sort_order.asc`,
`/rest/v1/plugins?select=url,name,enabled,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&profile_id=eq.1&order=sort_order.asc`,
{
method: 'GET',
authToken: token,
@ -1174,7 +1174,7 @@ class SupabaseSyncService {
if (!ownerId) return;
const rows = await this.request<AddonRow[]>(
`/rest/v1/addons?select=url,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&order=sort_order.asc`,
`/rest/v1/addons?select=url,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&profile_id=eq.1&order=sort_order.asc`,
{
method: 'GET',
authToken: token,

View file

@ -259,6 +259,17 @@ export class TMDBService {
}
}
/**
* Returns the resolved TMDB API key (custom user key if set, otherwise default).
* Always awaits key loading so callers get the correct value.
*/
async getApiKey(): Promise<string> {
if (!this.apiKeyLoaded) {
await this.loadApiKey();
}
return this.apiKey;
}
private async getHeaders() {
// Ensure API key is loaded before returning headers
if (!this.apiKeyLoaded) {

View file

@ -1,4 +1,6 @@
import { logger } from '../utils/logger';
import { Platform } from 'react-native';
import { YouTubeExtractor } from './youtubeExtractor';
export interface TrailerData {
url: string;
@ -6,372 +8,165 @@ export interface TrailerData {
year: number;
}
interface CacheEntry {
url: string;
expiresAt: number;
}
export class TrailerService {
// Environment-configurable values (Expo public env)
private static readonly ENV_LOCAL_BASE = process.env.EXPO_PUBLIC_TRAILER_LOCAL_BASE || 'http://46.62.173.157:3001';
private static readonly ENV_LOCAL_TRAILER_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_TRAILER_PATH || '/trailer';
private static readonly ENV_LOCAL_SEARCH_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_SEARCH_PATH || '/search-trailer';
// Cache for 5 seconds — just enough to avoid re-extracting on quick re-renders
private static readonly CACHE_TTL_MS = 5 * 1000;
private static urlCache = new Map<string, CacheEntry>();
private static readonly LOCAL_SERVER_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_TRAILER_PATH}`;
private static readonly AUTO_SEARCH_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_SEARCH_PATH}`;
private static readonly TIMEOUT = 20000; // 20 seconds
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fetches trailer URL for a given title and year
* @param title - The movie/series title
* @param year - The release year
* @param tmdbId - Optional TMDB ID for more accurate results
* @param type - Optional content type ('movie' or 'tv')
* @returns Promise<string | null> - The trailer URL or null if not found
* Get a playable stream URL from a raw YouTube video ID (e.g. from TMDB).
* Uses on-device extraction only.
*/
static async getTrailerUrl(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
logger.info('TrailerService', `getTrailerUrl requested: title="${title}", year=${year}, tmdbId=${tmdbId || 'n/a'}, type=${type || 'n/a'}`);
return this.getTrailerFromLocalServer(title, year, tmdbId, type);
static async getTrailerFromVideoId(
youtubeVideoId: string,
title?: string,
year?: number
): Promise<string | null> {
if (!youtubeVideoId) return null;
logger.info('TrailerService', `getTrailerFromVideoId: ${youtubeVideoId} (${title ?? '?'} ${year ?? ''})`);
const cached = this.getCached(youtubeVideoId);
if (cached) {
logger.info('TrailerService', `Cache hit for videoId=${youtubeVideoId}`);
return cached;
}
try {
const platform = Platform.OS === 'android' ? 'android' : 'ios';
const url = await YouTubeExtractor.getBestStreamUrl(youtubeVideoId, platform);
if (url) {
logger.info('TrailerService', `Extraction succeeded for ${youtubeVideoId}`);
this.setCache(youtubeVideoId, url);
return url;
}
logger.warn('TrailerService', `Extraction returned null for ${youtubeVideoId}`);
} catch (err) {
logger.warn('TrailerService', `Extraction threw for ${youtubeVideoId}:`, err);
}
return null;
}
/**
* Fetches trailer from local server using TMDB API or auto-search
* @param title - The movie/series title
* @param year - The release year
* @param tmdbId - Optional TMDB ID for more accurate results
* @param type - Optional content type ('movie' or 'tv')
* @returns Promise<string | null> - The trailer URL or null if not found
* Called by TrailerModal which has the full YouTube URL from TMDB.
* Parses the video ID then delegates to getTrailerFromVideoId.
*/
private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
const startTime = Date.now();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
static async getTrailerFromYouTubeUrl(
youtubeUrl: string,
title?: string,
year?: string
): Promise<string | null> {
logger.info('TrailerService', `getTrailerFromYouTubeUrl: ${youtubeUrl}`);
// Build URL with parameters
const params = new URLSearchParams();
// Always send title and year for logging and fallback
params.append('title', title);
params.append('year', year.toString());
if (tmdbId) {
params.append('tmdbId', tmdbId);
params.append('type', type || 'movie');
logger.info('TrailerService', `Using TMDB API for: ${title} (TMDB ID: ${tmdbId})`);
} else {
logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`);
}
const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`;
logger.info('TrailerService', `Local server request URL: ${url}`);
logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`);
logger.info('TrailerService', `Making fetch request to: ${url}`);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'Nuvio/1.0',
},
signal: controller.signal,
});
// logger.info('TrailerService', `Fetch request completed. Response status: ${response.status}`);
clearTimeout(timeoutId);
const elapsed = Date.now() - startTime;
const contentType = response.headers.get('content-type') || 'unknown';
// logger.info('TrailerService', `Local server response: status=${response.status} ok=${response.ok} content-type=${contentType} elapsedMs=${elapsed}`);
// Read body as text first so we can log it even on non-200s
let rawText = '';
try {
rawText = await response.text();
if (rawText) {
/*
const preview = rawText.length > 200 ? `${rawText.slice(0, 200)}...` : rawText;
logger.info('TrailerService', `Local server body preview: ${preview}`);
*/
} else {
// logger.info('TrailerService', 'Local server body is empty');
}
} catch (e) {
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
logger.warn('TrailerService', `Failed reading local server body text: ${msg}`);
}
if (!response.ok) {
logger.warn('TrailerService', `Auto-search failed: ${response.status} ${response.statusText}`);
const videoId = YouTubeExtractor.parseVideoId(youtubeUrl);
if (!videoId) {
logger.warn('TrailerService', `Could not parse video ID from: ${youtubeUrl}`);
return null;
}
// Attempt to parse JSON from the raw text
let data: any = null;
try {
data = rawText ? JSON.parse(rawText) : null;
// const keys = typeof data === 'object' && data !== null ? Object.keys(data).join(',') : typeof data;
// logger.info('TrailerService', `Local server JSON parsed. Keys/Type: ${keys}`);
} catch (e) {
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
logger.warn('TrailerService', `Failed to parse local server JSON: ${msg}`);
return null;
}
if (!data.url || !this.isValidTrailerUrl(data.url)) {
logger.warn('TrailerService', `Invalid trailer URL from auto-search: ${data.url}`);
return null;
}
// logger.info('TrailerService', `Successfully found trailer: ${String(data.url).substring(0, 80)}...`);
return data.url;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
logger.warn('TrailerService', `Auto-search request timed out after ${this.TIMEOUT}ms`);
} else {
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logger.error('TrailerService', `Error in auto-search: ${msg}`);
logger.error('TrailerService', `Error details:`, {
name: (error as any)?.name,
message: (error as any)?.message,
stack: (error as any)?.stack,
url: url
});
}
return null;
}
}
/**
* Validates if the provided string is a valid trailer URL
* @param url - The URL to validate
* @returns boolean - True if valid, false otherwise
*/
private static isValidTrailerUrl(url: string): boolean {
try {
const urlObj = new URL(url);
// Check if it's a valid HTTP/HTTPS URL
if (!['http:', 'https:'].includes(urlObj.protocol)) {
return false;
}
// Check for common video streaming domains/patterns
const validDomains = [
'theplatform.com',
'youtube.com',
'youtu.be',
'vimeo.com',
'dailymotion.com',
'twitch.tv',
'amazonaws.com',
'cloudfront.net',
'googlevideo.com', // Google's CDN for YouTube videos
'sn-aigl6nzr.googlevideo.com', // Specific Google CDN servers
'sn-aigl6nze.googlevideo.com',
'sn-aigl6nsk.googlevideo.com',
'sn-aigl6ns6.googlevideo.com'
];
const hostname = urlObj.hostname.toLowerCase();
const isValidDomain = validDomains.some(domain =>
hostname.includes(domain) || hostname.endsWith(domain)
return this.getTrailerFromVideoId(
videoId,
title,
year ? parseInt(year, 10) : undefined
);
// Special check for Google Video CDN (YouTube direct streaming URLs)
const isGoogleVideoCDN = hostname.includes('googlevideo.com') ||
hostname.includes('sn-') && hostname.includes('.googlevideo.com');
// Check for video file extensions or streaming formats
const hasVideoFormat = /\.(mp4|m3u8|mpd|webm|mov|avi|mkv)$/i.test(urlObj.pathname) ||
url.includes('formats=') ||
url.includes('manifest') ||
url.includes('playlist');
return isValidDomain || hasVideoFormat || isGoogleVideoCDN;
} catch {
return false;
}
}
/**
* Extracts the best video format URL from a multi-format URL
* @param url - The trailer URL that may contain multiple formats
* @returns string - The best format URL for mobile playback
* Called by AppleTVHero and HeroSection which only have title/year/tmdbId.
* Without a YouTube video ID there is nothing to extract returns null.
* Callers should ensure they pass a video ID via getTrailerFromVideoId instead.
*/
static getBestFormatUrl(url: string): string {
// If the URL contains format parameters, try to get the best one for mobile
if (url.includes('formats=')) {
// Prefer M3U (HLS) for better mobile compatibility
if (url.includes('M3U')) {
// Try to get M3U without encryption first, then with encryption
const baseUrl = url.split('?')[0];
const best = `${baseUrl}?formats=M3U+none,M3U+appleHlsEncryption`;
logger.info('TrailerService', `Optimized format URL from M3U: ${best.substring(0, 80)}...`);
return best;
}
// Fallback to MP4 if available
if (url.includes('MPEG4')) {
const baseUrl = url.split('?')[0];
const best = `${baseUrl}?formats=MPEG4`;
logger.info('TrailerService', `Optimized format URL from MPEG4: ${best.substring(0, 80)}...`);
return best;
}
static async getTrailerUrl(
title: string,
year: number,
_tmdbId?: string,
_type?: 'movie' | 'tv'
): Promise<string | null> {
logger.warn('TrailerService', `getTrailerUrl called for "${title}" but no YouTube video ID available — cannot extract`);
return null;
}
// Return the original URL if no format optimization is needed
// logger.info('TrailerService', 'No format optimization applied');
// ---------------------------------------------------------------------------
// Public helpers (API compatibility)
// ---------------------------------------------------------------------------
static getBestFormatUrl(url: string): string {
return url;
}
/**
* Checks if a trailer is available for the given title and year
* @param title - The movie/series title
* @param year - The release year
* @returns Promise<boolean> - True if trailer is available
*/
static async isTrailerAvailable(title: string, year: number): Promise<boolean> {
logger.info('TrailerService', `Checking trailer availability for: ${title} (${year})`);
const trailerUrl = await this.getTrailerUrl(title, year);
logger.info('TrailerService', `Trailer availability for ${title} (${year}): ${trailerUrl ? 'available' : 'not available'}`);
return trailerUrl !== null;
static async isTrailerAvailable(videoId: string): Promise<boolean> {
return (await this.getTrailerFromVideoId(videoId)) !== null;
}
/**
* Gets trailer data with additional metadata
* @param title - The movie/series title
* @param year - The release year
* @returns Promise<TrailerData | null> - Trailer data or null if not found
*/
static async getTrailerData(title: string, year: number): Promise<TrailerData | null> {
logger.info('TrailerService', `getTrailerData for: ${title} (${year})`);
const url = await this.getTrailerUrl(title, year);
if (!url) {
logger.info('TrailerService', 'No trailer URL found for getTrailerData');
return null;
if (!url) return null;
return { url, title, year };
}
return {
url: this.getBestFormatUrl(url),
title,
year
};
static invalidateCache(videoId: string): void {
this.urlCache.delete(videoId);
logger.info('TrailerService', `Cache invalidated for videoId=${videoId}`);
}
/**
* Fetches trailer directly from a known YouTube URL
* @param youtubeUrl - The YouTube URL to process
* @param title - Optional title for logging/caching
* @param year - Optional year for logging/caching
* @returns Promise<string | null> - The direct streaming URL or null if failed
*/
static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise<string | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
static setUseLocalServer(_useLocal: boolean): void {}
const params = new URLSearchParams();
params.append('youtube_url', youtubeUrl);
if (title) params.append('title', title);
if (year) params.append('year', year.toString());
const url = `${this.ENV_LOCAL_BASE}${this.ENV_LOCAL_TRAILER_PATH}?${params.toString()}`;
logger.info('TrailerService', `Fetching trailer directly from YouTube URL: ${youtubeUrl}`);
logger.info('TrailerService', `Direct trailer request URL: ${url}`);
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'Nuvio/1.0',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
logger.info('TrailerService', `Direct trailer response: status=${response.status} ok=${response.ok}`);
if (!response.ok) {
logger.warn('TrailerService', `Direct trailer failed: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
if (!data.url || !this.isValidTrailerUrl(data.url)) {
logger.warn('TrailerService', `Invalid trailer URL from direct fetch: ${data.url}`);
return null;
}
logger.info('TrailerService', `Successfully got direct trailer: ${String(data.url).substring(0, 80)}...`);
return data.url;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
logger.warn('TrailerService', `Direct trailer request timed out after ${this.TIMEOUT}ms`);
} else {
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logger.error('TrailerService', `Error in direct trailer fetch: ${msg}`);
}
return null;
}
}
/**
* Switch between local server (deprecated - always uses local server now)
* @param useLocal - true for local server (always true now)
*/
static setUseLocalServer(useLocal: boolean): void {
if (!useLocal) {
logger.warn('TrailerService', 'XPrime API is no longer supported. Always using local server.');
}
logger.info('TrailerService', 'Using local server');
}
/**
* Get current server status
* @returns object with server information
*/
static getServerStatus(): { usingLocal: boolean; localUrl: string } {
return {
usingLocal: true,
localUrl: this.LOCAL_SERVER_URL,
};
return { usingLocal: false, localUrl: '' };
}
/**
* Test local server and return its status
* @returns Promise with server status information
*/
static async testServers(): Promise<{
localServer: { status: 'online' | 'offline'; responseTime?: number };
}> {
logger.info('TrailerService', 'Testing local server');
const results: {
localServer: { status: 'online' | 'offline'; responseTime?: number };
} = {
localServer: { status: 'offline' }
};
return { localServer: { status: 'offline' } };
}
// Test local server
// ---------------------------------------------------------------------------
// Private — cache
// ---------------------------------------------------------------------------
private static getCached(key: string): string | null {
const entry = this.urlCache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.urlCache.delete(key);
return null;
}
// Check the URL's own CDN expiry — googlevideo.com URLs carry an `expire`
// param (Unix timestamp). Treat as stale if it expires within 2 minutes.
if (entry.url.includes('googlevideo.com')) {
try {
const startTime = Date.now();
const response = await fetch(`${this.AUTO_SEARCH_URL}?title=test&year=2023`, {
method: 'GET',
signal: AbortSignal.timeout(5000) // 5 second timeout
});
if (response.ok || response.status === 404) { // 404 is ok, means server is running
results.localServer = {
status: 'online',
responseTime: Date.now() - startTime
};
logger.info('TrailerService', `Local server online. Response time: ${results.localServer.responseTime}ms`);
const u = new URL(entry.url);
const expire = u.searchParams.get('expire');
if (expire) {
const expiresAt = parseInt(expire, 10) * 1000;
if (Date.now() > expiresAt - 2 * 60 * 1000) {
logger.info('TrailerService', `Cached URL expired or expiring soon — re-extracting`);
this.urlCache.delete(key);
return null;
}
} catch (error) {
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logger.warn('TrailerService', `Local server test failed: ${msg}`);
}
} catch { /* ignore */ }
}
return entry.url;
}
logger.info('TrailerService', `Server test results -> local: ${results.localServer.status}`);
return results;
private static setCache(key: string, url: string): void {
this.urlCache.set(key, { url, expiresAt: Date.now() + this.CACHE_TTL_MS });
if (this.urlCache.size > 100) {
const oldest = this.urlCache.keys().next().value;
if (oldest) this.urlCache.delete(oldest);
}
}
}

View file

@ -0,0 +1,729 @@
import { logger } from '../utils/logger';
import { Platform } from 'react-native';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InnertubeFormat {
itag?: number;
url?: string;
mimeType?: string;
bitrate?: number;
averageBitrate?: number;
width?: number;
height?: number;
fps?: number;
quality?: string;
qualityLabel?: string;
audioQuality?: string;
audioSampleRate?: string;
initRange?: { start: string; end: string };
indexRange?: { start: string; end: string };
}
interface PlayerResponse {
streamingData?: {
formats?: InnertubeFormat[];
adaptiveFormats?: InnertubeFormat[];
hlsManifestUrl?: string;
};
playabilityStatus?: {
status?: string;
reason?: string;
};
}
interface StreamCandidate {
client: string;
priority: number;
url: string;
score: number;
height: number;
fps: number;
ext: 'mp4' | 'webm' | 'm4a' | 'other';
bitrate: number;
audioSampleRate?: string;
mimeType: string;
}
interface HlsVariant {
url: string;
width: number;
height: number;
bandwidth: number;
}
export interface YouTubeExtractionResult {
/** Primary playable URL — HLS manifest, progressive muxed, or video-only adaptive */
videoUrl: string;
/** Separate audio URL when adaptive video-only is used. null for HLS/progressive. */
audioUrl: string | null;
quality: string;
videoId: string;
}
// ---------------------------------------------------------------------------
// Constants — matching the Kotlin extractor exactly
// ---------------------------------------------------------------------------
// Used for all GET requests (watch page, HLS manifest fetch)
const DEFAULT_USER_AGENT =
'Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36';
const DEFAULT_HEADERS: Record<string, string> = {
'accept-language': 'en-US,en;q=0.9',
'user-agent': DEFAULT_USER_AGENT,
};
const PREFERRED_ADAPTIVE_CLIENT = 'android_vr';
const REQUEST_TIMEOUT_MS = 6000; // player API + HLS manifest requests
const WATCH_PAGE_TIMEOUT_MS = 3000; // watch page scrape — best-effort only
const MAX_RETRIES = 2; // retry extraction up to 2 times on total failure
interface ClientDef {
key: string;
id: string;
version: string;
userAgent: string;
context: Record<string, any>;
priority: number;
}
// Matching the Kotlin extractor client list exactly (versions updated to current)
const CLIENTS: ClientDef[] = [
{
key: 'android_vr',
id: '28',
version: '1.62.27',
userAgent:
'com.google.android.apps.youtube.vr.oculus/1.62.27 ' +
'(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip',
context: {
clientName: 'ANDROID_VR',
clientVersion: '1.62.27',
deviceMake: 'Oculus',
deviceModel: 'Quest 3',
osName: 'Android',
osVersion: '12',
platform: 'MOBILE',
androidSdkVersion: 32,
hl: 'en',
gl: 'US',
},
priority: 0,
},
{
key: 'android',
id: '3',
version: '20.10.38',
userAgent:
'com.google.android.youtube/20.10.38 (Linux; U; Android 14; en_US) gzip',
context: {
clientName: 'ANDROID',
clientVersion: '20.10.38',
osName: 'Android',
osVersion: '14',
platform: 'MOBILE',
androidSdkVersion: 34,
hl: 'en',
gl: 'US',
},
priority: 1,
},
{
key: 'ios',
id: '5',
version: '20.10.1',
userAgent:
'com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)',
context: {
clientName: 'IOS',
clientVersion: '20.10.1',
deviceModel: 'iPhone16,2',
osName: 'iPhone',
osVersion: '17.4.0.21E219',
platform: 'MOBILE',
hl: 'en',
gl: 'US',
},
priority: 2,
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseVideoId(input: string): string | null {
if (!input) return null;
const trimmed = input.trim();
if (/^[A-Za-z0-9_-]{11}$/.test(trimmed)) return trimmed;
try {
const url = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`);
const host = url.hostname.toLowerCase();
if (host.endsWith('youtu.be')) {
const id = url.pathname.slice(1).split('/')[0];
if (/^[A-Za-z0-9_-]{11}$/.test(id)) return id;
}
const v = url.searchParams.get('v');
if (v && /^[A-Za-z0-9_-]{11}$/.test(v)) return v;
const m = url.pathname.match(/\/(embed|shorts|live|v)\/([A-Za-z0-9_-]{11})/);
if (m) return m[2];
} catch {
const m = trimmed.match(/[?&]v=([A-Za-z0-9_-]{11})/);
if (m) return m[1];
}
return null;
}
function getMimeBase(mimeType?: string): string {
return (mimeType ?? '').split(';')[0].trim();
}
function getExt(mimeType?: string): 'mp4' | 'webm' | 'm4a' | 'other' {
const base = getMimeBase(mimeType);
if (base === 'video/mp4' || base === 'audio/mp4') return 'mp4';
if (base.includes('webm')) return 'webm';
if (base.includes('m4a')) return 'm4a';
return 'other';
}
function containerScore(ext: string): number {
return ext === 'mp4' || ext === 'm4a' ? 0 : ext === 'webm' ? 1 : 2;
}
function videoScore(height: number, fps: number, bitrate: number): number {
return height * 1_000_000_000 + fps * 1_000_000 + bitrate;
}
function audioScore(bitrate: number, sampleRate: number): number {
return bitrate * 1_000_000 + sampleRate;
}
function parseQualityLabel(label?: string): number {
const m = (label ?? '').match(/(\d{2,4})p/);
return m ? parseInt(m[1], 10) : 0;
}
function summarizeUrl(url: string): string {
try {
const u = new URL(url);
return `${u.hostname}${u.pathname.substring(0, 40)}`;
} catch {
return url.substring(0, 80);
}
}
// ---------------------------------------------------------------------------
// URL validation — HEAD request to check if URL is actually accessible
// ---------------------------------------------------------------------------
async function validateUrl(url: string, userAgent: string): Promise<boolean> {
// Only validate googlevideo.com CDN URLs — other URLs (HLS manifests) are fine
if (!url.includes('googlevideo.com')) return true;
// Check expiry param before making a network request
try {
const u = new URL(url);
const expire = u.searchParams.get('expire');
if (expire) {
const expiresAt = parseInt(expire, 10) * 1000;
if (Date.now() > expiresAt - 30000) {
logger.warn('YouTubeExtractor', `URL expired or expiring in <30s: expire=${expire}`);
return false;
}
}
} catch { /* ignore URL parse errors */ }
// Quick HEAD request to confirm URL is accessible
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 4000);
try {
const res = await fetch(url, {
method: 'HEAD',
headers: { 'User-Agent': userAgent },
signal: controller.signal,
});
clearTimeout(timer);
if (res.status === 403 || res.status === 401) {
logger.warn('YouTubeExtractor', `URL validation failed: HTTP ${res.status}`);
return false;
}
return true;
} catch (err) {
clearTimeout(timer);
// Network error or timeout — assume valid and let the player try
logger.warn('YouTubeExtractor', `URL validation request failed (assuming valid):`, err);
return true;
}
}
// ---------------------------------------------------------------------------
// android_vr preferred selection — only fall back to other clients if
// android_vr returned zero formats (likely PO token required for others)
// ---------------------------------------------------------------------------
function filterPreferAndroidVr(items: StreamCandidate[]): StreamCandidate[] {
const fromVr = items.filter(c => c.client === 'android_vr');
return fromVr.length > 0 ? fromVr : items;
}
function sortCandidates(items: StreamCandidate[]): StreamCandidate[] {
return [...items].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const ca = containerScore(a.ext), cb = containerScore(b.ext);
if (ca !== cb) return ca - cb;
return a.priority - b.priority;
});
}
function pickBestForClient(
items: StreamCandidate[],
preferredClient: string,
): StreamCandidate | null {
const fromPreferred = items.filter(c => c.client === preferredClient);
const pool = fromPreferred.length > 0 ? fromPreferred : items;
return sortCandidates(pool)[0] ?? null;
}
// ---------------------------------------------------------------------------
// Watch page — extract API key + visitor data dynamically
// ---------------------------------------------------------------------------
interface WatchConfig {
apiKey: string | null;
visitorData: string | null;
}
async function fetchWatchConfig(videoId: string): Promise<WatchConfig> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), WATCH_PAGE_TIMEOUT_MS);
try {
const res = await fetch(
`https://www.youtube.com/watch?v=${videoId}&hl=en`,
{ headers: DEFAULT_HEADERS, signal: controller.signal },
);
clearTimeout(timer);
if (!res.ok) {
logger.warn('YouTubeExtractor', `Watch page ${res.status}`);
return { apiKey: null, visitorData: null };
}
const html = await res.text();
const apiKey = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/)?.[1] ?? null;
const visitorData = html.match(/"VISITOR_DATA":"([^"]+)"/)?.[1] ?? null;
logger.info('YouTubeExtractor', `Watch page: apiKey=${apiKey ? 'found' : 'missing'} visitorData=${visitorData ? 'found' : 'missing'}`);
return { apiKey, visitorData };
} catch (err) {
clearTimeout(timer);
logger.warn('YouTubeExtractor', 'Watch page error:', err);
return { apiKey: null, visitorData: null };
}
}
// ---------------------------------------------------------------------------
// Player API
// ---------------------------------------------------------------------------
async function fetchPlayerResponse(
videoId: string,
client: ClientDef,
apiKey: string | null,
visitorData: string | null,
): Promise<PlayerResponse | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const endpoint = apiKey
? `https://www.youtube.com/youtubei/v1/player?key=${encodeURIComponent(apiKey)}&prettyPrint=false`
: `https://www.youtube.com/youtubei/v1/player?prettyPrint=false`;
const headers: Record<string, string> = {
...DEFAULT_HEADERS,
'content-type': 'application/json',
'origin': 'https://www.youtube.com',
'referer': `https://www.youtube.com/watch?v=${videoId}`,
'x-youtube-client-name': client.id,
'x-youtube-client-version': client.version,
'user-agent': client.userAgent,
};
if (visitorData) headers['x-goog-visitor-id'] = visitorData;
const body = JSON.stringify({
videoId,
contentCheckOk: true,
racyCheckOk: true,
context: { client: client.context },
playbackContext: {
contentPlaybackContext: { html5Preference: 'HTML5_PREF_WANTS' },
},
});
try {
const res = await fetch(endpoint, {
method: 'POST',
headers,
body,
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
logger.warn('YouTubeExtractor', `[${client.key}] HTTP ${res.status}`);
return null;
}
return await res.json() as PlayerResponse;
} catch (err) {
clearTimeout(timer);
if (err instanceof Error && err.name === 'AbortError') {
logger.warn('YouTubeExtractor', `[${client.key}] Timed out`);
} else {
logger.warn('YouTubeExtractor', `[${client.key}] Error:`, err);
}
return null;
}
}
// ---------------------------------------------------------------------------
// HLS manifest parsing
// ---------------------------------------------------------------------------
async function parseBestHlsVariant(manifestUrl: string): Promise<HlsVariant | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(manifestUrl, {
headers: DEFAULT_HEADERS,
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) return null;
const text = await res.text();
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
let best: HlsVariant | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.startsWith('#EXT-X-STREAM-INF:')) continue;
const nextLine = lines[i + 1];
if (!nextLine || nextLine.startsWith('#')) continue;
// Parse attribute list
const attrs: Record<string, string> = {};
let key = '', val = '', inKey = true, inQuote = false;
for (const ch of line.substring(line.indexOf(':') + 1)) {
if (inKey) { if (ch === '=') inKey = false; else key += ch; continue; }
if (ch === '"') { inQuote = !inQuote; continue; }
if (ch === ',' && !inQuote) {
if (key.trim()) attrs[key.trim()] = val.trim();
key = ''; val = ''; inKey = true; continue;
}
val += ch;
}
if (key.trim()) attrs[key.trim()] = val.trim();
const res2 = (attrs['RESOLUTION'] ?? '').split('x');
const width = parseInt(res2[0] ?? '0', 10) || 0;
const height = parseInt(res2[1] ?? '0', 10) || 0;
const bandwidth = parseInt(attrs['BANDWIDTH'] ?? '0', 10) || 0;
let variantUrl = nextLine;
if (!variantUrl.startsWith('http')) {
try { variantUrl = new URL(variantUrl, manifestUrl).toString(); } catch { /* keep */ }
}
const candidate: HlsVariant = { url: variantUrl, width, height, bandwidth };
if (
!best ||
candidate.height > best.height ||
(candidate.height === best.height && candidate.bandwidth > best.bandwidth)
) {
best = candidate;
}
}
return best;
} catch (err) {
clearTimeout(timer);
logger.warn('YouTubeExtractor', 'HLS manifest parse error:', err);
return null;
}
}
// ---------------------------------------------------------------------------
// Format collection — tries ALL clients, collects from all (matching Kotlin)
// ---------------------------------------------------------------------------
interface CollectedFormats {
progressive: StreamCandidate[];
adaptiveVideo: StreamCandidate[];
adaptiveAudio: StreamCandidate[];
hlsManifests: Array<{ clientKey: string; priority: number; url: string }>;
}
async function collectAllFormats(
videoId: string,
apiKey: string | null,
visitorData: string | null,
): Promise<CollectedFormats> {
const progressive: StreamCandidate[] = [];
const adaptiveVideo: StreamCandidate[] = [];
const adaptiveAudio: StreamCandidate[] = [];
const hlsManifests: Array<{ clientKey: string; priority: number; url: string }> = [];
// Fire all client requests in parallel — same approach as Kotlin coroutines
const results = await Promise.allSettled(
CLIENTS.map(client => fetchPlayerResponse(videoId, client, apiKey, visitorData)
.then(resp => ({ client, resp }))
)
);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn('YouTubeExtractor', `Client request rejected:`, result.reason);
continue;
}
const { client, resp } = result.value;
if (!resp) continue;
const status = resp.playabilityStatus?.status;
if (status && status !== 'OK' && status !== 'CONTENT_CHECK_REQUIRED') {
logger.warn('YouTubeExtractor', `[${client.key}] status=${status} reason=${resp.playabilityStatus?.reason ?? ''}`);
continue;
}
const sd = resp.streamingData;
if (!sd) continue;
if (sd.hlsManifestUrl) {
hlsManifests.push({ clientKey: client.key, priority: client.priority, url: sd.hlsManifestUrl });
}
let nProg = 0, nVid = 0, nAud = 0;
// Progressive (muxed) formats — matching Kotlin: skip non-video mimeTypes
for (const f of (sd.formats ?? [])) {
if (!f.url) continue;
const mimeBase = getMimeBase(f.mimeType);
if (f.mimeType && !mimeBase.startsWith('video/')) continue;
const height = f.height ?? parseQualityLabel(f.qualityLabel);
const fps = f.fps ?? 0;
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
progressive.push({
client: client.key,
priority: client.priority,
url: f.url,
score: videoScore(height, fps, bitrate),
height,
fps,
ext: getExt(f.mimeType),
bitrate,
mimeType: f.mimeType ?? '',
});
nProg++;
}
// Adaptive formats
for (const f of (sd.adaptiveFormats ?? [])) {
if (!f.url) continue;
const mimeBase = getMimeBase(f.mimeType);
if (mimeBase.startsWith('video/')) {
const height = f.height ?? parseQualityLabel(f.qualityLabel);
const fps = f.fps ?? 0;
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
adaptiveVideo.push({
client: client.key,
priority: client.priority,
url: f.url,
score: videoScore(height, fps, bitrate),
height,
fps,
ext: getExt(f.mimeType),
bitrate,
mimeType: f.mimeType ?? '',
});
nVid++;
} else if (mimeBase.startsWith('audio/')) {
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
const sampleRate = parseFloat(f.audioSampleRate ?? '0') || 0;
adaptiveAudio.push({
client: client.key,
priority: client.priority,
url: f.url,
score: audioScore(bitrate, sampleRate),
height: 0,
fps: 0,
ext: getExt(f.mimeType),
bitrate,
audioSampleRate: f.audioSampleRate,
mimeType: f.mimeType ?? '',
});
nAud++;
}
}
logger.info('YouTubeExtractor', `[${client.key}] progressive=${nProg} video=${nVid} audio=${nAud} hls=${sd.hlsManifestUrl ? 1 : 0}`);
}
return { progressive, adaptiveVideo, adaptiveAudio, hlsManifests };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export class YouTubeExtractor {
/**
* Extract a playable source from a YouTube video ID or URL.
*
* Matches the Kotlin InAppYouTubeExtractor approach:
* 1. Fetch watch page for dynamic API key + visitor data
* 2. Try ALL clients, collect formats from all that succeed
* 3. Pick best HLS variant (by resolution/bandwidth) as primary
* 4. Fall back to best progressive (muxed) if no HLS
*
* Note: Unlike the Kotlin version, we do not return separate videoUrl/audioUrl
* for adaptive streams react-native-video cannot merge two sources. HLS
* provides the best quality without needing a separate audio track.
*/
static async extract(
videoIdOrUrl: string,
platform?: 'android' | 'ios',
): Promise<YouTubeExtractionResult | null> {
const videoId = parseVideoId(videoIdOrUrl);
if (!videoId) {
logger.warn('YouTubeExtractor', `Could not parse video ID: ${videoIdOrUrl}`);
return null;
}
const effectivePlatform = platform ?? (Platform.OS === 'android' ? 'android' : 'ios');
for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
if (attempt > 1) {
const delay = attempt * 300;
logger.info('YouTubeExtractor', `Retry attempt ${attempt}/${MAX_RETRIES + 1} after ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
const result = await this.extractOnce(videoId, effectivePlatform);
if (result) return result;
logger.warn('YouTubeExtractor', `Attempt ${attempt} failed for videoId=${videoId}`);
}
logger.warn('YouTubeExtractor', `All ${MAX_RETRIES + 1} attempts failed for videoId=${videoId}`);
return null;
}
private static async extractOnce(
videoId: string,
effectivePlatform: 'android' | 'ios',
): Promise<YouTubeExtractionResult | null> {
logger.info('YouTubeExtractor', `Extracting videoId=${videoId} platform=${effectivePlatform}`);
const { apiKey, visitorData } = await fetchWatchConfig(videoId);
// Step 2: collect formats from all clients
const { progressive, adaptiveVideo, adaptiveAudio, hlsManifests } =
await collectAllFormats(videoId, apiKey, visitorData);
logger.info('YouTubeExtractor',
`Totals: progressive=${progressive.length} adaptiveVideo=${adaptiveVideo.length} ` +
`adaptiveAudio=${adaptiveAudio.length} hls=${hlsManifests.length}`
);
if (progressive.length === 0 && adaptiveVideo.length === 0 && hlsManifests.length === 0) {
logger.warn('YouTubeExtractor', `No usable formats for videoId=${videoId}`);
return null;
}
// Step 3: pick best HLS variant across all manifests
let bestHls: (HlsVariant & { manifestUrl: string }) | null = null;
for (const { url } of hlsManifests.sort((a, b) => a.priority - b.priority)) {
const variant = await parseBestHlsVariant(url);
if (
variant &&
(!bestHls ||
variant.height > bestHls.height ||
(variant.height === bestHls.height && variant.bandwidth > bestHls.bandwidth))
) {
bestHls = { ...variant, manifestUrl: url };
}
}
// Prefer android_vr formats exclusively — other clients may require PO tokens
// and return URLs that 403 at the CDN level during playback
const preferredProgressive = sortCandidates(filterPreferAndroidVr(progressive));
const bestAdaptiveVideo = pickBestForClient(adaptiveVideo, PREFERRED_ADAPTIVE_CLIENT);
const bestAdaptiveAudio = pickBestForClient(adaptiveAudio, PREFERRED_ADAPTIVE_CLIENT);
if (bestHls) logger.info('YouTubeExtractor', `Best HLS: ${bestHls.height}p ${bestHls.bandwidth}bps`);
if (preferredProgressive[0]) logger.info('YouTubeExtractor', `Best progressive: ${preferredProgressive[0].height}p client=${preferredProgressive[0].client}`);
if (bestAdaptiveVideo) logger.info('YouTubeExtractor', `Best adaptive video: ${bestAdaptiveVideo.height}p client=${bestAdaptiveVideo.client}`);
if (bestAdaptiveAudio) logger.info('YouTubeExtractor', `Best adaptive audio: ${bestAdaptiveAudio.bitrate}bps client=${bestAdaptiveAudio.client}`);
// VR client user agent used for CDN URL validation
const vrUserAgent = CLIENTS.find(c => c.key === 'android_vr')!.userAgent;
// Step 4: select final source with URL validation
// Priority: HLS > progressive muxed
// HLS manifests don't need validation — they're not CDN segment URLs
if (bestHls) {
// Return the specific best variant URL, not the master playlist.
// Master playlist lets the player pick quality adaptively (often starts low).
// Pinning to the best variant ensures consistent high quality playback.
logger.info('YouTubeExtractor', `Using HLS variant: ${summarizeUrl(bestHls.url)} ${bestHls.height}p`);
return {
videoUrl: bestHls.url,
audioUrl: null,
quality: `${bestHls.height}p`,
videoId,
};
}
// Validate progressive candidates in order, return first valid one
for (const candidate of preferredProgressive) {
const valid = await validateUrl(candidate.url, vrUserAgent);
if (valid) {
logger.info('YouTubeExtractor', `Using progressive: ${summarizeUrl(candidate.url)} ${candidate.height}p`);
return {
videoUrl: candidate.url,
audioUrl: null,
quality: `${candidate.height}p`,
videoId,
};
}
logger.warn('YouTubeExtractor', `Progressive URL invalid, trying next candidate`);
}
// Last resort: video-only adaptive (no audio, but beats nothing)
if (bestAdaptiveVideo) {
const valid = await validateUrl(bestAdaptiveVideo.url, vrUserAgent);
if (valid) {
logger.warn('YouTubeExtractor', `Using video-only adaptive (no audio): ${bestAdaptiveVideo.height}p`);
return {
videoUrl: bestAdaptiveVideo.url,
audioUrl: null,
quality: `${bestAdaptiveVideo.height}p`,
videoId,
};
}
}
logger.warn('YouTubeExtractor', `No playable source for videoId=${videoId}`);
return null;
}
static async getBestStreamUrl(
videoIdOrUrl: string,
platform?: 'android' | 'ios',
): Promise<string | null> {
const result = await this.extract(videoIdOrUrl, platform);
return result?.videoUrl ?? null;
}
static parseVideoId(input: string): string | null {
return parseVideoId(input);
}
}
export default YouTubeExtractor;