vlcplayer init

This commit is contained in:
tapframe 2025-09-22 17:14:50 +05:30
parent 83c3bdbdde
commit 37ece44b9b
10 changed files with 261 additions and 30 deletions

View file

@ -54,3 +54,4 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
android.minSdkVersion=26

View file

@ -79,6 +79,13 @@
{
"username": "nayifleo"
}
],
[
"expo-libvlc-player",
{
"localNetworkPermission": "Allow $(PRODUCT_NAME) to access your local network",
"supportsBackgroundPlayback": true
}
]
],
"updates": {

View file

@ -428,7 +428,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = Nuvio;
PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -462,7 +462,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = Nuvio;
PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

View file

@ -56,7 +56,7 @@
<string>_http._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>App uses the local network to discover and connect to devices.</string>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTRootViewBackgroundColor</key>

View file

@ -1,5 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array/>
</dict>
</plist>

117
package-lock.json generated
View file

@ -46,6 +46,7 @@
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-intent-launcher": "~12.0.2",
"expo-libvlc-player": "^2.1.7",
"expo-linear-gradient": "~14.0.2",
"expo-localization": "~16.0.1",
"expo-notifications": "~0.29.14",
@ -72,6 +73,7 @@
"react-native-svg": "15.8.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.12.0",
"react-native-vlc-media-player": "^1.0.94",
"react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1"
},
@ -7906,6 +7908,17 @@
"react": "*"
}
},
"node_modules/expo-libvlc-player": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/expo-libvlc-player/-/expo-libvlc-player-2.1.7.tgz",
"integrity": "sha512-WJz5vZE3qcFi2yeSYFWimdUS6oG+/gZZzsuhTvjCJ0pDTWsVeYVvmZbF4fg6cKRZf2WkZTaLzfngfVEBa6+3hA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-linear-gradient": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.0.2.tgz",
@ -12737,6 +12750,15 @@
"react-native": "*"
}
},
"node_modules/react-native-slider": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-native-slider/-/react-native-slider-0.11.0.tgz",
"integrity": "sha512-jV9K87eu9uWr0uJIyrSpBLnCKvVlOySC2wynq9TFCdV9oGgjt7Niq8Q1A8R8v+5GHsuBw/s8vEj1AAkkUi+u+w==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.5.6"
}
},
"node_modules/react-native-svg": {
"version": "15.8.0",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz",
@ -12888,6 +12910,91 @@
"react-native": "*"
}
},
"node_modules/react-native-vector-icons": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-9.2.0.tgz",
"integrity": "sha512-wKYLaFuQST/chH3AJRjmOLoLy3JEs1JR6zMNgTaemFpNoXs0ztRnTxcxFD9xhX7cJe1/zoN5BpQYe7kL0m5yyA==",
"deprecated": "react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2",
"yargs": "^16.1.1"
},
"bin": {
"fa5-upgrade": "bin/fa5-upgrade.sh",
"generate-icon": "bin/generate-icon.js"
}
},
"node_modules/react-native-vector-icons/node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"node_modules/react-native-vector-icons/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/react-native-vector-icons/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/react-native-vector-icons/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/react-native-vector-icons/node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"license": "MIT",
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^20.2.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-vector-icons/node_modules/yargs-parser": {
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-video": {
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-6.16.1.tgz",
@ -12898,6 +13005,16 @@
"react-native": "*"
}
},
"node_modules/react-native-vlc-media-player": {
"version": "1.0.94",
"resolved": "https://registry.npmjs.org/react-native-vlc-media-player/-/react-native-vlc-media-player-1.0.94.tgz",
"integrity": "sha512-6Ee09NY3ir4UN7mSFv8N+4GBiUQzAyVWU54ilwBAOZO8ICU2aJJmOq9ptYxvswIiRcbSz8Z+aB7LnjIlMR6WoQ==",
"license": "MIT",
"dependencies": {
"react-native-slider": "^0.11.0",
"react-native-vector-icons": "^9.2.0"
}
},
"node_modules/react-native-web": {
"version": "0.19.13",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",

View file

@ -46,6 +46,7 @@
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-intent-launcher": "~12.0.2",
"expo-libvlc-player": "^2.1.7",
"expo-linear-gradient": "~14.0.2",
"expo-localization": "~16.0.1",
"expo-notifications": "~0.29.14",
@ -72,6 +73,7 @@
"react-native-svg": "15.8.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.12.0",
"react-native-vlc-media-player": "^1.0.94",
"react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1"
},

View file

