diff --git a/ALPHA_BUILD_2_ANNOUNCEMENT.md b/ALPHA_BUILD_2_ANNOUNCEMENT.md
new file mode 100644
index 00000000..6c9054bb
--- /dev/null
+++ b/ALPHA_BUILD_2_ANNOUNCEMENT.md
@@ -0,0 +1,10 @@
+# Nuvio Alpha Build 2
+
+This is the second alpha release of Nuvio!
+
+## What's New
+- **Intro Submission:** You can now submit intro timestamps directly to IntroDB!
+- **Bug Fixes:** Various improvements and stability fixes.
+
+## Installation
+Download the attached APK and install it on your Android device.
diff --git a/App.tsx b/App.tsx
index ffe6425f..5bd5ff19 100644
--- a/App.tsx
+++ b/App.tsx
@@ -47,19 +47,45 @@ import { AccountProvider, useAccount } from './src/contexts/AccountContext';
import { ToastProvider } from './src/contexts/ToastContext';
import { mmkvStorage } from './src/services/mmkvStorage';
import { CampaignManager } from './src/components/promotions/CampaignManager';
+import { isErrorReportingEnabledSync } from './src/services/telemetryService';
+// Initialize Sentry with privacy-first defaults
+// Settings are loaded from telemetryService and can be controlled by user
+// Note: Full dynamic control requires app restart as Sentry initializes at startup
Sentry.init({
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
- // Adds more context data to events (IP address, cookies, user, etc.)
- // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
- sendDefaultPii: true,
+ // Privacy-first: Disable PII by default (IP address, cookies, user data)
+ // Users can opt-in via Privacy Settings if they choose
+ sendDefaultPii: false,
- // Configure Session Replay conservatively to avoid startup overhead in production
- replaysSessionSampleRate: __DEV__ ? 0.1 : 0,
- replaysOnErrorSampleRate: __DEV__ ? 1 : 0,
+ // Session Replay completely disabled by default for privacy
+ // This prevents screen recording without explicit user consent
+ replaysSessionSampleRate: 0,
+ replaysOnErrorSampleRate: 0,
+
+ // Only include feedback integration (user-initiated, not automatic)
integrations: [Sentry.feedbackIntegration()],
+ // beforeSend hook to respect user's telemetry preferences
+ // Uses synchronous MMKV read to check preference immediately
+ beforeSend: (event) => {
+ // Check if error reporting is disabled (synchronous check)
+ if (!isErrorReportingEnabledSync()) {
+ // Drop the event - user has opted out
+ return null;
+ }
+ return event;
+ },
+
+ // beforeSendTransaction hook for performance monitoring
+ beforeSendTransaction: (event) => {
+ if (!isErrorReportingEnabledSync()) {
+ return null;
+ }
+ return event;
+ },
+
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: __DEV__,
});
diff --git a/README.md b/README.md
index cd7f51b7..671cef36 100644
--- a/README.md
+++ b/README.md
@@ -11,16 +11,16 @@
[![License][license-shield]][license-url]
- A modern media hub built with React Native and Expo.
+ A modern media hub for Android and iOS built with React Native and Expo.
- Stremio Addon ecosystem • Cross-platform • Offline metadata & sync
+ Stremio Addon ecosystem • Cross-platform
## About
-Nuvio Media Hub is a cross-platform app for managing, discovering, and streaming your media via a flexible addon ecosystem. Built with React Native and Expo.
+Nuvio Media Hub is a cross-platform app for managing and discovering media, with a playback-focused interface that can integrate with the Stremio addon ecosystem through user-installed extensions.
## Installation
@@ -30,9 +30,9 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
### iOS
-* [TestFlight](https://testflight.apple.com/join/QkKMGRqp)
-* [AltStore](https://tinyurl.com/NuvioAltstore)
-* [SideStore](https://tinyurl.com/NuvioSidestore)
+- [TestFlight](https://testflight.apple.com/join/QkKMGRqp)
+- [AltStore](https://tinyurl.com/NuvioAltstore)
+- [SideStore](https://tinyurl.com/NuvioSidestore)
**Manual source:** `https://raw.githubusercontent.com/tapframe/NuvioStreaming/main/nuvio-source.json`
@@ -41,7 +41,8 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
```bash
git clone https://github.com/tapframe/NuvioStreaming.git
cd NuvioStreaming
-npm install
+npm install --legacy-peer-deps
+npx expo prebuild
npx expo run:android
# or
npx expo run:ios
@@ -49,15 +50,17 @@ npx expo run:ios
## Legal & DMCA
-Nuvio functions solely as a client-side interface for browsing metadata and playing media files provided by user-installed extensions. It does not host, store, or distribute any media content.
+Nuvio functions solely as a client-side interface for browsing metadata and playing media provided by user-installed extensions and/or user-provided sources. It is intended for content the user owns or is otherwise authorized to access.
+
+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)**.
## Built With
-* React Native
-* Expo
-* TypeScript
+- React Native
+- Expo
+- TypeScript
## Star History
diff --git a/index.html b/index.html
index 0aa06117..749e2f0a 100644
--- a/index.html
+++ b/index.html
@@ -876,7 +876,7 @@
Back
Privacy
- Last updated: January 2025
+ Last updated: February 5, 2026
No Account Sync
@@ -901,10 +901,23 @@
- TMDB for metadata like posters and cast info
- Trakt.tv (optional) for watch history sync
- - Sentry for anonymous crash reporting
+ - Sentry for crash reporting (privacy-first defaults, no PII by default)
+ - PostHog for optional analytics (disabled by default)
+
+
Telemetry & Analytics
+
Nuvio includes optional telemetry to improve stability and user experience:
+
+ - Sentry captures crash reports and errors. Personal data (IP, device identifiers)
+ is disabled by default and can be enabled only if you opt in.
+ - PostHog analytics are disabled by default and require explicit opt-in.
+ - Session Replay is disabled by default and can be enabled only if you opt in.
+
+
You can disable all telemetry at any time in the app under Settings → Privacy & Data.
+
+
Content Disclaimer
Nuvio is a media player and aggregator. We do not host any content. All video content is
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
index 54221ef1..72007063 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
@@ -442,7 +442,7 @@ public class ReactExoplayerView extends FrameLayout implements
private void initializePlayerControl() {
exoPlayerView.setPlayer(player);
-
+
exoPlayerView.setControllerVisibilityListener(visibility -> {
boolean isVisible = visibility == View.VISIBLE;
eventEmitter.onControlsVisibilityChange.invoke(isVisible);
@@ -458,19 +458,19 @@ public class ReactExoplayerView extends FrameLayout implements
private void updateControllerConfig() {
if (exoPlayerView == null)
return;
-
+
exoPlayerView.setControllerShowTimeoutMs(5000);
-
+
exoPlayerView.setControllerAutoShow(true);
exoPlayerView.setControllerHideOnTouch(true);
-
+
updateControllerVisibility();
}
private void updateControllerVisibility() {
if (exoPlayerView == null)
return;
-
+
exoPlayerView.setUseController(controls && !controlsConfig.getHideFullscreen());
}
@@ -520,7 +520,7 @@ public class ReactExoplayerView extends FrameLayout implements
/**
* Update the layout
- *
+ *
* @param view view needs to update layout
*
* This is a workaround for the open bug in react-native: subtitleConfigurations = buildSubtitleConfigurations();
if (subtitleConfigurations != null) {
mediaItemBuilder.setSubtitleConfigurations(subtitleConfigurations);
}
-
+
if (source.getAdsProps() != null) {
Uri adTagUrl = source.getAdsProps().getAdTagUrl();
if (adTagUrl != null) {
@@ -1205,19 +1205,19 @@ public class ReactExoplayerView extends FrameLayout implements
label += " (" + track.getLanguage() + ")";
}
}
-
+
MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder(
track.getUri())
.setId(trackId)
.setMimeType(track.getType())
.setLabel(label)
.setRoleFlags(C.ROLE_FLAG_SUBTITLE);
-
+
// Set language if available
if (track.getLanguage() != null && !track.getLanguage().isEmpty()) {
configBuilder.setLanguage(track.getLanguage());
}
-
+
// Set selection flags - make first track default if no specific track is
// selected
if (trackIndex == 0 && (textTrackType == null || "disabled".equals(textTrackType))) {
@@ -1225,10 +1225,10 @@ public class ReactExoplayerView extends FrameLayout implements
} else {
configBuilder.setSelectionFlags(0);
}
-
+
MediaItem.SubtitleConfiguration subtitleConfiguration = configBuilder.build();
subtitleConfigurations.add(subtitleConfiguration);
-
+
DebugLog.d(TAG,
"Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")");
trackIndex++;
@@ -1549,7 +1549,7 @@ public class ReactExoplayerView extends FrameLayout implements
}
eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height,
audioTracks, textTracks, videoTracks, trackId);
-
+
updateSubtitleButtonVisibility();
});
return;
@@ -1589,19 +1589,19 @@ public class ReactExoplayerView extends FrameLayout implements
for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) {
TrackGroup group = groups.get(groupIndex);
Format format = group.getFormat(0);
-
+
// Check if this specific group is the currently selected one
boolean isSelected = false;
if (selection != null && selection.getTrackGroup() == group) {
isSelected = true;
}
-
+
Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group);
audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
audioTrack.setSelected(isSelected);
audioTracks.add(audioTrack);
}
-
+
return audioTracks;
}
@@ -1736,13 +1736,13 @@ public class ReactExoplayerView extends FrameLayout implements
if (trackSelector == null) {
return textTracks;
}
-
+
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
if (info == null || index == C.INDEX_UNSET) {
return textTracks;
}
-
+
TrackSelectionArray selectionArray = player.getCurrentTrackSelections();
TrackSelection selection = selectionArray.get(C.TRACK_TYPE_TEXT);
TrackGroupArray groups = info.getTrackGroups(index);
@@ -1752,12 +1752,12 @@ public class ReactExoplayerView extends FrameLayout implements
for (int trackIndex = 0; trackIndex < group.length; trackIndex++) {
Format format = group.getFormat(trackIndex);
Track textTrack = exoplayerTrackToGenericTrack(format, trackIndex, selection, group);
-
+
boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-");
boolean isSelected = isTrackSelected(selection, group, trackIndex);
-
+
textTrack.setIndex(textTracks.size());
-
+
if (textTrack.getTitle() == null || textTrack.getTitle().isEmpty()) {
if (isExternal) {
textTrack.setTitle("External " + (trackIndex + 1));
@@ -1765,7 +1765,7 @@ public class ReactExoplayerView extends FrameLayout implements
textTrack.setTitle("Track " + (textTracks.size() + 1));
}
}
-
+
textTracks.add(textTrack);
}
}
@@ -1785,11 +1785,11 @@ public class ReactExoplayerView extends FrameLayout implements
}
TrackGroupArray groups = info.getTrackGroups(index);
-
+
for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) {
TrackGroup group = groups.get(groupIndex);
Format format = group.getFormat(0);
-
+
// Create track without trying to determine selection status
Track track = new Track();
track.setIndex(groupIndex);
@@ -1799,10 +1799,10 @@ public class ReactExoplayerView extends FrameLayout implements
if (format.sampleMimeType != null)
track.setMimeType(format.sampleMimeType);
track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
-
+
tracks.add(track);
}
-
+
DebugLog.d(TAG, "getBasicAudioTrackInfo: returning " + tracks.size() + " audio tracks (no selection status)");
return tracks;
}
@@ -1812,29 +1812,29 @@ public class ReactExoplayerView extends FrameLayout implements
if (trackSelector == null) {
return textTracks;
}
-
+
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
if (info == null || index == C.INDEX_UNSET) {
return textTracks;
}
-
+
TrackGroupArray groups = info.getTrackGroups(index);
for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) {
TrackGroup group = groups.get(groupIndex);
for (int trackIndex = 0; trackIndex < group.length; trackIndex++) {
Format format = group.getFormat(trackIndex);
-
+
Track textTrack = new Track();
textTrack.setIndex(textTracks.size());
if (format.sampleMimeType != null)
textTrack.setMimeType(format.sampleMimeType);
if (format.language != null)
textTrack.setLanguage(format.language);
-
+
boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-");
-
+
if (format.label != null && !format.label.isEmpty()) {
textTrack.setTitle(format.label);
} else if (isExternal) {
@@ -1842,7 +1842,7 @@ public class ReactExoplayerView extends FrameLayout implements
} else {
textTrack.setTitle("Track " + (textTracks.size() + 1));
}
-
+
textTrack.setSelected(false); // Don't report selection status - let PlayerView handle it
textTracks.add(textTrack);
}
@@ -1912,12 +1912,12 @@ public class ReactExoplayerView extends FrameLayout implements
@Override
public void onTracksChanged(@NonNull Tracks tracks) {
DebugLog.d(TAG, "onTracksChanged called - updating track information, controls=" + controls);
-
+
if (controls) {
ArrayList