@ -1,14 +0,0 @@
module.exports = {
dependencies: {
'react-native-vlc-media-player': {
platforms: {
android: {
sourceDir: '../node_modules/react-native-vlc-media-player/android',
packageImportPath: 'import io.github.react_native_vlc_media_player.ReactNativeVlcMediaPlayerPackage;',
// Disable autolinking for Android
disable: true,
},
},
},
},
};

View file

@ -40,6 +40,7 @@ import { stremioService } from '../../services/stremioService';
import { shouldUseKSPlayer } from '../../utils/playerSelection';
import axios from 'axios';
import * as Brightness from 'expo-brightness';
import { LibVlcPlayerView } from 'expo-libvlc-player';
// Map VLC resize modes to react-native-video resize modes
const getVideoResizeMode = (resizeMode: ResizeModeType) => {
@ -76,6 +77,16 @@ const AndroidVideoPlayer: React.FC = () => {
backdrop
} = route.params;
// Opt-in flag to use VLC backend
const forceVlc = useMemo(() => {
const rp: any = route.params || {};
const v = rp.forceVlc !== undefined ? rp.forceVlc : rp.forceVLC;
return typeof v === 'string' ? v.toLowerCase() === 'true' : Boolean(v);
}, [route.params]);
// TEMP toggle disabled; rely on route param forceVlc
const TEMP_FORCE_VLC = false;
const useVLC = Platform.OS === 'android' && (TEMP_FORCE_VLC || forceVlc);
// Check if the stream is HLS (m3u8 playlist)
const isHlsStream = (url: string) => {
@ -250,6 +261,22 @@ const AndroidVideoPlayer: React.FC = () => {
const [errorDetails, setErrorDetails] = useState<string>('');
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// VLC refs/state
const vlcRef = useRef<any>(null);
const [vlcActive, setVlcActive] = useState(true);
// Compute VLC aspect ratio mapping from current resize mode
const vlcAspectRatio = useMemo(() => {
if (!useVLC) return undefined;
// Contain/original behavior -> let VLC choose best fit
if (resizeMode === 'contain' || resizeMode === 'none') return undefined;
// For cover/fill, force the view's aspect ratio to fill the container
if ((resizeMode === 'cover' || resizeMode === 'fill') && screenDimensions.width > 0 && screenDimensions.height > 0) {
return `${Math.round(screenDimensions.width)}:${Math.round(screenDimensions.height)}`;
}
return undefined;
}, [useVLC, resizeMode, screenDimensions.width, screenDimensions.height]);
// Volume and brightness controls
const [volume, setVolume] = useState(1.0);
@ -601,6 +628,12 @@ const AndroidVideoPlayer: React.FC = () => {
useFocusEffect(
useCallback(() => {
enableImmersiveMode();
// Workaround for VLC surface detach: briefly remount VLC view on focus
if (useVLC) {
setVlcActive(false);
const t = setTimeout(() => setVlcActive(true), 60);
return () => clearTimeout(t);
}
return () => {};
}, [])
);
@ -610,6 +643,11 @@ const AndroidVideoPlayer: React.FC = () => {
const onAppStateChange = (state: string) => {
if (state === 'active') {
enableImmersiveMode();
if (useVLC) {
// Briefly remount VLC view when app returns to foreground
setVlcActive(false);
setTimeout(() => setVlcActive(true), 60);
}
// On iOS, if we were playing before system interruption and the app becomes active again,
// ensure playback resumes (handles status bar pull-down case)
if (Platform.OS === 'ios' && wasPlayingBeforeIOSInterruptionRef.current && isPlayerReady) {
@ -816,6 +854,15 @@ const AndroidVideoPlayer: React.FC = () => {
const seekToTime = (rawSeconds: number) => {
// Clamp to just before the end of the media.
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
if (useVLC && duration > 0) {
try {
const fraction = Math.min(Math.max(timeInSeconds / duration, 0), 0.999);
if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
vlcRef.current.seek(fraction);
return;
}
} catch {}
}
if (videoRef.current && duration > 0 && !isSeeking.current) {
if (DEBUG_MODE) {
if (__DEV__) logger.log(`[AndroidVideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`);
@ -1844,14 +1891,22 @@ const AndroidVideoPlayer: React.FC = () => {
};
const togglePlayback = () => {
if (videoRef.current) {
const newPausedState = !paused;
const newPausedState = !paused;
if (useVLC && vlcRef.current) {
try {
if (newPausedState) {
if (typeof vlcRef.current.pause === 'function') vlcRef.current.pause();
} else {
if (typeof vlcRef.current.play === 'function') vlcRef.current.play();
}
} catch {}
setPaused(newPausedState);
} else if (videoRef.current) {
setPaused(newPausedState);
}
// IMMEDIATE: Send immediate progress update to Trakt for both pause and unpause
if (duration > 0) {
traktAutosync.handleProgressUpdate(currentTime, duration, true); // force=true triggers immediate sync
}
if (duration > 0) {
traktAutosync.handleProgressUpdate(currentTime, duration, true);
}
};
@ -2651,7 +2706,47 @@ const AndroidVideoPlayer: React.FC = () => {
onLongPress={resetZoom}
delayLongPress={300}
>
<Video
{useVLC ? (
<LibVlcPlayerView
ref={vlcRef}
style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]}
// Remount control
key={vlcActive ? 'vlc-on' : 'vlc-off'}
source={currentStreamUrl}
aspectRatio={vlcAspectRatio}
// When using contain/original, use scale 0 to let VLC manage
scale={resizeMode === 'contain' || resizeMode === 'none' ? 0 : 0}
volume={Math.round(Math.max(0, Math.min(1, volume)) * 100)}
mute={false}
repeat={false}
rate={1}
autoplay={!paused}
// Restore approximate time after remount
time={Math.max(0, Math.floor(currentTime * 1000))}
onFirstPlay={(info: any) => {
try {
const lenSec = (info?.length ?? 0) / 1000;
const width = info?.width || 0;
const height = info?.height || 0;
onLoad({ duration: lenSec, naturalSize: width && height ? { width, height } : undefined });
} catch (e) {
logger.warn('[AndroidVideoPlayer][VLC] onFirstPlay parse error', e);
}
}}
onPositionChanged={(ev: any) => {
const pos = typeof ev?.position === 'number' ? ev.position : 0;
if (duration > 0) {
const current = pos * duration;
handleProgress({ currentTime: current, playableDuration: current });
}
}}
onPlaying={() => setPaused(false)}
onPaused={() => setPaused(true)}
onEndReached={onEnd}
onEncounteredError={(e: any) => handleError(e)}
/>
) : (
<Video
ref={videoRef}
style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]}
source={{
@ -2721,6 +2816,7 @@ const AndroidVideoPlayer: React.FC = () => {
// Use textureView on Android: allows 3D mapping but DRM not supported
viewType={Platform.OS === 'android' ? ViewType.TEXTURE : undefined}
/>
)}
</TouchableOpacity>
</View>
</PinchGestureHandler>

View file

@ -874,9 +874,26 @@ export const StreamsScreen = () => {
const streamName = stream.name || stream.title || 'Unnamed Stream';
const streamProvider = stream.addonId || stream.addonName || stream.name;
// iOS now always uses KSPlayer, no need for player selection logic
// Keep forceVlc for backward compatibility but it's ignored by player selection
const forceVlc = !!options?.forceVlc;
// Decide if we should force VLC on Android based on scraper format or URL/content-type
let forceVlc = !!options?.forceVlc;
try {
const providerId = stream.addonId || (stream as any).addon || '';
// If provider declares MKV support in manifest, prefer VLC
if (Platform.OS === 'android' && providerId) {
const supportsMkv = await localScraperService.supportsFormat(providerId, 'mkv');
if (supportsMkv) forceVlc = true;
}
// URL/content-type heuristic
if (!forceVlc) {
const lowerUrl = (stream.url || '').toLowerCase();
const isMkvByPath = lowerUrl.includes('.mkv') || /[?&]ext=mkv\b/i.test(lowerUrl) || /format=mkv\b/i.test(lowerUrl) || /container=mkv\b/i.test(lowerUrl);
const contentType = (stream.headers && ((stream.headers as any)['Content-Type'] || (stream.headers as any)['content-type'])) || '';
const isMkvByHeader = typeof contentType === 'string' && /matroska|x-matroska/i.test(contentType);
if (Platform.OS === 'android' && (isMkvByPath || isMkvByHeader)) {
forceVlc = true;
}
}
} catch {}
// Show a quick full-screen black overlay to mask rotation flicker
@ -915,7 +932,7 @@ export const StreamsScreen = () => {
streamName: streamName,
// Always prefer stream.headers; player will use these for requests
headers: options?.headers || stream.headers || undefined,
// iOS now always uses KSPlayer, forceVlc kept for backward compatibility
// Android will use this to choose VLC path; iOS ignores
forceVlc,
id,
type,