mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-15 15:36:01 +00:00
Merge branch 'tapframe:main' into Mal
This commit is contained in:
commit
28e62fa674
40 changed files with 2485 additions and 170 deletions
10
ALPHA_BUILD_2_ANNOUNCEMENT.md
Normal file
10
ALPHA_BUILD_2_ANNOUNCEMENT.md
Normal file
|
|
@ -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.
|
||||
38
App.tsx
38
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__,
|
||||
});
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -11,16 +11,16 @@
|
|||
[![License][license-shield]][license-url]
|
||||
|
||||
<p>
|
||||
A modern media hub built with React Native and Expo.
|
||||
A modern media hub for Android and iOS built with React Native and Expo.
|
||||
<br />
|
||||
Stremio Addon ecosystem • Cross-platform • Offline metadata & sync
|
||||
Stremio Addon ecosystem • Cross-platform
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
17
index.html
17
index.html
|
|
@ -876,7 +876,7 @@
|
|||
Back
|
||||
</button>
|
||||
<h1>Privacy</h1>
|
||||
<p class="last-updated">Last updated: January 2025</p>
|
||||
<p class="last-updated">Last updated: February 5, 2026</p>
|
||||
|
||||
<div class="privacy-section">
|
||||
<h2>No Account Sync</h2>
|
||||
|
|
@ -901,10 +901,23 @@
|
|||
<ul>
|
||||
<li>TMDB for metadata like posters and cast info</li>
|
||||
<li>Trakt.tv (optional) for watch history sync</li>
|
||||
<li>Sentry for anonymous crash reporting</li>
|
||||
<li>Sentry for crash reporting (privacy-first defaults, no PII by default)</li>
|
||||
<li>PostHog for optional analytics (disabled by default)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="privacy-section">
|
||||
<h2>Telemetry & Analytics</h2>
|
||||
<p>Nuvio includes optional telemetry to improve stability and user experience:</p>
|
||||
<ul>
|
||||
<li><strong>Sentry</strong> captures crash reports and errors. Personal data (IP, device identifiers)
|
||||
is disabled by default and can be enabled only if you opt in.</li>
|
||||
<li><strong>PostHog</strong> analytics are disabled by default and require explicit opt-in.</li>
|
||||
<li><strong>Session Replay</strong> is disabled by default and can be enabled only if you opt in.</li>
|
||||
</ul>
|
||||
<p>You can disable all telemetry at any time in the app under <strong>Settings → Privacy & Data</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="privacy-section">
|
||||
<h2>Content Disclaimer</h2>
|
||||
<p>Nuvio is a media player and aggregator. We do not host any content. All video content is
|
||||
|
|
|
|||
|
|
@ -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: <a href=
|
||||
|
|
@ -758,7 +758,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
.forceEnableMediaCodecAsynchronousQueueing();
|
||||
|
||||
DefaultMediaSourceFactory mediaSourceFactory;
|
||||
|
||||
|
||||
if (isDaiRequest(source)) {
|
||||
mediaSourceFactory = createDaiMediaSourceFactory();
|
||||
} else {
|
||||
|
|
@ -872,7 +872,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
initializeDaiSource(runningSource);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (runningSource.getUri() == null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1051,13 +1051,13 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (customMetadata != null) {
|
||||
mediaItemBuilder.setMediaMetadata(customMetadata);
|
||||
}
|
||||
|
||||
|
||||
// Add external subtitles to MediaItem
|
||||
List<MediaItem.SubtitleConfiguration> 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<Track> textTracks = getBasicTextTrackInfo();
|
||||
ArrayList<Track> audioTracks = getBasicAudioTrackInfo();
|
||||
ArrayList<Track> audioTracks = getBasicAudioTrackInfo();
|
||||
ArrayList<VideoTrack> videoTracks = getVideoTrackInfo();
|
||||
|
||||
|
||||
eventEmitter.onTextTracks.invoke(textTracks);
|
||||
eventEmitter.onAudioTracks.invoke(audioTracks);
|
||||
eventEmitter.onVideoTracks.invoke(videoTracks);
|
||||
|
|
@ -1925,7 +1925,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
ArrayList<Track> textTracks = getTextTrackInfo();
|
||||
ArrayList<Track> audioTracks = getAudioTrackInfo();
|
||||
ArrayList<VideoTrack> videoTracks = getVideoTrackInfo();
|
||||
|
||||
|
||||
eventEmitter.onTextTracks.invoke(textTracks);
|
||||
eventEmitter.onAudioTracks.invoke(audioTracks);
|
||||
eventEmitter.onVideoTracks.invoke(videoTracks);
|
||||
|
|
@ -1936,24 +1936,24 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateSubtitleButtonVisibility();
|
||||
}
|
||||
|
||||
|
||||
private boolean hasBuiltInTextTracks() {
|
||||
if (player == null || trackSelector == null)
|
||||
return false;
|
||||
|
||||
|
||||
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
|
||||
if (info == null)
|
||||
return false;
|
||||
|
||||
|
||||
int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
|
||||
if (textRendererIndex == C.INDEX_UNSET)
|
||||
return false;
|
||||
|
||||
|
||||
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
|
||||
|
||||
|
||||
// Check if any groups have tracks that are NOT external subtitles
|
||||
for (int i = 0; i < groups.length; i++) {
|
||||
TrackGroup group = groups.get(i);
|
||||
|
|
@ -1965,18 +1965,18 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void updateSubtitleButtonVisibility() {
|
||||
if (exoPlayerView == null)
|
||||
return;
|
||||
|
||||
boolean hasTextTracks = (source.getSideLoadedTextTracks() != null &&
|
||||
|
||||
boolean hasTextTracks = (source.getSideLoadedTextTracks() != null &&
|
||||
!source.getSideLoadedTextTracks().getTracks().isEmpty()) ||
|
||||
hasBuiltInTextTracks();
|
||||
|
||||
|
||||
exoPlayerView.setShowSubtitleButton(hasTextTracks);
|
||||
}
|
||||
|
||||
|
|
@ -2177,7 +2177,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
public void disableTrack(int rendererIndex) {
|
||||
if (trackSelector == null)
|
||||
return;
|
||||
|
||||
|
||||
DefaultTrackSelector.Parameters disableParameters = trackSelector.getParameters()
|
||||
.buildUpon()
|
||||
.setRendererDisabled(rendererIndex, true)
|
||||
|
|
@ -2188,25 +2188,25 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
private void selectTextTrackInternal(String type, String value) {
|
||||
if (player == null || trackSelector == null)
|
||||
return;
|
||||
|
||||
|
||||
DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value);
|
||||
|
||||
|
||||
DefaultTrackSelector.Parameters.Builder parametersBuilder = trackSelector.getParameters().buildUpon();
|
||||
|
||||
|
||||
if ("disabled".equals(type) || value == null) {
|
||||
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true);
|
||||
} else {
|
||||
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false);
|
||||
|
||||
|
||||
parametersBuilder.clearOverridesOfType(C.TRACK_TYPE_TEXT);
|
||||
|
||||
|
||||
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
|
||||
if (info != null) {
|
||||
int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
|
||||
if (textRendererIndex != C.INDEX_UNSET) {
|
||||
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
|
||||
boolean trackFound = false;
|
||||
|
||||
|
||||
// react-native-video uses a flattened `textTracks` list on the JS side.
|
||||
// For HLS/DASH, each TrackGroup often contains a single track at index 0,
|
||||
// so comparing against `trackIndex` alone makes only the first subtitle selectable.
|
||||
|
|
@ -2216,7 +2216,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
TrackGroup group = groups.get(groupIndex);
|
||||
for (int trackIndex = 0; trackIndex < group.length; trackIndex++) {
|
||||
Format format = group.getFormat(trackIndex);
|
||||
|
||||
|
||||
boolean isMatch = false;
|
||||
if ("language".equals(type) && format.language != null && format.language.equals(value)) {
|
||||
isMatch = true;
|
||||
|
|
@ -2228,11 +2228,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
isMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
flattenedIndex++;
|
||||
|
||||
if (isMatch) {
|
||||
TrackSelectionOverride override = new TrackSelectionOverride(group,
|
||||
TrackSelectionOverride override = new TrackSelectionOverride(group,
|
||||
java.util.Arrays.asList(trackIndex));
|
||||
parametersBuilder.addOverride(override);
|
||||
trackFound = true;
|
||||
|
|
@ -2242,18 +2242,18 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (trackFound)
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (!trackFound) {
|
||||
DebugLog.w(TAG, "Text track not found for type=" + type + ", value=" + value +
|
||||
DebugLog.w(TAG, "Text track not found for type=" + type + ", value=" + value +
|
||||
". Keeping current selection.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
trackSelector.setParameters(parametersBuilder.build());
|
||||
|
||||
|
||||
// Give PlayerView time to update its controls
|
||||
mainHandler.postDelayed(() -> {
|
||||
if (exoPlayerView != null) {
|
||||
|
|
@ -2268,16 +2268,16 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
public void setSelectedTrack(int trackType, String type, String value) {
|
||||
if (player == null || trackSelector == null)
|
||||
return;
|
||||
|
||||
|
||||
if (controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
int rendererIndex = getTrackRendererIndex(trackType);
|
||||
if (rendererIndex == C.INDEX_UNSET) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
|
||||
if (info == null) {
|
||||
return;
|
||||
|
|
@ -2435,7 +2435,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
.setExceedVideoConstraintsIfNecessary(true)
|
||||
.setRendererDisabled(rendererIndex, false);
|
||||
|
||||
// Clear existing overrides for this track type to avoid conflicts
|
||||
// Clear existing overrides for this track type to avoid conflicts
|
||||
// But be careful with audio tracks - don't clear unless explicitly selecting a
|
||||
// different track
|
||||
if (trackType != C.TRACK_TYPE_AUDIO || !type.equals("default")) {
|
||||
|
|
@ -2452,7 +2452,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (trackType == C.TRACK_TYPE_AUDIO) {
|
||||
selectionParameters.setForceHighestSupportedBitrate(false);
|
||||
selectionParameters.setForceLowestBitrate(false);
|
||||
DebugLog.d(TAG, "Audio track selection: group=" + groupIndex + ", tracks=" + tracks +
|
||||
DebugLog.d(TAG, "Audio track selection: group=" + groupIndex + ", tracks=" + tracks +
|
||||
", override=" + selectionOverride);
|
||||
}
|
||||
|
||||
|
|
@ -2512,7 +2512,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
public void setSelectedAudioTrack(String type, String value) {
|
||||
audioTrackType = type;
|
||||
audioTrackValue = value;
|
||||
|
||||
|
||||
if (!controls && player != null && trackSelector != null) {
|
||||
setSelectedTrack(C.TRACK_TYPE_AUDIO, audioTrackType, audioTrackValue);
|
||||
}
|
||||
|
|
@ -2521,7 +2521,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
public void setSelectedTextTrack(String type, String value) {
|
||||
textTrackType = type;
|
||||
textTrackValue = value;
|
||||
|
||||
|
||||
selectTextTrackInternal(type, value);
|
||||
}
|
||||
|
||||
|
|
@ -2847,7 +2847,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
"code", String.valueOf(error.getErrorCode()),
|
||||
"type", String.valueOf(error.getErrorType()));
|
||||
eventEmitter.onReceiveAdEvent.invoke("ERROR", errMap);
|
||||
|
||||
|
||||
handleDaiBackupStream();
|
||||
}
|
||||
|
||||
|
|
@ -2855,7 +2855,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
controlsConfig = controlsStyles;
|
||||
refreshControlsStyles();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the source is a DAI (Dynamic Ad Insertion) request.
|
||||
*
|
||||
|
|
@ -2902,7 +2902,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
daiAdsLoader, mediaSourceFactory);
|
||||
|
||||
mediaSourceFactory.setServerSideAdInsertionMediaSourceFactory(adsMediaSourceFactory);
|
||||
|
||||
|
||||
return mediaSourceFactory;
|
||||
}
|
||||
|
||||
|
|
@ -2944,15 +2944,15 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
eventEmitter.onVideoError.invoke("DaiAdsLoader is null", null, "DAI_ADS_LOADER_NULL_ERROR");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
daiAdsLoader.setPlayer(player);
|
||||
|
||||
|
||||
AdsProps adsProps = runningSource.getAdsProps();
|
||||
int streamFormat = "dash".equalsIgnoreCase(adsProps.getFormat()) ? CONTENT_TYPE_DASH : CONTENT_TYPE_HLS;
|
||||
|
||||
|
||||
try {
|
||||
Uri.Builder uriBuilder;
|
||||
|
||||
|
||||
if (adsProps.isDAILive()) {
|
||||
uriBuilder = new ImaServerSideAdInsertionUriBuilder()
|
||||
.setAssetKey(adsProps.getAssetKey())
|
||||
|
|
@ -2970,17 +2970,17 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
throw new IllegalArgumentException(
|
||||
"Either assetKey (for live) or contentSourceId+videoId (for VOD) must be provided");
|
||||
}
|
||||
|
||||
|
||||
Map<String, String> adTagParameters = adsProps.getAdTagParameters();
|
||||
if (adTagParameters != null && !adTagParameters.isEmpty()) {
|
||||
for (Map.Entry<String, String> entry : adTagParameters.entrySet()) {
|
||||
uriBuilder.appendQueryParameter(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Uri ssaiUri = uriBuilder.build();
|
||||
MediaItem ssaiMediaItem = MediaItem.fromUri(ssaiUri);
|
||||
|
||||
|
||||
player.setMediaItem(ssaiMediaItem);
|
||||
} catch (Exception e) {
|
||||
eventEmitter.onVideoError.invoke("DAI stream request failed: " + e.getMessage(), e, "DAI_REQUEST_ERROR");
|
||||
|
|
@ -3001,29 +3001,29 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (source == null || source.getAdsProps() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
String fallbackStreamUri = source.getAdsProps().getFallbackUri();
|
||||
if (fallbackStreamUri == null || fallbackStreamUri.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
DebugLog.d(TAG, "DAI stream error occurred, falling back to backup stream URI: " + fallbackStreamUri);
|
||||
|
||||
|
||||
WritableMap backupSourceMap = Arguments.createMap();
|
||||
backupSourceMap.putString("uri", fallbackStreamUri);
|
||||
backupSourceMap.putBoolean("isNetwork", true);
|
||||
|
||||
|
||||
Source backupSource = Source.parse(backupSourceMap, themedReactContext);
|
||||
if (backupSource == null || backupSource.getUri() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (daiAdsLoader != null) {
|
||||
daiAdsLoader.setPlayer(null);
|
||||
}
|
||||
|
||||
|
||||
setSrc(backupSource);
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
"buildVersion": "34",
|
||||
"date": "2026-01-21",
|
||||
"localizedDescription": "# Nuvio Media Hub – v1.3.6 \n\n## Update Notes\n\n### Player & Playback\n- Updated **React Native Video** to the latest version \n- Fixed some **TXT-based streams** failing to play in ExoPlayer \n- Fixed **M3U8 streams without file extension** failing to play in ExoPlayer \n- Added more **aspect ratio options** to ExoPlayer for better viewing control \n\n### Improvements & Fixes\n- Updated several **dependencies** \n- Added an **in-built major app update downloader** (Android) \n- Various **internal fixes and stability improvements** \n\nThis update focuses on improving playback compatibility, update handling, and overall stability.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.6/Stable_1-3-6.ipa",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.6/Stable_1-3-6.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
"buildVersion": "33",
|
||||
"date": "2026-01-09",
|
||||
"localizedDescription": "## Update Notes\n\n### ExoPlayer Subtitle Fixes\nThis update mainly focuses on fixing multiple subtitle-related issues in ExoPlayer:\n- Fixed issue where **only the first subtitle index** was being rendered \n- Fixed subtitles **always showing background** due to Android native caption settings \n- Fixed subtitles being **rendered inside the player view** \n- Fixed **subtitle bottom offset** issues \n- Merged PR **#391** by **@saifshaikh1805** \n - Fixes issue **#301**\n\n### KSPlayer Improvements\n- Improved **subtitle behavior** in KSPlayer \n- Merged PR **#394** by **@AdityasahuX07**\n\n### Settings & Persistence\n- Implemented **save and load** functionality for **Discover settings**\n\nThis release improves subtitle rendering accuracy across players and adds better persistence for user preferences.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.5/Stable_1-3-5.ipa",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.5/Stable_1-3-5.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
"buildVersion": "32",
|
||||
"date": "2026-01-06",
|
||||
"localizedDescription": "## Update Notes\n\n### Player & Playback\n- Fixed **Android player crashes with large files** when using ExoPlayer \n - Merged PR **#361** by **@chrisk325**\n\n### Trakt Improvements\n- Improved **Trakt Continue Watching** section for better accuracy and reliability\n\n### Internationalization\n- Added **multi-language support** across the app using **i18n** \n - More languages will be added through **community contributions** \n - ⚠️ **Arabic UI does not use RTL yet**. RTL support will be added in a future update\n\n### Stability & Fixes\n- Crash optimizations and internal stability improvements\n\nThis update focuses on improving playback stability, Trakt experience, and expanding language support.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.4/Stable_1-3-4.ipa",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.4/Stable_1-3-4.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
|
|
|
|||
132
package-lock.json
generated
132
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "nuvio",
|
||||
"version": "0.6.0-beta.6",
|
||||
"version": "0.6.0-beta.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nuvio",
|
||||
"version": "0.6.0-beta.6",
|
||||
"version": "0.6.0-beta.16",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@adrianso/react-native-device-brightness": "^1.2.7",
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@kesha-antonov/react-native-background-downloader": "^4.4.5",
|
||||
"@legendapp/list": "^2.0.13",
|
||||
"@lottiefiles/dotlottie-react": "^0.17.7",
|
||||
"@lottiefiles/dotlottie-react": "^0.13.5",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-community/slider": "^5.1.1",
|
||||
|
|
@ -1540,6 +1540,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
|
|
@ -1798,6 +1799,15 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/cli/node_modules/undici": {
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/code-signing-certificates": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz",
|
||||
|
|
@ -2088,6 +2098,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz",
|
||||
"integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"anser": "^1.4.9",
|
||||
"pretty-format": "^29.7.0",
|
||||
|
|
@ -2576,6 +2587,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz",
|
||||
"integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/core": "^0.22.12"
|
||||
}
|
||||
|
|
@ -2757,21 +2769,22 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@lottiefiles/dotlottie-react": {
|
||||
"version": "0.17.10",
|
||||
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.17.10.tgz",
|
||||
"integrity": "sha512-ikrN05/q0/KjqIU+n48uNwmE7DeZIC9y3Nd19httcKqe273zoOeNYycEaQzLSdcpEGnWLmHaZpgtoo07aQZAXg==",
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz",
|
||||
"integrity": "sha512-4U5okwjRqDPkjB572hfZtLXJ/LGfCo6vDwUB2KIPEUoSgqbIlw+UrbnaqVp3GS+dRvhMD27F2JObpHpYRlpF0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lottiefiles/dotlottie-web": "0.58.1"
|
||||
"@lottiefiles/dotlottie-web": "0.44.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@lottiefiles/dotlottie-web": {
|
||||
"version": "0.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.58.1.tgz",
|
||||
"integrity": "sha512-YC4pmScrV0R3rd11gU5xHrjeNczlCic69zlnMH/buDIzYxIbpR88oPUhGtKgu5ln7EJchoLpeRJbA3uLCzSeTA==",
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.44.0.tgz",
|
||||
"integrity": "sha512-IUWKVciDJI/BMWDWnh7j0Ngd0N8q9ySRAwm84aDqIE07qpmdZ7x1rkIpBaU1yHSNqNYHeh1Rxsl+LC3CY4f0KA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
|
|
@ -3116,7 +3129,7 @@
|
|||
"version": "0.72.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz",
|
||||
"integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4",
|
||||
|
|
@ -3237,6 +3250,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.25.tgz",
|
||||
"integrity": "sha512-zQeWK9txDePWbYfqTs0C6jeRdJTm/7VhQtW/1IbJNDi9/rFIRzZule8bdQPAnf8QWUsNujRmi1J9OG/hhfbalg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-navigation/core": "^7.13.6",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
|
|
@ -3878,6 +3892,7 @@
|
|||
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@svgr/babel-preset": "8.1.0",
|
||||
|
|
@ -4098,6 +4113,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -4107,8 +4123,9 @@
|
|||
"version": "0.72.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz",
|
||||
"integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-native/virtualized-lists": "^0.72.4",
|
||||
"@types/react": "*"
|
||||
|
|
@ -4644,6 +4661,7 @@
|
|||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
|
|
@ -5047,6 +5065,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -6276,6 +6295,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.29.tgz",
|
||||
"integrity": "sha512-9C90gyOzV83y2S3XzCbRDCuKYNaiyCzuP9ketv46acHCEZn+QTamPK/DobdghoSiofCmlfoaiD6/SzfxDiHMnw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "54.0.19",
|
||||
|
|
@ -6479,6 +6499,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz",
|
||||
"integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ua-parser-js": "^0.7.33"
|
||||
},
|
||||
|
|
@ -6506,6 +6527,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
"integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
|
|
@ -6516,6 +6538,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz",
|
||||
"integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fontfaceobserver": "^2.1.0"
|
||||
},
|
||||
|
|
@ -6611,6 +6634,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
|
||||
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"rtl-detect": "^1.0.2"
|
||||
},
|
||||
|
|
@ -7670,6 +7694,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
|
|
@ -8286,6 +8311,20 @@
|
|||
"xml-name-validator": ">= 2.0.1 < 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
|
|
@ -10562,6 +10601,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -10602,6 +10642,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -10659,6 +10700,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz",
|
||||
"integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/create-cache-key-function": "^29.7.0",
|
||||
"@react-native/assets-registry": "0.81.4",
|
||||
|
|
@ -10747,6 +10789,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-bottom-tabs/-/react-native-bottom-tabs-1.1.0.tgz",
|
||||
"integrity": "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-freeze": "^1.0.0",
|
||||
"sf-symbols-typescript": "^2.0.0",
|
||||
|
|
@ -10777,6 +10820,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.29.1.tgz",
|
||||
"integrity": "sha512-du3qmv0e3Sm7qsd9SfmHps+AggLiylcBBQ8ztz7WUtd8ZjKs5V3kekAbi9R2W9bRLSg47Ntp4GGMYZOhikQdZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
|
|
@ -10875,6 +10919,7 @@
|
|||
"integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
|
|
@ -10940,6 +10985,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.0.tgz",
|
||||
"integrity": "sha512-frhu5b8/m/VvaMWz48V8RxcsXnE3hrlErQ5chr21MzAeDCpY4X14sQjvm+jvu3aOI+7Cz2atdRpyhhIuqxVaXg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "1.2.1",
|
||||
"semver": "7.7.3"
|
||||
|
|
@ -10979,6 +11025,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
||||
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
|
|
@ -10989,6 +11036,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.18.0.tgz",
|
||||
"integrity": "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-freeze": "^1.0.0",
|
||||
"warn-once": "^0.1.0"
|
||||
|
|
@ -11003,6 +11051,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.1.tgz",
|
||||
"integrity": "sha512-ZUD1xwc3Hwo4cOmOLumjJVoc7lEf9oQFlHnLmgccLC19fNm6LVEdtB+Cnip6gEi0PG3wfvVzskViEtrySQP8Fw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"css-tree": "^1.1.3",
|
||||
|
|
@ -11231,6 +11280,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.6",
|
||||
"@react-native/normalize-colors": "^0.74.1",
|
||||
|
|
@ -11487,6 +11537,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -11690,6 +11741,20 @@
|
|||
"node": ">= 0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/request/node_modules/tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
|
@ -12939,6 +13004,24 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.19",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",
|
||||
"integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.19"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.0.19",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz",
|
||||
"integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
|
|
@ -12994,17 +13077,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
||||
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
|
|
@ -13081,8 +13163,9 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -13123,15 +13206,6 @@
|
|||
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
|
||||
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"name": "nuvio",
|
||||
"version": "0.6.0-beta.6",
|
||||
"version": "0.6.0-beta.16",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"build": "export NODE_ENV=production && cd android && ./gradlew assembleRelease",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -19,7 +20,7 @@
|
|||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@kesha-antonov/react-native-background-downloader": "^4.4.5",
|
||||
"@legendapp/list": "^2.0.13",
|
||||
"@lottiefiles/dotlottie-react": "^0.17.7",
|
||||
"@lottiefiles/dotlottie-react": "^0.13.5",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-community/slider": "^5.1.1",
|
||||
|
|
@ -110,5 +111,8 @@
|
|||
"typescript": "^5.9.3",
|
||||
"xcode": "^3.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "~18.3.12"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
|||
38
patches/react-native-bottom-tabs+1.1.0.patch
Normal file
38
patches/react-native-bottom-tabs+1.1.0.patch
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
diff --git a/node_modules/react-native-bottom-tabs/app.plugin.js b/node_modules/react-native-bottom-tabs/app.plugin.js
|
||||
index 87edf84..711d0c0 100644
|
||||
--- a/node_modules/react-native-bottom-tabs/app.plugin.js
|
||||
+++ b/node_modules/react-native-bottom-tabs/app.plugin.js
|
||||
@@ -1 +1,31 @@
|
||||
-module.exports = require('./lib/module/expo');
|
||||
+"use strict";
|
||||
+
|
||||
+const { createRunOncePlugin, withAndroidStyles } = require("@expo/config-plugins");
|
||||
+
|
||||
+const MATERIAL3_THEME_DYANMIC = "Theme.Material3.DynamicColors.DayNight.NoActionBar";
|
||||
+const MATERIAL3_THEME = "Theme.Material3.DayNight.NoActionBar";
|
||||
+const MATERIAL2_THEME = "Theme.MaterialComponents.DayNight.NoActionBar";
|
||||
+const MATERIAL3_EXPRESSIVE_THEME = "Theme.Material3Expressive.DayNight.NoActionBar";
|
||||
+
|
||||
+const withMaterial3Theme = (config, options) => {
|
||||
+ const theme = options?.theme;
|
||||
+ return withAndroidStyles(config, stylesConfig => {
|
||||
+ stylesConfig.modResults.resources.style = stylesConfig.modResults.resources.style?.map(style => {
|
||||
+ if (style.$.name === "AppTheme") {
|
||||
+ if (theme === "material3-dynamic") {
|
||||
+ style.$.parent = MATERIAL3_THEME_DYANMIC;
|
||||
+ } else if (theme === "material2") {
|
||||
+ style.$.parent = MATERIAL2_THEME;
|
||||
+ } else if (theme === "material3-expressive") {
|
||||
+ style.$.parent = MATERIAL3_EXPRESSIVE_THEME;
|
||||
+ } else {
|
||||
+ style.$.parent = MATERIAL3_THEME;
|
||||
+ }
|
||||
+ }
|
||||
+ return style;
|
||||
+ });
|
||||
+ return stylesConfig;
|
||||
+ });
|
||||
+};
|
||||
+
|
||||
+module.exports = createRunOncePlugin(withMaterial3Theme, "react-native-bottom-tabs");
|
||||
\ No newline at end of file
|
||||
|
|
@ -68,8 +68,6 @@ export const CustomAlert = ({
|
|||
const handleActionPress = useCallback((action: { label: string; onPress: () => void; style?: object }) => {
|
||||
try {
|
||||
action.onPress();
|
||||
// Don't auto-close here if the action handles it, or check if we should
|
||||
// Standard behavior is to close
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.warn('[CustomAlert] Error in action handler:', error);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const BREAKPOINTS = {
|
|||
tv: 1440,
|
||||
};
|
||||
|
||||
const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/6/69/IMDB_Logo_2016.svg';
|
||||
const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png';
|
||||
|
||||
export const RATING_PROVIDERS = {
|
||||
imdb: {
|
||||
|
|
@ -95,21 +95,41 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
}, [deviceType]);
|
||||
|
||||
const iconSize = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
return 20;
|
||||
case 'largeTablet':
|
||||
return 18;
|
||||
case 'tablet':
|
||||
return 16;
|
||||
default:
|
||||
return 16;
|
||||
const baseSize = deviceType === 'tv' ? 20 : deviceType === 'largeTablet' ? 18 : deviceType === 'tablet' ? 16 : 16;
|
||||
const numRatings = ratings ? Object.keys(ratings).length : 0;
|
||||
// Reduce size if many ratings to fit in one line
|
||||
if (numRatings > 4) {
|
||||
return Math.max(baseSize - 2, 12);
|
||||
}
|
||||
}, [deviceType]);
|
||||
return baseSize;
|
||||
}, [deviceType, ratings]);
|
||||
|
||||
const textSize = useMemo(() => (isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14), [isTV, isLargeTablet, isTablet]);
|
||||
const itemSpacing = useMemo(() => (isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12), [isTV, isLargeTablet, isTablet]);
|
||||
const iconTextGap = useMemo(() => (isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4), [isTV, isLargeTablet, isTablet]);
|
||||
const textSize = useMemo(() => {
|
||||
const baseSize = isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14;
|
||||
const numRatings = ratings ? Object.keys(ratings).length : 0;
|
||||
if (numRatings > 4) {
|
||||
return Math.max(baseSize - 1, 12);
|
||||
}
|
||||
return baseSize;
|
||||
}, [isTV, isLargeTablet, isTablet, ratings]);
|
||||
|
||||
const itemSpacing = useMemo(() => {
|
||||
const baseSpacing = isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12;
|
||||
const numRatings = ratings ? Object.keys(ratings).length : 0;
|
||||
if (numRatings > 4) {
|
||||
return Math.max(baseSpacing - 2, 8);
|
||||
}
|
||||
return baseSpacing;
|
||||
}, [isTV, isLargeTablet, isTablet, ratings]);
|
||||
|
||||
const iconTextGap = useMemo(() => {
|
||||
const baseGap = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4;
|
||||
const numRatings = ratings ? Object.keys(ratings).length : 0;
|
||||
if (numRatings > 4) {
|
||||
return Math.max(baseGap - 1, 2);
|
||||
}
|
||||
return baseGap;
|
||||
}, [isTV, isLargeTablet, isTablet, ratings]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProviderSettings();
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { AudioTrackModal } from './modals/AudioTrackModal';
|
|||
import { SubtitleModals } from './modals/SubtitleModals';
|
||||
import { SubtitleSyncModal } from './modals/SubtitleSyncModal';
|
||||
import SpeedModal from './modals/SpeedModal';
|
||||
import { SubmitIntroModal } from './modals/SubmitIntroModal';
|
||||
import { SourcesModal } from './modals/SourcesModal';
|
||||
import { EpisodesModal } from './modals/EpisodesModal';
|
||||
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||
|
|
@ -967,6 +968,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
setShowAudioModal={modals.setShowAudioModal}
|
||||
setShowSubtitleModal={modals.setShowSubtitleModal}
|
||||
setShowSpeedModal={modals.setShowSpeedModal}
|
||||
setShowSubmitIntroModal={modals.setShowSubmitIntroModal}
|
||||
isSubtitleModalOpen={modals.showSubtitleModal}
|
||||
setShowSourcesModal={modals.setShowSourcesModal}
|
||||
setShowEpisodesModal={type === 'series' ? modals.setShowEpisodesModal : undefined}
|
||||
|
|
@ -982,6 +984,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
onSwitchToMPV={handleManualSwitchToMPV}
|
||||
useExoPlayer={useExoPlayer}
|
||||
isBuffering={playerState.isBuffering}
|
||||
imdbId={imdbId}
|
||||
/>
|
||||
|
||||
<SpeedActivatedOverlay
|
||||
|
|
@ -1157,6 +1160,15 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
setHoldToSpeedValue={speedControl.setHoldToSpeedValue}
|
||||
/>
|
||||
|
||||
<SubmitIntroModal
|
||||
visible={modals.showSubmitIntroModal}
|
||||
onClose={() => modals.setShowSubmitIntroModal(false)}
|
||||
currentTime={playerState.currentTime}
|
||||
imdbId={imdbId}
|
||||
season={season}
|
||||
episode={episode}
|
||||
/>
|
||||
|
||||
<EpisodesModal
|
||||
showEpisodesModal={modals.showEpisodesModal}
|
||||
setShowEpisodesModal={modals.setShowEpisodesModal}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import UpNextButton from './common/UpNextButton';
|
|||
import { PlayerControls } from './controls/PlayerControls';
|
||||
import AudioTrackModal from './modals/AudioTrackModal';
|
||||
import SpeedModal from './modals/SpeedModal';
|
||||
import { SubmitIntroModal } from './modals/SubmitIntroModal';
|
||||
import SubtitleModals from './modals/SubtitleModals';
|
||||
import { SubtitleSyncModal } from './modals/SubtitleSyncModal';
|
||||
import SourcesModal from './modals/SourcesModal';
|
||||
|
|
@ -924,6 +925,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
setShowAudioModal={modals.setShowAudioModal}
|
||||
setShowSubtitleModal={modals.setShowSubtitleModal}
|
||||
setShowSpeedModal={modals.setShowSpeedModal}
|
||||
setShowSubmitIntroModal={modals.setShowSubmitIntroModal}
|
||||
isSubtitleModalOpen={modals.showSubtitleModal}
|
||||
setShowSourcesModal={modals.setShowSourcesModal}
|
||||
setShowEpisodesModal={type === 'series' ? modals.setShowEpisodesModal : undefined}
|
||||
|
|
@ -937,6 +939,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
allowsAirPlay={allowsAirPlay}
|
||||
onAirPlayPress={() => ksPlayerRef.current?.showAirPlayPicker()}
|
||||
isBuffering={isBuffering}
|
||||
imdbId={imdbId}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -1055,6 +1058,15 @@ const KSPlayerCore: React.FC = () => {
|
|||
setHoldToSpeedValue={speedControl.setHoldToSpeedValue}
|
||||
/>
|
||||
|
||||
<SubmitIntroModal
|
||||
visible={modals.showSubmitIntroModal}
|
||||
onClose={() => modals.setShowSubmitIntroModal(false)}
|
||||
currentTime={currentTime}
|
||||
imdbId={imdbId}
|
||||
season={season}
|
||||
episode={episode}
|
||||
/>
|
||||
|
||||
<SubtitleModals
|
||||
showSubtitleModal={modals.showSubtitleModal}
|
||||
setShowSubtitleModal={modals.setShowSubtitleModal}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import { useTranslation } from 'react-i18next';
|
|||
import { styles } from '../utils/playerStyles'; // Updated styles
|
||||
import { getTrackDisplayName } from '../utils/playerUtils';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useSettings } from '../../../hooks/useSettings';
|
||||
|
||||
import { introService } from '../../../services/introService';
|
||||
import { toastService } from '../../../services/toastService';
|
||||
|
||||
interface PlayerControlsProps {
|
||||
showControls: boolean;
|
||||
|
|
@ -37,6 +41,7 @@ interface PlayerControlsProps {
|
|||
setShowAudioModal: (show: boolean) => void;
|
||||
setShowSubtitleModal: (show: boolean) => void;
|
||||
setShowSpeedModal: (show: boolean) => void;
|
||||
setShowSubmitIntroModal: (show: boolean) => void;
|
||||
isSubtitleModalOpen?: boolean;
|
||||
setShowSourcesModal?: (show: boolean) => void;
|
||||
setShowEpisodesModal?: (show: boolean) => void;
|
||||
|
|
@ -55,6 +60,7 @@ interface PlayerControlsProps {
|
|||
onSwitchToMPV?: () => void;
|
||||
useExoPlayer?: boolean;
|
||||
isBuffering?: boolean;
|
||||
imdbId?: string;
|
||||
}
|
||||
|
||||
export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||
|
|
@ -85,6 +91,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
setShowAudioModal,
|
||||
setShowSubtitleModal,
|
||||
setShowSpeedModal,
|
||||
setShowSubmitIntroModal,
|
||||
isSubtitleModalOpen,
|
||||
setShowSourcesModal,
|
||||
setShowEpisodesModal,
|
||||
|
|
@ -100,10 +107,17 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
onSwitchToMPV,
|
||||
useExoPlayer,
|
||||
isBuffering = false,
|
||||
imdbId,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// --- Intro Submission Logic ---
|
||||
const handleIntroPress = () => {
|
||||
setShowSubmitIntroModal(true);
|
||||
};
|
||||
|
||||
|
||||
/* Responsive Spacing */
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
|
|
@ -619,6 +633,20 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Submit Intro Button */}
|
||||
{season !== undefined && episode !== undefined && settings.introSubmitEnabled && settings.introDbApiKey && (
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={handleIntroPress}
|
||||
>
|
||||
<Ionicons
|
||||
name="flag-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Right Side: Episodes Button */}
|
||||
{setShowEpisodesModal && (
|
||||
<TouchableOpacity
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const usePlayerModals = () => {
|
|||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
|
||||
const [showCastDetails, setShowCastDetails] = useState(false);
|
||||
const [showSubmitIntroModal, setShowSubmitIntroModal] = useState(false);
|
||||
|
||||
// Some modals have associated data
|
||||
const [selectedEpisodeForStreams, setSelectedEpisodeForStreams] = useState<Episode | null>(null);
|
||||
|
|
@ -31,6 +32,7 @@ export const usePlayerModals = () => {
|
|||
showErrorModal, setShowErrorModal,
|
||||
showSubtitleLanguageModal, setShowSubtitleLanguageModal,
|
||||
showCastDetails, setShowCastDetails,
|
||||
showSubmitIntroModal, setShowSubmitIntroModal,
|
||||
selectedEpisodeForStreams, setSelectedEpisodeForStreams,
|
||||
errorDetails, setErrorDetails,
|
||||
selectedCastMember, setSelectedCastMember
|
||||
|
|
|
|||
347
src/components/player/modals/SubmitIntroModal.tsx
Normal file
347
src/components/player/modals/SubmitIntroModal.tsx
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, TextInput, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons, MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
SlideInDown,
|
||||
SlideOutDown,
|
||||
} from 'react-native-reanimated';
|
||||
import { useSettings } from '../../../hooks/useSettings';
|
||||
import { introService } from '../../../services/introService';
|
||||
import { toastService } from '../../../services/toastService';
|
||||
|
||||
interface SubmitIntroModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentTime: number;
|
||||
imdbId?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses time string (MM:SS or SS) to seconds
|
||||
*/
|
||||
const parseTimeToSeconds = (input: string): number | null => {
|
||||
if (!input) return null;
|
||||
|
||||
// Format: MM:SS
|
||||
if (input.includes(':')) {
|
||||
const parts = input.split(':');
|
||||
if (parts.length !== 2) return null;
|
||||
const mins = parseInt(parts[0], 10);
|
||||
const secs = parseInt(parts[1], 10);
|
||||
if (isNaN(mins) || isParseSecs(secs)) return null;
|
||||
return mins * 60 + secs;
|
||||
}
|
||||
|
||||
// Format: Seconds only
|
||||
const secs = parseInt(input, 10);
|
||||
return isNaN(secs) ? null : secs;
|
||||
};
|
||||
|
||||
const isParseSecs = (secs: number) => isNaN(secs) || secs < 0 || secs >= 60;
|
||||
|
||||
/**
|
||||
* Formats seconds to MM:SS
|
||||
*/
|
||||
const formatSecondsToMMSS = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
currentTime,
|
||||
imdbId,
|
||||
season,
|
||||
episode,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const [startTimeStr, setStartTimeStr] = useState('00:00');
|
||||
const [endTimeStr, setEndTimeStr] = useState(formatSecondsToMMSS(currentTime));
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setEndTimeStr(formatSecondsToMMSS(currentTime));
|
||||
}
|
||||
}, [visible, currentTime]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const handleCaptureStart = () => setStartTimeStr(formatSecondsToMMSS(currentTime));
|
||||
const handleCaptureEnd = () => setEndTimeStr(formatSecondsToMMSS(currentTime));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const startSec = parseTimeToSeconds(startTimeStr);
|
||||
const endSec = parseTimeToSeconds(endTimeStr);
|
||||
|
||||
if (startSec === null || endSec === null) {
|
||||
toastService.error('Invalid format', 'Please use MM:SS format');
|
||||
return;
|
||||
}
|
||||
|
||||
if (endSec <= startSec) {
|
||||
toastService.warning('Invalid duration', 'End time must be after start time');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imdbId || season === undefined || episode === undefined) {
|
||||
toastService.error('Missing metadata', 'Could not identify this episode');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await introService.submitIntro(
|
||||
settings.introDbApiKey,
|
||||
imdbId,
|
||||
season,
|
||||
episode,
|
||||
startSec,
|
||||
endSec
|
||||
);
|
||||
|
||||
if (success) {
|
||||
toastService.success(t('player_ui.intro_submitted', { defaultValue: 'Intro submitted successfully' }));
|
||||
onClose();
|
||||
} else {
|
||||
toastService.error(t('player_ui.intro_submit_failed', { defaultValue: 'Failed to submit intro' }));
|
||||
}
|
||||
} catch (error) {
|
||||
toastService.error('Error', 'An unexpected error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startVal = parseTimeToSeconds(startTimeStr);
|
||||
const endVal = parseTimeToSeconds(endTimeStr);
|
||||
const durationSec = (startVal !== null && endVal !== null) ? endVal - startVal : 0;
|
||||
|
||||
return (
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
|
||||
<TouchableOpacity
|
||||
style={StyleSheet.absoluteFill}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.4)' }} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View pointerEvents="box-none" style={localStyles.centeredView}>
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(300)}
|
||||
exiting={SlideOutDown.duration(250)}
|
||||
style={[localStyles.modalContainer, { width: Math.min(width * 0.85, 380) }]}
|
||||
>
|
||||
<View style={localStyles.header}>
|
||||
<Text style={localStyles.title}>Submit Intro Timestamp</Text>
|
||||
<TouchableOpacity onPress={onClose} style={localStyles.closeButton}>
|
||||
<Ionicons name="close" size={24} color="rgba(255,255,255,0.5)" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={localStyles.content}>
|
||||
{/* Start Time Input */}
|
||||
<View style={localStyles.inputRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={localStyles.label}>Start Time (MM:SS)</Text>
|
||||
<TextInput
|
||||
style={localStyles.input}
|
||||
value={startTimeStr}
|
||||
onChangeText={setStartTimeStr}
|
||||
placeholder="00:00"
|
||||
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity onPress={handleCaptureStart} style={localStyles.captureBtn}>
|
||||
<MaterialIcons name="my-location" size={20} color="white" />
|
||||
<Text style={localStyles.captureText}>Capture</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* End Time Input */}
|
||||
<View style={localStyles.inputRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={localStyles.label}>End Time (MM:SS)</Text>
|
||||
<TextInput
|
||||
style={localStyles.input}
|
||||
value={endTimeStr}
|
||||
onChangeText={setEndTimeStr}
|
||||
placeholder="00:00"
|
||||
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity onPress={handleCaptureEnd} style={localStyles.captureBtn}>
|
||||
<MaterialIcons name="my-location" size={20} color="white" />
|
||||
<Text style={localStyles.captureText}>Capture</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={localStyles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
disabled={isSubmitting}
|
||||
style={[localStyles.cancelBtn, isSubmitting && { opacity: 0.5 }]}
|
||||
>
|
||||
<Text style={localStyles.cancelBtnText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
style={[localStyles.submitBtn, isSubmitting && { opacity: 0.7 }]}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color="black" />
|
||||
) : (
|
||||
<>
|
||||
<MaterialIcons name="send" size={18} color="black" />
|
||||
<Text style={localStyles.submitBtnText}>Submit</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const localStyles = StyleSheet.create({
|
||||
centeredView: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingBottom: 40,
|
||||
},
|
||||
modalContainer: {
|
||||
backgroundColor: 'rgba(20, 20, 20, 0.98)',
|
||||
borderRadius: 28,
|
||||
padding: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 15,
|
||||
elevation: 20,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
color: 'white',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
content: {
|
||||
gap: 20,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: 12,
|
||||
},
|
||||
label: {
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontSize: 12,
|
||||
marginBottom: 8,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
captureBtn: {
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
height: 48,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
captureText: {
|
||||
color: 'white',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
summaryBox: {
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginTop: 8,
|
||||
},
|
||||
summaryText: {
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
hintText: {
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
},
|
||||
cancelBtn: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
borderRadius: 16,
|
||||
height: 56,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cancelBtnText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
submitBtn: {
|
||||
flex: 2,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 16,
|
||||
height: 56,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
submitBtnText: {
|
||||
color: 'black',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
|
@ -9,5 +9,6 @@ export const LOCALES = [
|
|||
{ code: 'es', key: 'spanish' },
|
||||
{ code: 'hr', key: 'croatian' },
|
||||
{ code: 'zh-CN', key: 'chinese' },
|
||||
{ code: 'hi', key: 'hindi' }
|
||||
{ code: 'hi', key: 'hindi' },
|
||||
{ code: 'sr', key: 'serbian' }
|
||||
];
|
||||
|
|
@ -59,6 +59,8 @@ export interface AppSettings {
|
|||
// Playback behavior
|
||||
alwaysResume: boolean; // If true, resume automatically without prompt when progress < 85%
|
||||
skipIntroEnabled: boolean; // Enable/disable Skip Intro overlay (IntroDB)
|
||||
introSubmitEnabled: boolean; // Enable/disable Intro Submission
|
||||
introDbApiKey: string; // API Key for IntroDB submission
|
||||
// Downloads
|
||||
enableDownloads: boolean; // Show Downloads tab and enable saving streams
|
||||
// Theme settings
|
||||
|
|
@ -147,6 +149,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
// Playback behavior defaults
|
||||
alwaysResume: true,
|
||||
skipIntroEnabled: true,
|
||||
introSubmitEnabled: false,
|
||||
introDbApiKey: '',
|
||||
// Downloads
|
||||
enableDownloads: false,
|
||||
useExternalPlayerForDownloads: false,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "العودة",
|
||||
"settings": "إعدادات",
|
||||
"close": "إغلاق",
|
||||
"enable": "تفعيل",
|
||||
"disable": "تعطيل",
|
||||
"show_more": "عرض المزيد",
|
||||
"show_less": "عرض أقل",
|
||||
"load_more": "تحميل المزيد",
|
||||
|
|
@ -747,6 +749,50 @@
|
|||
"app_updates": "تحديثات التطبيق",
|
||||
"about_nuvio": "حول Nuvio"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "الخصوصية والبيانات",
|
||||
"settings_desc": "التحكم في جمع البيانات والقياس",
|
||||
"info_title": "خصوصيتك مهمة لنا",
|
||||
"info_description": "تحكم في البيانات التي يتم جمعها ومشاركتها. التحليلات معطلة بشكل افتراضي وتقارير الأعطال مجهولة الهوية بشكل افتراضي.",
|
||||
"analytics_enabled_title": "تفعيل التحليلات",
|
||||
"analytics_enabled_message": "سيتم جمع بيانات الاستخدام لمساعدة تحسين التطبيق. يمكنك تعطيل هذا في أي وقت.",
|
||||
"disable_error_reporting_title": "تعطيل تقارير الأخطاء؟",
|
||||
"disable_error_reporting_message": "تعطيل تقارير الأخطاء يعني أننا لن نتلقى إشعارات بالأعطال أو المشاكل التي تواجهها. قد يؤثر هذا على قدرتنا على إصلاح الأخطاء.",
|
||||
"enable_session_replay_title": "تفعيل إعادة تشغيل الجلسة؟",
|
||||
"enable_session_replay_message": "تسجل إعادة تشغيل الجلسة شاشتك عند حدوث أخطاء لمساعدتنا على فهم ما حدث. قد يلتقط محتوى مرئي على شاشتك.",
|
||||
"enable_pii_title": "تفعيل جمع معلومات التعريف الشخصية؟",
|
||||
"enable_pii_message": "يسمح هذا بجمع المعلومات التي تحدد الهوية الشخصية مثل عنوان IP وتفاصيل الجهاز. تساعد هذه البيانات في تشخيص المشاكل لكنها تزيد من تعريض الخصوصية.",
|
||||
"disable_all_title": "تعطيل جميع القياس؟",
|
||||
"disable_all_message": "سيؤدي هذا إلى تعطيل جميع التحليلات وتقارير الأخطاء وإعادة تشغيل الجلسة. لن نتلقى أي بيانات عن استخدام التطبيق أو الأعطال.",
|
||||
"disable_all_button": "تعطيل الكل",
|
||||
"all_disabled_title": "تم تعطيل جميع القياسات",
|
||||
"all_disabled_message": "تم تعطيل جميع جمع البيانات. ستسري التغييرات عند إعادة تشغيل التطبيق التالية.",
|
||||
"reset_title": "إعادة تعيين للافتراضيات الموصى بها",
|
||||
"reset_message": "تم إعادة تعيين إعدادات الخصوصية إلى الافتراضيات الموصى بها (تقارير الأخطاء مفعلة، التحليلات معطلة).",
|
||||
"section_analytics": "التحليلات",
|
||||
"analytics_title": "تحليلات الاستخدام",
|
||||
"analytics_description": "جمع أنماط الاستخدام والمشاهد المجهولة الهوية",
|
||||
"section_error_reporting": "تقارير الأخطاء",
|
||||
"error_reporting_title": "تقارير الأعطال",
|
||||
"error_reporting_description": "إرسال تقارير أعطال مجهولة الهوية لتحسين الاستقرار",
|
||||
"session_replay_title": "إعادة تشغيل الجلسة",
|
||||
"session_replay_description": "تسجيل الشاشة عند حدوث أخطاء",
|
||||
"pii_title": "تضمين معلومات الجهاز",
|
||||
"pii_description": "إرسال عنوان IP وتفاصيل الجهاز مع التقارير",
|
||||
"section_quick_actions": "الإجراءات السريعة",
|
||||
"disable_all": "تعطيل جميع القياس",
|
||||
"disable_all_desc": "إيقاف جميع جمع البيانات",
|
||||
"reset_recommended": "إعادة تعيين للافتراضيات الموصى بها",
|
||||
"reset_recommended_desc": "الافتراضيات الموجهة للخصوصية مع تقارير الأخطاء",
|
||||
"section_learn_more": "معرفة المزيد",
|
||||
"privacy_policy": "سياسة الخصوصية",
|
||||
"current_settings": "ملخص الإعدادات الحالية",
|
||||
"summary_analytics": "التحليلات",
|
||||
"summary_errors": "تقارير الأخطاء",
|
||||
"summary_replay": "إعادة تشغيل الجلسة",
|
||||
"summary_pii": "معلومات الجهاز",
|
||||
"restart_note_detailed": "* تسري التغييرات في التحليلات وتقارير الأخطاء فوراً. تتطلب إعدادات إعادة تشغيل الجلسة ومعلومات التعريف إعادة تشغيل التطبيق."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "مساعد الذكاء الاصطناعي",
|
||||
"info_title": "دردشة مدعومة بالذكاء الاصطناعي",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "Zurück",
|
||||
"settings": "Einstellungen",
|
||||
"close": "Schließen",
|
||||
"enable": "Aktivieren",
|
||||
"disable": "Deaktivieren",
|
||||
"show_more": "Mehr anzeigen",
|
||||
"show_less": "Weniger anzeigen",
|
||||
"load_more": "Mehr laden",
|
||||
|
|
@ -747,6 +749,50 @@
|
|||
"app_updates": "App-Updates",
|
||||
"about_nuvio": "Über Nuvio"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Datenschutz & Daten",
|
||||
"settings_desc": "Telemetrie- und Datenerfassung steuern",
|
||||
"info_title": "Ihr Datenschutz ist uns wichtig",
|
||||
"info_description": "Kontrollieren Sie, welche Daten erfasst und weitergegeben werden. Analysen sind standardmäßig deaktiviert und Absturzberichte sind standardmäßig anonym.",
|
||||
"analytics_enabled_title": "Analysen aktiviert",
|
||||
"analytics_enabled_message": "Nutzungsdaten werden erfasst, um die App zu verbessern. Sie können dies jederzeit deaktivieren.",
|
||||
"disable_error_reporting_title": "Fehlerberichte deaktivieren?",
|
||||
"disable_error_reporting_message": "Durch Deaktivieren der Fehlerberichte werden wir nicht über Abstürze oder Probleme benachrichtigt, die Sie erleben. Dies kann unsere Fähigkeit, Fehler zu beheben, beeinträchtigen.",
|
||||
"enable_session_replay_title": "Sitzungswiedergabe aktivieren?",
|
||||
"enable_session_replay_message": "Die Sitzungswiedergabe zeichnet Ihren Bildschirm bei Fehlern auf, um uns zu helfen zu verstehen, was passiert ist. Dies kann sichtbare Inhalte auf Ihrem Bildschirm erfassen.",
|
||||
"enable_pii_title": "Erfassung von persönlich identifizierbaren Informationen aktivieren?",
|
||||
"enable_pii_message": "Dies ermöglicht die Erfassung persönlich identifizierbarer Informationen wie IP-Adresse und Gerätedetails. Diese Daten helfen bei der Fehlerbehebung, erhöhen aber das Datenschutzrisiko.",
|
||||
"disable_all_title": "Gesamte Telemetrie deaktivieren?",
|
||||
"disable_all_message": "Dies deaktiviert alle Analysen, Fehlerberichte und Sitzungswiedergaben. Wir erhalten keine Daten über die App-Nutzung oder Abstürze.",
|
||||
"disable_all_button": "Alles deaktivieren",
|
||||
"all_disabled_title": "Alle Telemetrie deaktiviert",
|
||||
"all_disabled_message": "Die gesamte Datenerfassung wurde deaktiviert. Änderungen werden beim nächsten App-Start wirksam.",
|
||||
"reset_title": "Auf empfohlene Einstellungen zurücksetzen",
|
||||
"reset_message": "Datenschutzeinstellungen wurden auf empfohlene Standardwerte zurückgesetzt (Fehlerberichte aktiviert, Analysen deaktiviert).",
|
||||
"section_analytics": "ANALYSEN",
|
||||
"analytics_title": "Nutzungsanalysen",
|
||||
"analytics_description": "Anonyme Nutzungsmuster und Bildschirmaufrufe erfassen",
|
||||
"section_error_reporting": "FEHLERBERICHTE",
|
||||
"error_reporting_title": "Absturzberichte",
|
||||
"error_reporting_description": "Anonyme Absturzberichte zur Verbesserung der Stabilität senden",
|
||||
"session_replay_title": "Sitzungswiedergabe",
|
||||
"session_replay_description": "Bildschirm bei Fehlern aufzeichnen",
|
||||
"pii_title": "Geräteinformationen einschließen",
|
||||
"pii_description": "IP-Adresse und Gerätedetails mit Berichten senden",
|
||||
"section_quick_actions": "SCHNELLAKTIONEN",
|
||||
"disable_all": "Gesamte Telemetrie deaktivieren",
|
||||
"disable_all_desc": "Alle Datenerfassung ausschalten",
|
||||
"reset_recommended": "Auf empfohlene Einstellungen zurücksetzen",
|
||||
"reset_recommended_desc": "Datenschutzorientierte Standardeinstellungen mit Fehlerberichten",
|
||||
"section_learn_more": "MEHR ERFAHREN",
|
||||
"privacy_policy": "Datenschutzrichtlinie",
|
||||
"current_settings": "Zusammenfassung der aktuellen Einstellungen",
|
||||
"summary_analytics": "Analysen",
|
||||
"summary_errors": "Fehlerberichte",
|
||||
"summary_replay": "Sitzungswiedergabe",
|
||||
"summary_pii": "Geräteinformationen",
|
||||
"restart_note_detailed": "* Änderungen bei Analysen und Fehlerberichten werden sofort wirksam. Sitzungswiedergabe und PII-Einstellungen erfordern einen App-Neustart."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "KI-Assistent",
|
||||
"info_title": "KI-gestützter Chat",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "Go Back",
|
||||
"settings": "Settings",
|
||||
"close": "Close",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"show_more": "Show More",
|
||||
"show_less": "Show Less",
|
||||
"load_more": "Load More",
|
||||
|
|
@ -417,7 +419,12 @@
|
|||
"timing_offset": "Timing Offset (s)",
|
||||
"visual_sync": "Visual Sync",
|
||||
"timing_hint": "Nudge subtitles earlier (-) or later (+) to sync if needed.",
|
||||
"reset_defaults": "Reset to defaults"
|
||||
"reset_defaults": "Reset to defaults",
|
||||
"mark_intro_start": "Mark Intro Start",
|
||||
"mark_intro_end": "Mark Intro End",
|
||||
"intro_start_marked": "Intro start marked",
|
||||
"intro_submitted": "Intro submitted successfully",
|
||||
"intro_submit_failed": "Failed to submit intro"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
|
|
@ -747,6 +754,50 @@
|
|||
"app_updates": "App Updates",
|
||||
"about_nuvio": "About Nuvio"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy & Data",
|
||||
"settings_desc": "Control telemetry and data collection",
|
||||
"info_title": "Your Privacy Matters",
|
||||
"info_description": "Control what data is collected and shared. Analytics are off by default and crash reports are anonymous by default.",
|
||||
"analytics_enabled_title": "Analytics Enabled",
|
||||
"analytics_enabled_message": "Usage data will be collected to help improve the app. You can disable this at any time.",
|
||||
"disable_error_reporting_title": "Disable Error Reporting?",
|
||||
"disable_error_reporting_message": "Disabling error reporting means we won\u2019t be notified of crashes or issues you experience. This may affect our ability to fix bugs.",
|
||||
"enable_session_replay_title": "Enable Session Replay?",
|
||||
"enable_session_replay_message": "Session replay records your screen when errors occur to help us understand what happened. This may capture visible content on your screen.",
|
||||
"enable_pii_title": "Enable PII Collection?",
|
||||
"enable_pii_message": "This allows collection of personally identifiable information like IP address and device details. This data helps diagnose issues but increases privacy exposure.",
|
||||
"disable_all_title": "Disable All Telemetry?",
|
||||
"disable_all_message": "This will disable all analytics, error reporting, and session replay. We won\u2019t receive any data about app usage or crashes.",
|
||||
"disable_all_button": "Disable All",
|
||||
"all_disabled_title": "All Telemetry Disabled",
|
||||
"all_disabled_message": "All data collection has been disabled. Changes take effect on next app restart.",
|
||||
"reset_title": "Reset to Recommended",
|
||||
"reset_message": "Privacy settings have been reset to recommended defaults (error reporting enabled, analytics disabled).",
|
||||
"section_analytics": "ANALYTICS",
|
||||
"analytics_title": "Usage Analytics",
|
||||
"analytics_description": "Collect anonymous usage patterns and screen views",
|
||||
"section_error_reporting": "ERROR REPORTING",
|
||||
"error_reporting_title": "Crash Reports",
|
||||
"error_reporting_description": "Send anonymous crash reports to improve stability",
|
||||
"session_replay_title": "Session Replay",
|
||||
"session_replay_description": "Record screen when errors occur",
|
||||
"pii_title": "Include Device Info",
|
||||
"pii_description": "Send IP address and device details with reports",
|
||||
"section_quick_actions": "QUICK ACTIONS",
|
||||
"disable_all": "Disable All Telemetry",
|
||||
"disable_all_desc": "Turn off all data collection",
|
||||
"reset_recommended": "Reset to Recommended",
|
||||
"reset_recommended_desc": "Privacy-first defaults with error reporting",
|
||||
"section_learn_more": "LEARN MORE",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"current_settings": "Current Settings Summary",
|
||||
"summary_analytics": "Analytics",
|
||||
"summary_errors": "Error Reports",
|
||||
"summary_replay": "Session Replay",
|
||||
"summary_pii": "Device Info",
|
||||
"restart_note_detailed": "* Analytics and error reporting changes take effect immediately. Session replay and PII settings require app restart."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "AI Assistant",
|
||||
"info_title": "AI-Powered Chat",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "Volver",
|
||||
"settings": "Ajustes",
|
||||
"close": "Cerrar",
|
||||
"enable": "Habilitar",
|
||||
"disable": "Desactivar",
|
||||
"show_more": "Mostrar más",
|
||||
"show_less": "Mostrar menos",
|
||||
"load_more": "Cargar más",
|
||||
|
|
@ -747,6 +749,50 @@
|
|||
"app_updates": "Actualizaciones de la app",
|
||||
"about_nuvio": "Acerca de Nuvio"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacidad y Datos",
|
||||
"settings_desc": "Controla la recopilación de telemetría y datos",
|
||||
"info_title": "Tu Privacidad nos Importa",
|
||||
"info_description": "Controla qué datos se recopilan y comparten. Las analíticas están desactivadas por defecto y los reportes de fallos son anónimos por defecto.",
|
||||
"analytics_enabled_title": "Analíticas Habilitadas",
|
||||
"analytics_enabled_message": "Se recopilarán datos de uso para ayudar a mejorar la aplicación. Puedes deshabilitarlo en cualquier momento.",
|
||||
"disable_error_reporting_title": "¿Desactivar Reportes de Errores?",
|
||||
"disable_error_reporting_message": "Desactivar reportes de errores significa que no seremos notificados de caídas o problemas que experimentes. Esto puede afectar nuestra capacidad de corregir errores.",
|
||||
"enable_session_replay_title": "¿Habilitar Repetición de Sesión?",
|
||||
"enable_session_replay_message": "La repetición de sesión registra tu pantalla cuando ocurren errores para ayudarnos a entender qué sucedió. Esto puede capturar contenido visible en tu pantalla.",
|
||||
"enable_pii_title": "¿Habilitar Recopilación de PII?",
|
||||
"enable_pii_message": "Esto permite la recopilación de información de identificación personal como dirección IP y detalles del dispositivo. Estos datos ayudan a diagnosticar problemas pero aumentan la exposición de privacidad.",
|
||||
"disable_all_title": "¿Desactivar Toda la Telemetría?",
|
||||
"disable_all_message": "Esto desactivará todas las analíticas, reportes de errores y repetición de sesión. No recibiremos datos sobre el uso de la aplicación o caídas.",
|
||||
"disable_all_button": "Desactivar Todo",
|
||||
"all_disabled_title": "Toda la Telemetría Desactivada",
|
||||
"all_disabled_message": "Se ha desactivado toda la recopilación de datos. Los cambios entrarán en vigor en el próximo reinicio de la aplicación.",
|
||||
"reset_title": "Restablecer a Valores Recomendados",
|
||||
"reset_message": "La configuración de privacidad se ha restablecido a los valores por defecto recomendados (reportes de errores habilitados, analíticas desactivadas).",
|
||||
"section_analytics": "ANALÍTICAS",
|
||||
"analytics_title": "Analíticas de Uso",
|
||||
"analytics_description": "Recopilar patrones de uso anónimos y vistas de pantalla",
|
||||
"section_error_reporting": "REPORTES DE ERRORES",
|
||||
"error_reporting_title": "Reportes de Caídas",
|
||||
"error_reporting_description": "Enviar reportes de caídas anónimos para mejorar la estabilidad",
|
||||
"session_replay_title": "Repetición de Sesión",
|
||||
"session_replay_description": "Grabar pantalla cuando ocurren errores",
|
||||
"pii_title": "Incluir Información del Dispositivo",
|
||||
"pii_description": "Enviar dirección IP y detalles del dispositivo con reportes",
|
||||
"section_quick_actions": "ACCIONES RÁPIDAS",
|
||||
"disable_all": "Desactivar Toda la Telemetría",
|
||||
"disable_all_desc": "Desactivar toda la recopilación de datos",
|
||||
"reset_recommended": "Restablecer a Valores Recomendados",
|
||||
"reset_recommended_desc": "Valores por defecto enfocados en privacidad con reportes de errores",
|
||||
"section_learn_more": "APRENDE MÁS",
|
||||
"privacy_policy": "Política de Privacidad",
|
||||
"current_settings": "Resumen de Configuración Actual",
|
||||
"summary_analytics": "Analíticas",
|
||||
"summary_errors": "Reportes de Errores",
|
||||
"summary_replay": "Repetición de Sesión",
|
||||
"summary_pii": "Información del Dispositivo",
|
||||
"restart_note_detailed": "* Los cambios en analíticas y reportes de errores entran en vigor inmediatamente. La repetición de sesión y la configuración de PII requieren reiniciar la aplicación."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "Asistente de IA",
|
||||
"info_title": "Chat impulsado por IA",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "Retour",
|
||||
"settings": "Paramètres",
|
||||
"close": "Fermer",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"show_more": "Afficher plus",
|
||||
"show_less": "Afficher moins",
|
||||
"load_more": "Charger plus",
|
||||
|
|
@ -747,6 +749,50 @@
|
|||
"app_updates": "Mises à jour de l'application",
|
||||
"about_nuvio": "À propos de Nuvio"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Confidentialité et Données",
|
||||
"settings_desc": "Contrôlez la télémétrie et la collecte de données",
|
||||
"info_title": "Votre Confidentialité nous Importe",
|
||||
"info_description": "Contrôlez les données collectées et partagées. Les analyses sont désactivées par défaut et les rapports de plantage sont anonymes par défaut.",
|
||||
"analytics_enabled_title": "Analyses Activées",
|
||||
"analytics_enabled_message": "Les données d'utilisation seront collectées pour aider à améliorer l'application. Vous pouvez le désactiver à tout moment.",
|
||||
"disable_error_reporting_title": "Désactiver les Rapports d'Erreur ?",
|
||||
"disable_error_reporting_message": "Désactiver les rapports d'erreur signifie que nous ne serons pas notifiés des plantages ou des problèmes que vous rencontrez. Cela pourrait affecter notre capacité à corriger les bugs.",
|
||||
"enable_session_replay_title": "Activer la Lecture de Session ?",
|
||||
"enable_session_replay_message": "La lecture de session enregistre votre écran lorsque des erreurs se produisent pour nous aider à comprendre ce qui s'est passé. Cela peut capturer le contenu visible sur votre écran.",
|
||||
"enable_pii_title": "Activer la Collecte d'IPI ?",
|
||||
"enable_pii_message": "Cela permet la collecte d'informations personnelles identifiables comme l'adresse IP et les détails de l'appareil. Ces données aident à diagnostiquer les problèmes mais augmentent l'exposition à la confidentialité.",
|
||||
"disable_all_title": "Désactiver Toute la Télémétrie ?",
|
||||
"disable_all_message": "Cela désactivera toutes les analyses, rapports d'erreur et lectures de session. Nous ne recevrons aucune donnée sur l'utilisation de l'application ou les plantages.",
|
||||
"disable_all_button": "Tout Désactiver",
|
||||
"all_disabled_title": "Toute la Télémétrie Désactivée",
|
||||
"all_disabled_message": "La collecte de toutes les données a été désactivée. Les modifications prendront effet au prochain redémarrage de l'application.",
|
||||
"reset_title": "Rétablir les Paramètres Recommandés",
|
||||
"reset_message": "Les paramètres de confidentialité ont été rétablis aux valeurs par défaut recommandées (rapports d'erreur activés, analyses désactivées).",
|
||||
"section_analytics": "ANALYSES",
|
||||
"analytics_title": "Analyses d'Utilisation",
|
||||
"analytics_description": "Collecter les modèles d'utilisation anonymes et les vues d'écran",
|
||||
"section_error_reporting": "RAPPORTS D'ERREUR",
|
||||
"error_reporting_title": "Rapports de Plantage",
|
||||
"error_reporting_description": "Envoyer des rapports de plantage anonymes pour améliorer la stabilité",
|
||||
"session_replay_title": "Lecture de Session",
|
||||
"session_replay_description": "Enregistrer l'écran lorsque des erreurs se produisent",
|
||||
"pii_title": "Inclure les Informations d'Appareil",
|
||||
"pii_description": "Envoyer l'adresse IP et les détails de l'appareil avec les rapports",
|
||||
"section_quick_actions": "ACTIONS RAPIDES",
|
||||
"disable_all": "Désactiver Toute la Télémétrie",
|
||||
"disable_all_desc": "Désactiver toute la collecte de données",
|
||||
"reset_recommended": "Rétablir les Paramètres Recommandés",
|
||||
"reset_recommended_desc": "Paramètres par défaut axés sur la confidentialité avec rapports d'erreur",
|
||||
"section_learn_more": "EN SAVOIR PLUS",
|
||||
"privacy_policy": "Politique de Confidentialité",
|
||||
"current_settings": "Résumé des Paramètres Actuels",
|
||||
"summary_analytics": "Analyses",
|
||||
"summary_errors": "Rapports d'Erreur",
|
||||
"summary_replay": "Lecture de Session",
|
||||
"summary_pii": "Informations d'Appareil",
|
||||
"restart_note_detailed": "* Les modifications apportées aux analyses et aux rapports d'erreur entrent en vigueur immédiatement. La lecture de session et les paramètres d'IPI nécessitent un redémarrage de l'application."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "Assistant IA",
|
||||
"info_title": "Chat propulsé par l'IA",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "वापस जाएं",
|
||||
"settings": "सेटिंग्स",
|
||||
"close": "बंद करें",
|
||||
"enable": "सक्षम करें",
|
||||
"disable": "अक्षम करें",
|
||||
"show_more": "और दिखाएं",
|
||||
"show_less": "कम दिखाएं",
|
||||
"load_more": "और लोड करें",
|
||||
|
|
@ -747,6 +749,50 @@
|
|||
"app_updates": "ऐप अपडेट",
|
||||
"about_nuvio": "Nuvio के बारे में"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "गोपनीयता और डेटा",
|
||||
"settings_desc": "टेलीमेट्री और डेटा संग्रह को नियंत्रित करें",
|
||||
"info_title": "आपकी गोपनीयता हमारे लिए महत्वपूर्ण है",
|
||||
"info_description": "नियंत्रित करें कि कौन से डेटा संग्रहीत और साझा किए जाते हैं। विश्लेषण डिफ़ॉल्ट रूप से बंद हैं और क्रैश रिपोर्ट डिफ़ॉल्ट रूप से अनाम हैं।",
|
||||
"analytics_enabled_title": "विश्लेषण सक्षम",
|
||||
"analytics_enabled_message": "ऐप को बेहतर बनाने में मदद के लिए उपयोग डेटा संग्रहीत किया जाएगा। आप इसे किसी भी समय अक्षम कर सकते हैं।",
|
||||
"disable_error_reporting_title": "त्रुटि रिपोर्टिंग अक्षम करें?",
|
||||
"disable_error_reporting_message": "त्रुटि रिपोर्टिंग को अक्षम करने का अर्थ है कि हमें क्रैश या आपके द्वारा अनुभव की गई समस्याओं के बारे में सूचित नहीं किया जाएगा। यह बग को ठीक करने की हमारी क्षमता को प्रभावित कर सकता है।",
|
||||
"enable_session_replay_title": "सत्र प्लेबैक सक्षम करें?",
|
||||
"enable_session_replay_message": "सत्र प्लेबैक त्रुटि होने पर आपकी स्क्रीन को रिकॉर्ड करता है ताकि हमें समझने में मदद मिले कि क्या हुआ। यह आपकी स्क्रीन पर दृश्यमान सामग्री को कैप्चर कर सकता है।",
|
||||
"enable_pii_title": "PII संग्रह सक्षम करें?",
|
||||
"enable_pii_message": "यह IP पता और डिवाइस विवरण जैसी व्यक्तिगत रूप से पहचान योग्य जानकारी के संग्रह की अनुमति देता है। यह डेटा समस्याओं का निदान करने में मदद करता है लेकिन गोपनीयता जोखिम को बढ़ाता है।",
|
||||
"disable_all_title": "सभी टेलीमेट्री अक्षम करें?",
|
||||
"disable_all_message": "यह सभी विश्लेषण, त्रुटि रिपोर्टिंग और सत्र प्लेबैक को अक्षम करेगा। हमें ऐप उपयोग या क्रैश के बारे में कोई डेटा नहीं मिलेगा।",
|
||||
"disable_all_button": "सभी को अक्षम करें",
|
||||
"all_disabled_title": "सभी टेलीमेट्री अक्षम",
|
||||
"all_disabled_message": "सभी डेटा संग्रह को अक्षम कर दिया गया है। अगली ऐप पुनः शुरुआत पर परिवर्तन प्रभावी होंगे।",
|
||||
"reset_title": "अनुशंसित में रीसेट करें",
|
||||
"reset_message": "गोपनीयता सेटिंग्स को अनुशंसित डिफ़ॉल्ट में रीसेट कर दिया गया है (त्रुटि रिपोर्टिंग सक्षम, विश्लेषण अक्षम)।",
|
||||
"section_analytics": "विश्लेषण",
|
||||
"analytics_title": "उपयोग विश्लेषण",
|
||||
"analytics_description": "अनाम उपयोग पैटर्न और स्क्रीन दृश्य संग्रहीत करें",
|
||||
"section_error_reporting": "त्रुटि रिपोर्टिंग",
|
||||
"error_reporting_title": "क्रैश रिपोर्ट",
|
||||
"error_reporting_description": "स्थिरता में सुधार के लिए अनाम क्रैश रिपोर्ट भेजें",
|
||||
"session_replay_title": "सत्र प्लेबैक",
|
||||
"session_replay_description": "त्रुटि होने पर स्क्रीन रिकॉर्ड करें",
|
||||
"pii_title": "डिवाइस जानकारी शामिल करें",
|
||||
"pii_description": "रिपोर्ट के साथ IP पता और डिवाइस विवरण भेजें",
|
||||
"section_quick_actions": "त्वरित कार्य",
|
||||
"disable_all": "सभी टेलीमेट्री अक्षम करें",
|
||||
"disable_all_desc": "सभी डेटा संग्रह बंद करें",
|
||||
"reset_recommended": "अनुशंसित में रीसेट करें",
|
||||
"reset_recommended_desc": "त्रुटि रिपोर्टिंग के साथ गोपनीयता-प्रथम डिफ़ॉल्ट",
|
||||
"section_learn_more": "अधिक जानें",
|
||||
"privacy_policy": "गोपनीयता नीति",
|
||||
"current_settings": "वर्तमान सेटिंग्स सारांश",
|
||||
"summary_analytics": "विश्लेषण",
|
||||
"summary_errors": "त्रुटि रिपोर्ट",
|
||||
"summary_replay": "सत्र प्लेबैक",
|
||||
"summary_pii": "डिवाइस जानकारी",
|
||||
"restart_note_detailed": "* विश्लेषण और त्रुटि रिपोर्टिंग परिवर्तन तुरंत प्रभावी होते हैं। सत्र प्लेबैक और PII सेटिंग्स को ऐप पुनः शुरुआत की आवश्यकता है।"
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "AI सहायक",
|
||||
"info_title": "AI-पावर्ड चैट",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "Idi natrag",
|
||||
"settings": "Postavke",
|
||||
"close": "Zatvori",
|
||||
"enable": "Omogući",
|
||||
"disable": "Onemogući",
|
||||
"show_more": "Prikaži više",
|
||||
"show_less": "Prikaži manje",
|
||||
"load_more": "Učitaj više",
|
||||
|
|
@ -747,6 +749,50 @@
|
|||
"app_updates": "Ažuriranja aplikacije",
|
||||
"about_nuvio": "O Nuviju"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privatnost i Podaci",
|
||||
"settings_desc": "Kontrolirajte telemetriju i prikupljanje podataka",
|
||||
"info_title": "Vaša Privatnost nam je Važna",
|
||||
"info_description": "Kontrolirajte koje podatke se prikupljaju i dijele. Analitika je podrazumevano onemogućena, a izveštaji o greškama su anonimni po zadanom.",
|
||||
"analytics_enabled_title": "Analitika Omogućena",
|
||||
"analytics_enabled_message": "Podaci o korišćenju će se prikupljati kako bi se poboljšala aplikacija. Možete to onemogućiti u bilo kojem trenutku.",
|
||||
"disable_error_reporting_title": "Onemogućiti Izveštavanje o Greškama?",
|
||||
"disable_error_reporting_message": "Onemogućavanje izveštavanja o greškama znači da nećemo biti obavesteni o padu ili problemima koje doživljate. Ovo može uticati na našu sposobnost da ispravimo greške.",
|
||||
"enable_session_replay_title": "Omogućiti Reprodukciju Sesije?",
|
||||
"enable_session_replay_message": "Reprodukcija sesije snima vaš ekran kada se greške dogode kako bi nam pomogla da razumemo šta se desilo. Ovo može da hvata vidljiv sadržaj na vašoj ekranu.",
|
||||
"enable_pii_title": "Omogućiti Prikupljanje PII?",
|
||||
"enable_pii_message": "Ovo omogućava prikupljanje lično identifikabilnih podataka kao što su IP adresa i detalji uređaja. Ovi podaci pomažu u dijagnostici problema, ali povećavaju izloženost privatnosti.",
|
||||
"disable_all_title": "Onemogućiti Svu Telemetriju?",
|
||||
"disable_all_message": "Ovo će onemogućiti svu analitiku, izveštavanje o greškama i reprodukciju sesije. Nećemo primati nikakve podatke o korišćenju aplikacije ili padevima.",
|
||||
"disable_all_button": "Onemogući Sve",
|
||||
"all_disabled_title": "Sva Telemetrija Onemogućena",
|
||||
"all_disabled_message": "Svo prikupljanje podataka je onemogućeno. Promene će stupiti na snagu pri sledećem pokretanju aplikacije.",
|
||||
"reset_title": "Resetuj na Preporučene",
|
||||
"reset_message": "Postavke privatnosti su resetovane na preporučene zadane vrednosti (izveštavanje o greškama omogućeno, analitika onemogućena).",
|
||||
"section_analytics": "ANALITIKA",
|
||||
"analytics_title": "Analitika Korišćenja",
|
||||
"analytics_description": "Prikupljaj anonimne obrasce korišćenja i prikaze ekrana",
|
||||
"section_error_reporting": "IZVEŠTAVANJE O GREŠKAMA",
|
||||
"error_reporting_title": "Izveštaji o Greškama",
|
||||
"error_reporting_description": "Pošalji anonimne izveštaje o greškama kako bi se poboljšala stabilnost",
|
||||
"session_replay_title": "Reprodukcija Sesije",
|
||||
"session_replay_description": "Snimaj ekran kada se greške dogode",
|
||||
"pii_title": "Uključi Informacije o Uređaju",
|
||||
"pii_description": "Pošalji IP adresu i detalje uređaja sa izveštajima",
|
||||
"section_quick_actions": "BRZE AKCIJE",
|
||||
"disable_all": "Onemogući Svu Telemetriju",
|
||||
"disable_all_desc": "Isključi svo prikupljanje podataka",
|
||||
"reset_recommended": "Resetuj na Preporučene",
|
||||
"reset_recommended_desc": "Zadane vrednosti usmeren na privatnost sa izveštavanjem o greškama",
|
||||
"section_learn_more": "SAZNAJ VIŠE",
|
||||
"privacy_policy": "Politika Privatnosti",
|
||||
"current_settings": "Sažetak Trenutnih Postavki",
|
||||
"summary_analytics": "Analitika",
|
||||
"summary_errors": "Izveštaji o Greškama",
|
||||
"summary_replay": "Reprodukcija Sesije",
|
||||
"summary_pii": "Informacije o Uređaju",
|
||||
"restart_note_detailed": "* Promene u analitici i izveštavanju o greškama stupaju na snagu odmah. Reprodukcija sesije i PII postavke zahtevaju ponovni pokretanje aplikacije."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "AI asistent",
|
||||
"info_title": "Chat pokretan umjetnom inteligencijom",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "Torna indietro",
|
||||
"settings": "Impostazioni",
|
||||
"close": "Chiudi",
|
||||
"enable": "Abilita",
|
||||
"disable": "Disabilita",
|
||||
"show_more": "Mostra altro",
|
||||
"show_less": "Mostra meno",
|
||||
"load_more": "Carica altro",
|
||||
|
|
@ -747,6 +749,50 @@
|
|||
"app_updates": "Aggiornamenti App",
|
||||
"about_nuvio": "Informazioni su Nuvio"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy e Dati",
|
||||
"settings_desc": "Controlla la telemetria e la raccolta di dati",
|
||||
"info_title": "La Tua Privacy è Importante per Noi",
|
||||
"info_description": "Controlla quali dati vengono raccolti e condivisi. L'analisi è disabilitata per impostazione predefinita e i rapporti di arresto sono anonimi per impostazione predefinita.",
|
||||
"analytics_enabled_title": "Analisi Abilitata",
|
||||
"analytics_enabled_message": "I dati di utilizzo verranno raccolti per aiutare a migliorare l'app. Puoi disabilitarlo in qualsiasi momento.",
|
||||
"disable_error_reporting_title": "Disabilitare la Segnalazione di Errori?",
|
||||
"disable_error_reporting_message": "Disabilitare la segnalazione di errori significa che non riceveremo notifiche di arresti anomali o problemi che riscontri. Questo potrebbe influire sulla nostra capacità di correggere i bug.",
|
||||
"enable_session_replay_title": "Abilitare la Riproduzione Sessione?",
|
||||
"enable_session_replay_message": "La riproduzione della sessione registra il tuo schermo quando si verificano errori per aiutarci a capire cosa è successo. Questo potrebbe catturare contenuti visibili sullo schermo.",
|
||||
"enable_pii_title": "Abilitare la Raccolta PII?",
|
||||
"enable_pii_message": "Ciò consente la raccolta di informazioni di identificazione personale come indirizzo IP e dettagli del dispositivo. Questi dati aiutano a diagnosticare i problemi ma aumentano l'esposizione della privacy.",
|
||||
"disable_all_title": "Disabilitare Tutta la Telemetria?",
|
||||
"disable_all_message": "Questo disabiliterà tutta l'analisi, la segnalazione di errori e la riproduzione della sessione. Non riceveremo alcun dato sull'utilizzo dell'app o gli arresti.",
|
||||
"disable_all_button": "Disabilita Tutto",
|
||||
"all_disabled_title": "Tutta la Telemetria Disabilitata",
|
||||
"all_disabled_message": "Tutta la raccolta di dati è stata disabilitata. Le modifiche avranno effetto al prossimo riavvio dell'app.",
|
||||
"reset_title": "Ripristina ai Valori Consigliati",
|
||||
"reset_message": "Le impostazioni di privacy sono state ripristinate ai valori predefiniti consigliati (segnalazione di errori abilitata, analisi disabilitata).",
|
||||
"section_analytics": "ANALISI",
|
||||
"analytics_title": "Analisi di Utilizzo",
|
||||
"analytics_description": "Raccogli schemi di utilizzo anonimi e visualizzazioni dello schermo",
|
||||
"section_error_reporting": "SEGNALAZIONE DI ERRORI",
|
||||
"error_reporting_title": "Rapporti di Arresto",
|
||||
"error_reporting_description": "Invia rapporti di arresto anonimi per migliorare la stabilità",
|
||||
"session_replay_title": "Riproduzione Sessione",
|
||||
"session_replay_description": "Registra lo schermo quando si verificano errori",
|
||||
"pii_title": "Includi Informazioni Dispositivo",
|
||||
"pii_description": "Invia indirizzo IP e dettagli del dispositivo con i rapporti",
|
||||
"section_quick_actions": "AZIONI RAPIDE",
|
||||
"disable_all": "Disabilitare Tutta la Telemetria",
|
||||
"disable_all_desc": "Disattiva tutta la raccolta di dati",
|
||||
"reset_recommended": "Ripristina ai Valori Consigliati",
|
||||
"reset_recommended_desc": "Impostazioni predefinite incentrate sulla privacy con segnalazione di errori",
|
||||
"section_learn_more": "SCOPRI DI PIÙ",
|
||||
"privacy_policy": "Informativa sulla Privacy",
|
||||
"current_settings": "Riepilogo delle Impostazioni Attuali",
|
||||
"summary_analytics": "Analisi",
|
||||
"summary_errors": "Rapporti di Errore",
|
||||
"summary_replay": "Riproduzione Sessione",
|
||||
"summary_pii": "Informazioni Dispositivo",
|
||||
"restart_note_detailed": "* Le modifiche all'analisi e alla segnalazione di errori hanno effetto immediato. La riproduzione della sessione e le impostazioni PII richiedono il riavvio dell'app."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "Assistente IA",
|
||||
"info_title": "Chat potenziata dall'IA",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "Voltar",
|
||||
"settings": "Configurações",
|
||||
"close": "Fechar",
|
||||
"enable": "Habilitar",
|
||||
"disable": "Desabilitar",
|
||||
"show_more": "Mostrar Mais",
|
||||
"show_less": "Mostrar Menos",
|
||||
"load_more": "Carregar Mais",
|
||||
|
|
@ -761,6 +763,50 @@
|
|||
"app_updates": "Atualizações do App",
|
||||
"about_nuvio": "Sobre o Nuvio"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacidade e Dados",
|
||||
"settings_desc": "Controle a telemetria e coleta de dados",
|
||||
"info_title": "Sua Privacidade é Importante para Nós",
|
||||
"info_description": "Controle quais dados são coletados e compartilhados. A análise está desabilitada por padrão e os relatórios de falha são anônimos por padrão.",
|
||||
"analytics_enabled_title": "Análise Habilitada",
|
||||
"analytics_enabled_message": "Os dados de uso serão coletados para ajudar a melhorar o app. Você pode desabilitar isso a qualquer momento.",
|
||||
"disable_error_reporting_title": "Desabilitar Relatório de Erros?",
|
||||
"disable_error_reporting_message": "Desabilitar o relatório de erros significa que não seremos notificados de falhas ou problemas que você vivencia. Isso pode afetar nossa capacidade de corrigir bugs.",
|
||||
"enable_session_replay_title": "Habilitar Repetição de Sessão?",
|
||||
"enable_session_replay_message": "A repetição de sessão registra sua tela quando ocorrem erros para nos ajudar a entender o que aconteceu. Isso pode capturar conteúdo visível na sua tela.",
|
||||
"enable_pii_title": "Habilitar Coleta de PII?",
|
||||
"enable_pii_message": "Isso permite a coleta de informações de identificação pessoal como endereço IP e detalhes do dispositivo. Esses dados ajudam a diagnosticar problemas, mas aumentam a exposição de privacidade.",
|
||||
"disable_all_title": "Desabilitar Toda a Telemetria?",
|
||||
"disable_all_message": "Isso desabilitará toda análise, relatório de erros e repetição de sessão. Não receberemos nenhum dado sobre o uso do app ou falhas.",
|
||||
"disable_all_button": "Desabilitar Tudo",
|
||||
"all_disabled_title": "Toda Telemetria Desabilitada",
|
||||
"all_disabled_message": "Toda coleta de dados foi desabilitada. As alterações terão efeito no próximo reinício do app.",
|
||||
"reset_title": "Restaurar para Recomendado",
|
||||
"reset_message": "As configurações de privacidade foram restauradas para os padrões recomendados (relatório de erros habilitado, análise desabilitada).",
|
||||
"section_analytics": "ANÁLISE",
|
||||
"analytics_title": "Análise de Uso",
|
||||
"analytics_description": "Coletar padrões de uso anônimos e visualizações de tela",
|
||||
"section_error_reporting": "RELATÓRIO DE ERROS",
|
||||
"error_reporting_title": "Relatórios de Falha",
|
||||
"error_reporting_description": "Enviar relatórios de falha anônimos para melhorar a estabilidade",
|
||||
"session_replay_title": "Repetição de Sessão",
|
||||
"session_replay_description": "Gravar tela quando ocorrem erros",
|
||||
"pii_title": "Incluir Informações do Dispositivo",
|
||||
"pii_description": "Enviar endereço IP e detalhes do dispositivo com relatórios",
|
||||
"section_quick_actions": "AÇÕES RÁPIDAS",
|
||||
"disable_all": "Desabilitar Toda a Telemetria",
|
||||
"disable_all_desc": "Desligar toda coleta de dados",
|
||||
"reset_recommended": "Restaurar para Recomendado",
|
||||
"reset_recommended_desc": "Padrões focados em privacidade com relatório de erros",
|
||||
"section_learn_more": "SAIBA MAIS",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"current_settings": "Resumo das Configurações Atuais",
|
||||
"summary_analytics": "Análise",
|
||||
"summary_errors": "Relatórios de Erro",
|
||||
"summary_replay": "Repetição de Sessão",
|
||||
"summary_pii": "Informações do Dispositivo",
|
||||
"restart_note_detailed": "* As alterações em análise e relatório de erros têm efeito imediato. A repetição de sessão e as configurações de PII requerem reinicialização do app."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "Assistente IA",
|
||||
"info_title": "Chat com IA",
|
||||
|
|
|
|||
|
|
@ -761,6 +761,50 @@
|
|||
"app_updates": "Atualizações da App",
|
||||
"about_nuvio": "Sobre o Nuvio"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacidade e Dados",
|
||||
"settings_desc": "Controle a telemetria e recolha de dados",
|
||||
"info_title": "A Sua Privacidade Importa-nos",
|
||||
"info_description": "Controle quais dados são recolhidos e partilhados. A análise está desativada por padrão e os relatórios de falha são anónimos por padrão.",
|
||||
"analytics_enabled_title": "Análise Ativada",
|
||||
"analytics_enabled_message": "Os dados de utilização serão recolhidos para ajudar a melhorar a aplicação. Pode desativar isto a qualquer momento.",
|
||||
"disable_error_reporting_title": "Desativar Relatório de Erros?",
|
||||
"disable_error_reporting_message": "Desativar o relatório de erros significa que não seremos notificados de falhas ou problemas que experimenta. Isto pode afetar a nossa capacidade de corrigir bugs.",
|
||||
"enable_session_replay_title": "Ativar Reprodução de Sessão?",
|
||||
"enable_session_replay_message": "A reprodução de sessão grava o seu ecrã quando ocorrem erros para nos ajudar a compreender o que aconteceu. Isto pode capturar conteúdo visível no seu ecrã.",
|
||||
"enable_pii_title": "Ativar Recolha de PII?",
|
||||
"enable_pii_message": "Isto permite a recolha de informações de identificação pessoal como endereço IP e detalhes do dispositivo. Estes dados ajudam a diagnosticar problemas, mas aumentam a exposição de privacidade.",
|
||||
"disable_all_title": "Desativar Toda a Telemetria?",
|
||||
"disable_all_message": "Isto desativará toda a análise, relatório de erros e reprodução de sessão. Não receberemos nenhum dado sobre a utilização da aplicação ou falhas.",
|
||||
"disable_all_button": "Desativar Tudo",
|
||||
"all_disabled_title": "Toda Telemetria Desativada",
|
||||
"all_disabled_message": "Toda a recolha de dados foi desativada. As alterações terão efeito no próximo reinício da aplicação.",
|
||||
"reset_title": "Restaurar para Recomendado",
|
||||
"reset_message": "As configurações de privacidade foram restauradas para os padrões recomendados (relatório de erros ativado, análise desativada).",
|
||||
"section_analytics": "ANÁLISE",
|
||||
"analytics_title": "Análise de Utilização",
|
||||
"analytics_description": "Recolher padrões de utilização anónimos e vistas de ecrã",
|
||||
"section_error_reporting": "RELATÓRIO DE ERROS",
|
||||
"error_reporting_title": "Relatórios de Falha",
|
||||
"error_reporting_description": "Enviar relatórios de falha anónimos para melhorar a estabilidade",
|
||||
"session_replay_title": "Reprodução de Sessão",
|
||||
"session_replay_description": "Gravar ecrã quando ocorrem erros",
|
||||
"pii_title": "Incluir Informações do Dispositivo",
|
||||
"pii_description": "Enviar endereço IP e detalhes do dispositivo com relatórios",
|
||||
"section_quick_actions": "AÇÕES RÁPIDAS",
|
||||
"disable_all": "Desativar Toda a Telemetria",
|
||||
"disable_all_desc": "Desligar toda a recolha de dados",
|
||||
"reset_recommended": "Restaurar para Recomendado",
|
||||
"reset_recommended_desc": "Padrões focados em privacidade com relatório de erros",
|
||||
"section_learn_more": "SAIBA MAIS",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"current_settings": "Resumo das Configurações Atuais",
|
||||
"summary_analytics": "Análise",
|
||||
"summary_errors": "Relatórios de Erro",
|
||||
"summary_replay": "Reprodução de Sessão",
|
||||
"summary_pii": "Informações do Dispositivo",
|
||||
"restart_note_detailed": "* As alterações em análise e relatório de erros têm efeito imediato. A reprodução de sessão e as configurações de PII requerem reinicialização da aplicação."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "Assistente IA",
|
||||
"info_title": "Chat com IA",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "Иди назад",
|
||||
"settings": "Подешавања",
|
||||
"close": "Затвори",
|
||||
"enable": "Омогући",
|
||||
"disable": "Искључи",
|
||||
"show_more": "Прикажи више",
|
||||
"show_less": "Прикажи мање",
|
||||
"load_more": "Учитај више",
|
||||
|
|
@ -745,6 +747,50 @@
|
|||
"app_updates": "Ажурирања апликације",
|
||||
"about_nuvio": "О Nuvio апликацији"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Приватност и Подаци",
|
||||
"settings_desc": "Управљајте телеметријом и прикупљањем података",
|
||||
"info_title": "Ваша Приватност је Важна за Нас",
|
||||
"info_description": "Управљајте које podatke se prikupljaju i dijele. Аналитика је подразумевано искључена и извештаји о паду су подразумевано анонимни.",
|
||||
"analytics_enabled_title": "Аналитика Омогућена",
|
||||
"analytics_enabled_message": "Подаци о коришћењу ће бити прикупљени да помогну унапређивању апликације. Можете то искључити у било ком тренутку.",
|
||||
"disable_error_reporting_title": "Искључити Извештавање о Грешкама?",
|
||||
"disable_error_reporting_message": "Искључивање извештавања о грешкама значи да нећемо бити обавештени о падовима или проблемима које доживљавате. Ово може утицати на нашу способност да исправимо грешке.",
|
||||
"enable_session_replay_title": "Омогућити Репродукцију Сесије?",
|
||||
"enable_session_replay_message": "Репродукција сесије снима ваш екран када се греške јаве да би нам помогла да разумемо шта се десило. Ово може да захвати видљив садржај на вашем екрану.",
|
||||
"enable_pii_title": "Омогућити Прикупљање PII?",
|
||||
"enable_pii_message": "Ово омогућава прикупљање лично идентификабилних информација као што су IP адреса и детаљи уређаја. Ови подаци помажу у дијагностици проблема, али повећавају изложеност приватности.",
|
||||
"disable_all_title": "Искључити Сву Телеметрију?",
|
||||
"disable_all_message": "Ово ће искључити сву аналитику, извештавање о грешкама и репродукцију сесије. Нећемо примити никакве податке о коришћењу апликације или падовима.",
|
||||
"disable_all_button": "Искључи Све",
|
||||
"all_disabled_title": "Сва Телеметрија Искључена",
|
||||
"all_disabled_message": "Сво прикупљање подстака је искључено. Промене ће ступити на снагу при следећем покретању апликације.",
|
||||
"reset_title": "Враћање на Препоручено",
|
||||
"reset_message": "Поставке приватности су враћене на препоручене подразумеване вредности (извештавање о грешкама омогућено, аналитика искључена).",
|
||||
"section_analytics": "АНАЛИТИКА",
|
||||
"analytics_title": "Аналитика Коришћења",
|
||||
"analytics_description": "Прикупљање анонимних образаца коришћења и приказа екрана",
|
||||
"section_error_reporting": "ИЗВЕШТАВАЊЕ О ГРЕШКАМА",
|
||||
"error_reporting_title": "Извештаји о Паду",
|
||||
"error_reporting_description": "Слање анонимних извештаја о паду за унапређивање стабилности",
|
||||
"session_replay_title": "Репродукција Сесије",
|
||||
"session_replay_description": "Снимање екрана када се греške јаве",
|
||||
"pii_title": "Укључивање Информација о Уређају",
|
||||
"pii_description": "Слање IP адресе и детаља уређаја са извештајима",
|
||||
"section_quick_actions": "БРЗЕ АКЦИЈЕ",
|
||||
"disable_all": "Искључити Сву Телеметрију",
|
||||
"disable_all_desc": "Искључи сво прикупљање подстака",
|
||||
"reset_recommended": "Враћање на Препоручено",
|
||||
"reset_recommended_desc": "Подразумеване вредности усмерене на приватност са извештавањем о грешкама",
|
||||
"section_learn_more": "САЗНАЈТЕ ВИШЕ",
|
||||
"privacy_policy": "Политика Приватности",
|
||||
"current_settings": "Резиме Текућих Поставки",
|
||||
"summary_analytics": "Аналитика",
|
||||
"summary_errors": "Извештаји о Грешкама",
|
||||
"summary_replay": "Репродукција Сесије",
|
||||
"summary_pii": "Информације о Уређају",
|
||||
"restart_note_detailed": "* Промене у аналитици и извештавању о грешкама ступају на снагу одмah. Репродукција сесије и PII поставке захтевају поновно покретање апликације."
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "AI Асистент",
|
||||
"info_title": "Ћаскање уз помоћ AI",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"go_back": "返回",
|
||||
"settings": "设置",
|
||||
"close": "关闭",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"show_more": "显示更多",
|
||||
"show_less": "显示更少",
|
||||
"load_more": "加载更多",
|
||||
|
|
@ -747,6 +749,50 @@
|
|||
"app_updates": "应用更新",
|
||||
"about_nuvio": "关于 Nuvio"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "隐私和数据",
|
||||
"settings_desc": "控制遥测和数据收集",
|
||||
"info_title": "您的隐私对我们很重要",
|
||||
"info_description": "控制收集和共享哪些数据。分析默认情况下处于关闭状态,崩溃报告默认情况下是匿名的。",
|
||||
"analytics_enabled_title": "已启用分析",
|
||||
"analytics_enabled_message": "将收集使用数据以帮助改进应用。您可以随时禁用此功能。",
|
||||
"disable_error_reporting_title": "禁用错误报告?",
|
||||
"disable_error_reporting_message": "禁用错误报告意味着我们不会被告知您遇到的崩溃或问题。这可能会影响我们修复bug的能力。",
|
||||
"enable_session_replay_title": "启用会话重放?",
|
||||
"enable_session_replay_message": "会话重放会在发生错误时记录您的屏幕,以帮助我们了解发生了什么。这可能会捕获屏幕上的可见内容。",
|
||||
"enable_pii_title": "启用PII收集?",
|
||||
"enable_pii_message": "这允许收集个人可识别信息,如IP地址和设备详情。这些数据有助于诊断问题,但会增加隐私风险。",
|
||||
"disable_all_title": "禁用所有遥测?",
|
||||
"disable_all_message": "这将禁用所有分析、错误报告和会话重放。我们不会收到任何关于应用使用或崩溃的数据。",
|
||||
"disable_all_button": "禁用全部",
|
||||
"all_disabled_title": "所有遥测已禁用",
|
||||
"all_disabled_message": "已禁用所有数据收集。更改将在下次应用重启时生效。",
|
||||
"reset_title": "重置为推荐设置",
|
||||
"reset_message": "隐私设置已重置为推荐的默认值(错误报告已启用,分析已禁用)。",
|
||||
"section_analytics": "分析",
|
||||
"analytics_title": "使用分析",
|
||||
"analytics_description": "收集匿名使用模式和屏幕查看",
|
||||
"section_error_reporting": "错误报告",
|
||||
"error_reporting_title": "崩溃报告",
|
||||
"error_reporting_description": "发送匿名崩溃报告以提高稳定性",
|
||||
"session_replay_title": "会话重放",
|
||||
"session_replay_description": "发生错误时记录屏幕",
|
||||
"pii_title": "包含设备信息",
|
||||
"pii_description": "在报告中发送IP地址和设备详情",
|
||||
"section_quick_actions": "快速操作",
|
||||
"disable_all": "禁用所有遥测",
|
||||
"disable_all_desc": "关闭所有数据收集",
|
||||
"reset_recommended": "重置为推荐设置",
|
||||
"reset_recommended_desc": "注重隐私的默认设置,包含错误报告",
|
||||
"section_learn_more": "了解更多",
|
||||
"privacy_policy": "隐私政策",
|
||||
"current_settings": "当前设置摘要",
|
||||
"summary_analytics": "分析",
|
||||
"summary_errors": "错误报告",
|
||||
"summary_replay": "会话重放",
|
||||
"summary_pii": "设备信息",
|
||||
"restart_note_detailed": "* 分析和错误报告的更改会立即生效。会话重放和PII设置需要重启应用。"
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "AI 助手",
|
||||
"info_title": "AI 驱动的聊天",
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ import { HeaderVisibility } from '../contexts/HeaderVisibility';
|
|||
import { Stream } from '../types/streams';
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { PostHogProvider } from 'posthog-react-native';
|
||||
import { PostHogProvider, usePostHog } from 'posthog-react-native';
|
||||
import { ScrollToTopProvider, useScrollToTopEmitter } from '../contexts/ScrollToTopContext';
|
||||
import { telemetryService, TELEMETRY_EVENTS } from '../services/telemetryService';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Optional iOS Glass effect (expo-glass-effect) with safe fallback
|
||||
|
|
@ -84,6 +85,7 @@ import {
|
|||
AboutSettingsScreen,
|
||||
DeveloperSettingsScreen,
|
||||
LegalScreen,
|
||||
PrivacySettingsScreen,
|
||||
} from '../screens/settings';
|
||||
|
||||
|
||||
|
|
@ -228,6 +230,7 @@ export type RootStackParamList = {
|
|||
PlaybackSettings: undefined;
|
||||
AboutSettings: undefined;
|
||||
DeveloperSettings: undefined;
|
||||
PrivacySettings: undefined;
|
||||
Legal: undefined;
|
||||
};
|
||||
|
||||
|
|
@ -1872,6 +1875,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PrivacySettings"
|
||||
component={PrivacySettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'default' : 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</View>
|
||||
</PaperProvider>
|
||||
|
|
@ -1879,19 +1897,118 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Conditional PostHog Provider Wrapper
|
||||
*
|
||||
* Only initializes PostHog analytics if user has opted in via Privacy Settings.
|
||||
* By default, analytics is disabled for privacy.
|
||||
* Uses PostHog's optIn/optOut API for runtime control.
|
||||
*/
|
||||
const ConditionalPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const posthogRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize telemetry service and check analytics preference
|
||||
const initializeTelemetry = async () => {
|
||||
try {
|
||||
await telemetryService.initialize();
|
||||
setAnalyticsEnabled(telemetryService.isAnalyticsEnabled());
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize telemetry service:', error);
|
||||
setAnalyticsEnabled(false);
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
initializeTelemetry();
|
||||
|
||||
// Listen for telemetry setting changes
|
||||
const subscription = DeviceEventEmitter.addListener(
|
||||
TELEMETRY_EVENTS.SETTINGS_CHANGED,
|
||||
(settings) => {
|
||||
setAnalyticsEnabled(settings.analyticsEnabled);
|
||||
// If PostHog is available, update its opt-in/out state immediately
|
||||
if (posthogRef.current) {
|
||||
if (settings.analyticsEnabled) {
|
||||
posthogRef.current.optIn();
|
||||
console.log('[Telemetry] PostHog opted in');
|
||||
} else {
|
||||
posthogRef.current.optOut();
|
||||
console.log('[Telemetry] PostHog opted out');
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Wait for initialization before rendering
|
||||
if (!isInitialized) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Always wrap with PostHogProvider but control via optOut
|
||||
// This allows runtime toggling without remounting the tree
|
||||
return (
|
||||
<PostHogProvider
|
||||
apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C"
|
||||
options={{
|
||||
host: "https://us.i.posthog.com",
|
||||
autocapture: analyticsEnabled,
|
||||
// Start opted out if analytics is disabled
|
||||
defaultOptIn: analyticsEnabled,
|
||||
}}
|
||||
autocapture={analyticsEnabled}
|
||||
>
|
||||
<PostHogOptController
|
||||
enabled={analyticsEnabled}
|
||||
onPostHogReady={(posthog) => { posthogRef.current = posthog; }}
|
||||
/>
|
||||
{children}
|
||||
</PostHogProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal component to handle PostHog opt-in/opt-out
|
||||
* Uses the official usePostHog hook for reliable API access
|
||||
*/
|
||||
const PostHogOptController: React.FC<{
|
||||
enabled: boolean;
|
||||
onPostHogReady: (posthog: any) => void;
|
||||
}> = ({ enabled, onPostHogReady }) => {
|
||||
const posthog = usePostHog();
|
||||
|
||||
useEffect(() => {
|
||||
if (posthog) {
|
||||
onPostHogReady(posthog);
|
||||
if (enabled) {
|
||||
posthog.optIn();
|
||||
console.log('[Telemetry] PostHog opted in');
|
||||
} else {
|
||||
posthog.optOut();
|
||||
console.log('[Telemetry] PostHog opted out');
|
||||
}
|
||||
}
|
||||
}, [enabled, posthog, onPostHogReady]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => (
|
||||
<PostHogProvider
|
||||
apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C"
|
||||
options={{
|
||||
host: "https://us.i.posthog.com",
|
||||
}}
|
||||
>
|
||||
<ConditionalPostHogProvider>
|
||||
<ScrollToTopProvider>
|
||||
<LoadingProvider>
|
||||
<InnerNavigator initialRouteName={initialRouteName} />
|
||||
</LoadingProvider>
|
||||
</ScrollToTopProvider>
|
||||
</PostHogProvider>
|
||||
</ConditionalPostHogProvider>
|
||||
);
|
||||
|
||||
export default AppNavigator;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { ContentDiscoverySettingsContent } from './settings/ContentDiscoverySett
|
|||
import { AppearanceSettingsContent } from './settings/AppearanceSettingsScreen';
|
||||
import { IntegrationsSettingsContent } from './settings/IntegrationsSettingsScreen';
|
||||
import { AboutSettingsContent, AboutFooter } from './settings/AboutSettingsScreen';
|
||||
import { PrivacySettingsContent } from './settings/PrivacySettingsScreen';
|
||||
import { SettingsCard, SettingItem, ChevronRight, CustomSwitch } from './settings/SettingsComponents';
|
||||
import { useBottomSheetBackHandler } from '../hooks/useBottomSheetBackHandler';
|
||||
import { LOCALES } from '../constants/locales';
|
||||
|
|
@ -149,6 +150,7 @@ const SettingsScreen: React.FC = () => {
|
|||
{ id: 'playback', title: t('settings.playback'), icon: 'play-circle' },
|
||||
{ id: 'backup', title: t('settings.backup_restore'), icon: 'archive' },
|
||||
{ id: 'updates', title: t('settings.updates'), icon: 'refresh-ccw' },
|
||||
{ id: 'privacy', title: t('privacy.title'), icon: 'shield' },
|
||||
{ id: 'about', title: t('settings.about'), icon: 'info' },
|
||||
{ id: 'developer', title: t('settings.developer'), icon: 'code' },
|
||||
{ id: 'cache', title: t('settings.cache'), icon: 'database' },
|
||||
|
|
@ -442,6 +444,9 @@ const SettingsScreen: React.FC = () => {
|
|||
case 'about':
|
||||
return <AboutSettingsContent isTablet={isTablet} displayDownloads={displayDownloads} />;
|
||||
|
||||
case 'privacy':
|
||||
return <PrivacySettingsContent isTablet={isTablet} />;
|
||||
|
||||
case 'developer':
|
||||
return (__DEV__ || developerModeEnabled) ? (
|
||||
<SettingsCard title={t('settings.sections.testing')} isTablet={isTablet}>
|
||||
|
|
@ -837,6 +842,13 @@ const SettingsScreen: React.FC = () => {
|
|||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('Contributors')}
|
||||
/>
|
||||
<SettingItem
|
||||
title={t('privacy.title')}
|
||||
description={t('privacy.settings_desc')}
|
||||
icon="shield"
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('PrivacySettings')}
|
||||
/>
|
||||
<SettingItem
|
||||
title={t('settings.about_nuvio')}
|
||||
description={getDisplayedAppVersion()}
|
||||
|
|
|
|||
|
|
@ -1175,6 +1175,8 @@ const TMDBSettingsScreen = () => {
|
|||
{ code: 'uk', label: 'Українська', native: 'Ukrainian' },
|
||||
{ code: 'vi', label: 'Tiếng Việt', native: 'Vietnamese' },
|
||||
{ code: 'th', label: 'ไทย', native: 'Thai' },
|
||||
{ code: 'hr',
|
||||
label: 'Hrvatski', native: 'Croatian' },
|
||||
];
|
||||
|
||||
const filteredLanguages = languages.filter(({ label, code, native }) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Dimensions, TextInput, ActivityIndicator } from 'react-native';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
|
@ -13,6 +13,8 @@ import { MaterialIcons } from '@expo/vector-icons';
|
|||
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SvgXml } from 'react-native-svg';
|
||||
import { toastService } from '../../services/toastService';
|
||||
import { introService } from '../../services/introService';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
|
|
@ -77,6 +79,42 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
const config = useRealtimeConfig();
|
||||
|
||||
const [introDbLogoXml, setIntroDbLogoXml] = useState<string | null>(null);
|
||||
const [apiKeyInput, setApiKeyInput] = useState(settings?.introDbApiKey || '');
|
||||
const [isVerifyingKey, setIsVerifyingKey] = useState(false);
|
||||
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setApiKeyInput(settings?.introDbApiKey || '');
|
||||
}, [settings?.introDbApiKey]);
|
||||
|
||||
const handleApiKeySubmit = async () => {
|
||||
if (!apiKeyInput.trim()) {
|
||||
updateSetting('introDbApiKey', '');
|
||||
toastService.success(t('settings.items.api_key_cleared', { defaultValue: 'API Key Cleared' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVerifyingKey(true);
|
||||
const isValid = await introService.verifyApiKey(apiKeyInput);
|
||||
|
||||
if (!isMounted.current) return;
|
||||
setIsVerifyingKey(false);
|
||||
|
||||
if (isValid) {
|
||||
updateSetting('introDbApiKey', apiKeyInput);
|
||||
toastService.success(t('settings.items.api_key_saved', { defaultValue: 'API Key Saved' }));
|
||||
} else {
|
||||
toastService.error(t('settings.items.api_key_invalid', { defaultValue: 'Invalid API Key' }));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -225,6 +263,54 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{/* IntroDB Contribution Section */}
|
||||
<SettingsCard title={t('settings.sections.introdb_contribution', { defaultValue: 'IntroDB Contribution' })} isTablet={isTablet}>
|
||||
<SettingItem
|
||||
title={t('settings.items.enable_intro_submission', { defaultValue: 'Enable Intro Submission' })}
|
||||
description={t('settings.items.enable_intro_submission_desc', { defaultValue: 'Contribute timestamps to the community' })}
|
||||
icon="flag"
|
||||
renderControl={() => (
|
||||
<CustomSwitch
|
||||
value={settings?.introSubmitEnabled ?? false}
|
||||
onValueChange={(value) => updateSetting('introSubmitEnabled', value)}
|
||||
/>
|
||||
)}
|
||||
isLast={!settings?.introSubmitEnabled}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
|
||||
{settings?.introSubmitEnabled && (
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>
|
||||
{t('settings.items.introdb_api_key', { defaultValue: 'INTRODB API KEY' })}
|
||||
</Text>
|
||||
<View style={styles.apiKeyRow}>
|
||||
<TextInput
|
||||
style={[styles.input, { flex: 1, marginRight: 10, color: currentTheme.colors.highEmphasis }]}
|
||||
value={apiKeyInput}
|
||||
onChangeText={setApiKeyInput}
|
||||
placeholder="Enter your API key"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.confirmButton}
|
||||
onPress={handleApiKeySubmit}
|
||||
disabled={isVerifyingKey}
|
||||
>
|
||||
{isVerifyingKey ? (
|
||||
<ActivityIndicator size="small" color="black" />
|
||||
) : (
|
||||
<MaterialIcons name="check" size={24} color="black" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Audio & Subtitle Preferences */}
|
||||
<SettingsCard title={t('settings.sections.audio_subtitles')} isTablet={isTablet}>
|
||||
<SettingItem
|
||||
|
|
@ -542,6 +628,39 @@ const styles = StyleSheet.create({
|
|||
fontSize: 13,
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
},
|
||||
inputContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
marginBottom: 8,
|
||||
marginLeft: 4,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
apiKeyRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 12,
|
||||
width: 48,
|
||||
height: 48,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default PlaybackSettingsScreen;
|
||||
|
|
|
|||
469
src/screens/settings/PrivacySettingsScreen.tsx
Normal file
469
src/screens/settings/PrivacySettingsScreen.tsx
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Dimensions,
|
||||
Linking,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import ScreenHeader from '../../components/common/ScreenHeader';
|
||||
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { telemetryService, TelemetrySettings } from '../../services/telemetryService';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface PrivacySettingsContentProps {
|
||||
isTablet?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy Settings Content Component
|
||||
*
|
||||
* Provides user control over telemetry, analytics, and error reporting.
|
||||
*
|
||||
* Data Collection Summary:
|
||||
* - Analytics (PostHog): Usage patterns, screen views, interactions
|
||||
* - Error Reporting (Sentry): Crash reports and errors for app stability
|
||||
* - Session Replay: Screen recordings when errors occur
|
||||
* - PII: Personal identifiable information (IP, device info, etc.)
|
||||
*/
|
||||
export const PrivacySettingsContent: React.FC<PrivacySettingsContentProps> = ({
|
||||
isTablet = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Telemetry settings state
|
||||
const [settings, setSettings] = useState<TelemetrySettings>({
|
||||
analyticsEnabled: false,
|
||||
errorReportingEnabled: true,
|
||||
sessionReplayEnabled: false,
|
||||
piiEnabled: false,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const showAlert = useCallback((
|
||||
title: string,
|
||||
message: string,
|
||||
actions?: Array<{ label: string; onPress: () => void; style?: object }>
|
||||
) => {
|
||||
const alertActions = (actions || [{ label: 'OK', onPress: () => { } }]).map(action => ({
|
||||
text: action.label,
|
||||
onPress: action.onPress,
|
||||
style: undefined as 'default' | 'cancel' | 'destructive' | undefined,
|
||||
}));
|
||||
|
||||
Alert.alert(title, message, alertActions, { cancelable: true });
|
||||
}, []);
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
await telemetryService.initialize();
|
||||
setSettings(telemetryService.getSettings());
|
||||
} catch (error) {
|
||||
console.error('Failed to load telemetry settings:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Handle analytics toggle
|
||||
const handleAnalyticsToggle = async (enabled: boolean) => {
|
||||
try {
|
||||
await telemetryService.setAnalyticsEnabled(enabled);
|
||||
setSettings(prev => ({ ...prev, analyticsEnabled: enabled }));
|
||||
|
||||
if (enabled) {
|
||||
showAlert(
|
||||
t('privacy.analytics_enabled_title'),
|
||||
t('privacy.analytics_enabled_message')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update analytics setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle error reporting toggle
|
||||
const handleErrorReportingToggle = async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
showAlert(
|
||||
t('privacy.disable_error_reporting_title'),
|
||||
t('privacy.disable_error_reporting_message'),
|
||||
[
|
||||
{ label: t('common.cancel', 'Cancel'), onPress: () => { } },
|
||||
{
|
||||
label: t('common.disable', 'Disable'),
|
||||
onPress: async () => {
|
||||
await telemetryService.setErrorReportingEnabled(false);
|
||||
setSettings(prev => ({ ...prev, errorReportingEnabled: false }));
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
} else {
|
||||
await telemetryService.setErrorReportingEnabled(true);
|
||||
setSettings(prev => ({ ...prev, errorReportingEnabled: true }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle session replay toggle
|
||||
const handleSessionReplayToggle = async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
showAlert(
|
||||
t('privacy.enable_session_replay_title'),
|
||||
t('privacy.enable_session_replay_message'),
|
||||
[
|
||||
{ label: t('common.cancel', 'Cancel'), onPress: () => { } },
|
||||
{
|
||||
label: t('common.enable'),
|
||||
onPress: async () => {
|
||||
await telemetryService.setSessionReplayEnabled(true);
|
||||
setSettings(prev => ({ ...prev, sessionReplayEnabled: true }));
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
} else {
|
||||
await telemetryService.setSessionReplayEnabled(false);
|
||||
setSettings(prev => ({ ...prev, sessionReplayEnabled: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle PII toggle
|
||||
const handlePiiToggle = async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
showAlert(
|
||||
t('privacy.enable_pii_title'),
|
||||
t('privacy.enable_pii_message'),
|
||||
[
|
||||
{ label: t('common.cancel', 'Cancel'), onPress: () => { } },
|
||||
{
|
||||
label: t('common.enable'),
|
||||
onPress: async () => {
|
||||
await telemetryService.setPiiEnabled(true);
|
||||
setSettings(prev => ({ ...prev, piiEnabled: true }));
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
} else {
|
||||
await telemetryService.setPiiEnabled(false);
|
||||
setSettings(prev => ({ ...prev, piiEnabled: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Disable all telemetry
|
||||
const handleDisableAll = () => {
|
||||
showAlert(
|
||||
t('privacy.disable_all_title'),
|
||||
t('privacy.disable_all_message'),
|
||||
[
|
||||
{ label: t('common.cancel', 'Cancel'), onPress: () => { } },
|
||||
{
|
||||
label: t('privacy.disable_all_button'),
|
||||
onPress: async () => {
|
||||
await telemetryService.disableAllTelemetry();
|
||||
setSettings({
|
||||
analyticsEnabled: false,
|
||||
errorReportingEnabled: false,
|
||||
sessionReplayEnabled: false,
|
||||
piiEnabled: false,
|
||||
});
|
||||
// Delay showing the next alert to avoid Reanimated conflicts
|
||||
setTimeout(() => {
|
||||
showAlert(
|
||||
t('privacy.all_disabled_title'),
|
||||
t('privacy.all_disabled_message')
|
||||
);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Reset to recommended defaults
|
||||
const handleResetToRecommended = async () => {
|
||||
await telemetryService.enableRecommendedTelemetry();
|
||||
setSettings({
|
||||
analyticsEnabled: false,
|
||||
errorReportingEnabled: true,
|
||||
sessionReplayEnabled: false,
|
||||
piiEnabled: false,
|
||||
});
|
||||
// No chained alert here, this is direct so it's fine
|
||||
showAlert(
|
||||
t('privacy.reset_title'),
|
||||
t('privacy.reset_message')
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('common.loading', 'Loading...')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Info Card */}
|
||||
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{t('privacy.info_title')}
|
||||
</Text>
|
||||
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('privacy.info_description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Analytics Section */}
|
||||
<SettingsCard title={t('privacy.section_analytics')} isTablet={isTablet}>
|
||||
<SettingItem
|
||||
title={t('privacy.analytics_title')}
|
||||
description={t('privacy.analytics_description')}
|
||||
icon="bar-chart-2"
|
||||
renderControl={() => (
|
||||
<CustomSwitch
|
||||
value={settings.analyticsEnabled}
|
||||
onValueChange={handleAnalyticsToggle}
|
||||
/>
|
||||
)}
|
||||
descriptionNumberOfLines={2}
|
||||
isLast
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Error Reporting Section */}
|
||||
<SettingsCard title={t('privacy.section_error_reporting')} isTablet={isTablet}>
|
||||
<SettingItem
|
||||
title={t('privacy.error_reporting_title')}
|
||||
description={t('privacy.error_reporting_description')}
|
||||
icon="alert-circle"
|
||||
renderControl={() => (
|
||||
<CustomSwitch
|
||||
value={settings.errorReportingEnabled}
|
||||
onValueChange={handleErrorReportingToggle}
|
||||
/>
|
||||
)}
|
||||
descriptionNumberOfLines={2}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title={t('privacy.session_replay_title')}
|
||||
description={t('privacy.session_replay_description')}
|
||||
icon="video"
|
||||
renderControl={() => (
|
||||
<CustomSwitch
|
||||
value={settings.sessionReplayEnabled}
|
||||
onValueChange={handleSessionReplayToggle}
|
||||
/>
|
||||
)}
|
||||
descriptionNumberOfLines={2}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title={t('privacy.pii_title')}
|
||||
description={t('privacy.pii_description')}
|
||||
icon="user"
|
||||
renderControl={() => (
|
||||
<CustomSwitch
|
||||
value={settings.piiEnabled}
|
||||
onValueChange={handlePiiToggle}
|
||||
/>
|
||||
)}
|
||||
descriptionNumberOfLines={2}
|
||||
isLast
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<SettingsCard title={t('privacy.section_quick_actions')} isTablet={isTablet}>
|
||||
<SettingItem
|
||||
title={t('privacy.disable_all')}
|
||||
description={t('privacy.disable_all_desc')}
|
||||
icon="shield-off"
|
||||
onPress={handleDisableAll}
|
||||
renderControl={() => <ChevronRight />}
|
||||
descriptionNumberOfLines={2}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title={t('privacy.reset_recommended')}
|
||||
description={t('privacy.reset_recommended_desc')}
|
||||
icon="refresh-cw"
|
||||
onPress={handleResetToRecommended}
|
||||
renderControl={() => <ChevronRight />}
|
||||
descriptionNumberOfLines={2}
|
||||
isLast
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Learn More */}
|
||||
<SettingsCard title={t('privacy.section_learn_more')} isTablet={isTablet}>
|
||||
<SettingItem
|
||||
title={t('privacy.privacy_policy')}
|
||||
icon="file-text"
|
||||
onPress={() => Linking.openURL('https://tapframe.github.io/NuvioStreaming/#privacy-policy')}
|
||||
renderControl={() => <ChevronRight />}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Data Summary */}
|
||||
<View style={[styles.summaryCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.summaryTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{t('privacy.current_settings')}
|
||||
</Text>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: settings.analyticsEnabled ? '#4CAF50' : '#9E9E9E' }]} />
|
||||
<Text style={[styles.summaryText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('privacy.summary_analytics')}: {settings.analyticsEnabled ? t('common.on', 'On') : t('common.off', 'Off')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: settings.errorReportingEnabled ? '#4CAF50' : '#9E9E9E' }]} />
|
||||
<Text style={[styles.summaryText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('privacy.summary_errors')}: {settings.errorReportingEnabled ? t('common.on', 'On') : t('common.off', 'Off')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: settings.sessionReplayEnabled ? '#FF9800' : '#9E9E9E' }]} />
|
||||
<Text style={[styles.summaryText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('privacy.summary_replay')}: {settings.sessionReplayEnabled ? t('common.on', 'On') : t('common.off', 'Off')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: settings.piiEnabled ? '#FF9800' : '#9E9E9E' }]} />
|
||||
<Text style={[styles.summaryText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('privacy.summary_pii')}: {settings.piiEnabled ? t('common.on', 'On') : t('common.off', 'Off')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.restartNote, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{t('privacy.restart_note_detailed')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PrivacySettingsScreen - Wrapper for mobile navigation
|
||||
*/
|
||||
const PrivacySettingsScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const screenIsTablet = width >= 768;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<ScreenHeader
|
||||
title={t('privacy.title')}
|
||||
showBackButton
|
||||
onBackPress={() => navigation.goBack()}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 40 }]}
|
||||
>
|
||||
<PrivacySettingsContent isTablet={screenIsTablet} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingTop: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
infoCard: {
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
summaryCard: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 8,
|
||||
marginBottom: 20,
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
},
|
||||
summaryTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 10,
|
||||
},
|
||||
summaryText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
restartNote: {
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default PrivacySettingsScreen;
|
||||
|
|
@ -58,6 +58,7 @@ interface SettingItemProps {
|
|||
onPress?: () => void;
|
||||
badge?: string | number;
|
||||
isTablet?: boolean;
|
||||
descriptionNumberOfLines?: number;
|
||||
}
|
||||
|
||||
export const SettingItem: React.FC<SettingItemProps> = ({
|
||||
|
|
@ -69,7 +70,8 @@ export const SettingItem: React.FC<SettingItemProps> = ({
|
|||
isLast = false,
|
||||
onPress,
|
||||
badge,
|
||||
isTablet: isTabletProp = false
|
||||
isTablet: isTabletProp = false,
|
||||
descriptionNumberOfLines = 1
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const useTabletStyle = isTabletProp || isTablet;
|
||||
|
|
@ -116,7 +118,7 @@ export const SettingItem: React.FC<SettingItemProps> = ({
|
|||
styles.settingDescription,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
useTabletStyle && styles.tabletSettingDescription
|
||||
]} numberOfLines={1}>
|
||||
]} numberOfLines={descriptionNumberOfLines}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export { default as PlaybackSettingsScreen } from './PlaybackSettingsScreen';
|
|||
export { default as AboutSettingsScreen } from './AboutSettingsScreen';
|
||||
export { default as DeveloperSettingsScreen } from './DeveloperSettingsScreen';
|
||||
export { default as LegalScreen } from './LegalScreen';
|
||||
export { default as PrivacySettingsScreen } from './PrivacySettingsScreen';
|
||||
|
||||
// Reusable content component exports (for inline use on tablets)
|
||||
export { ContentDiscoverySettingsContent } from './ContentDiscoverySettingsScreen';
|
||||
|
|
@ -13,6 +14,7 @@ export { AppearanceSettingsContent } from './AppearanceSettingsScreen';
|
|||
export { IntegrationsSettingsContent } from './IntegrationsSettingsScreen';
|
||||
export { PlaybackSettingsContent } from './PlaybackSettingsScreen';
|
||||
export { AboutSettingsContent, AboutFooter } from './AboutSettingsScreen';
|
||||
export { PrivacySettingsContent } from './PrivacySettingsScreen';
|
||||
|
||||
// Shared UI component exports
|
||||
export { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
|
||||
|
|
|
|||
|
|
@ -188,6 +188,78 @@ async function fetchFromIntroDb(imdbId: string, season: number, episode: number)
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an IntroDB API key
|
||||
*/
|
||||
export async function verifyApiKey(apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
if (!apiKey) return false;
|
||||
|
||||
const response = await axios.post(`${INTRODB_API_URL}/submit`, {}, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 5000,
|
||||
validateStatus: (status) => true // Handle status codes manually
|
||||
});
|
||||
|
||||
// 400 means Auth passed but payload was empty/invalid -> Key is Valid
|
||||
if (response.status === 400) return true;
|
||||
|
||||
// 200/201 would also mean valid (though unexpected with empty body)
|
||||
if (response.status === 200 || response.status === 201) return true;
|
||||
|
||||
// Explicitly handle auth failures
|
||||
if (response.status === 401 || response.status === 403) return false;
|
||||
|
||||
// Log warning for unexpected states (500, 429, etc.) but fail safe
|
||||
logger.warn(`[IntroService] Verification received unexpected status: ${response.status}`);
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
logger.log('[IntroService] API Key verification failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits an intro timestamp to IntroDB
|
||||
*/
|
||||
export async function submitIntro(
|
||||
apiKey: string,
|
||||
imdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
startTime: number, // in seconds
|
||||
endTime: number // in seconds
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!apiKey) {
|
||||
logger.warn('[IntroService] Missing API key for submission');
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await axios.post(`${INTRODB_API_URL}/submit`, {
|
||||
imdb_id: imdbId,
|
||||
season,
|
||||
episode,
|
||||
start_ms: Math.round(startTime * 1000),
|
||||
end_ms: Math.round(endTime * 1000),
|
||||
}, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return response.status === 200 || response.status === 201;
|
||||
} catch (error: any) {
|
||||
logger.error('[IntroService] Error submitting intro:', error?.response?.data || error?.message || error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches skip intervals (intro, outro, recap) from available providers
|
||||
*/
|
||||
|
|
@ -283,7 +355,9 @@ export async function getIntroTimestamps(
|
|||
|
||||
export const introService = {
|
||||
getIntroTimestamps,
|
||||
getSkipTimes
|
||||
getSkipTimes,
|
||||
submitIntro,
|
||||
verifyApiKey
|
||||
};
|
||||
|
||||
export default introService;
|
||||
326
src/services/telemetryService.ts
Normal file
326
src/services/telemetryService.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* Telemetry Service
|
||||
*
|
||||
* Manages user preferences for telemetry, analytics, and error reporting.
|
||||
* Provides a central opt-out mechanism for privacy-conscious users.
|
||||
*
|
||||
* Data Collection Overview:
|
||||
* - Analytics (PostHog): Page views, interactions, session data, device metadata
|
||||
* - Error Reporting (Sentry): Crash reports, errors, breadcrumbs, device info
|
||||
* - Session Replay: Screen recordings on errors (disabled by default)
|
||||
*
|
||||
* Privacy-First Defaults:
|
||||
* - Analytics: Disabled by default
|
||||
* - Error Reporting: Enabled (no PII) by default for stability
|
||||
* - Session Replay: Disabled by default
|
||||
* - PII Collection: Disabled by default
|
||||
*/
|
||||
|
||||
import { mmkvStorage } from './mmkvStorage';
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
|
||||
// Direct MMKV access for synchronous reads (needed for Sentry beforeSend)
|
||||
const directMMKV = createMMKV();
|
||||
|
||||
// Storage keys for telemetry preferences
|
||||
const TELEMETRY_KEYS = {
|
||||
ANALYTICS_ENABLED: 'telemetry_analytics_enabled',
|
||||
ERROR_REPORTING_ENABLED: 'telemetry_error_reporting_enabled',
|
||||
SESSION_REPLAY_ENABLED: 'telemetry_session_replay_enabled',
|
||||
PII_ENABLED: 'telemetry_pii_enabled',
|
||||
TELEMETRY_INITIALIZED: 'telemetry_initialized',
|
||||
} as const;
|
||||
|
||||
// Event names for telemetry changes
|
||||
export const TELEMETRY_EVENTS = {
|
||||
SETTINGS_CHANGED: 'telemetry_settings_changed',
|
||||
} as const;
|
||||
|
||||
export interface TelemetrySettings {
|
||||
analyticsEnabled: boolean;
|
||||
errorReportingEnabled: boolean;
|
||||
sessionReplayEnabled: boolean;
|
||||
piiEnabled: boolean;
|
||||
}
|
||||
|
||||
// Default settings - Privacy-first approach
|
||||
const DEFAULT_SETTINGS: TelemetrySettings = {
|
||||
analyticsEnabled: false, // Disabled by default - user must opt-in
|
||||
errorReportingEnabled: true, // Enabled for app stability, but without PII
|
||||
sessionReplayEnabled: false, // Disabled by default - high privacy impact
|
||||
piiEnabled: false, // Never send PII by default
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronously read a setting directly from MMKV
|
||||
* Used by Sentry's beforeSend hook which runs synchronously
|
||||
*/
|
||||
function readSettingSync(key: string): string | undefined {
|
||||
try {
|
||||
return directMMKV.getString(key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error reporting is enabled (synchronous version for Sentry)
|
||||
* This is called from Sentry's beforeSend hook
|
||||
*/
|
||||
export function isErrorReportingEnabledSync(): boolean {
|
||||
const value = readSettingSync(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED);
|
||||
// Default to true if not set (privacy-safe default for stability)
|
||||
return value !== 'false';
|
||||
}
|
||||
|
||||
class TelemetryService {
|
||||
private static instance: TelemetryService;
|
||||
private settings: TelemetrySettings = { ...DEFAULT_SETTINGS };
|
||||
private initialized = false;
|
||||
|
||||
private constructor() {
|
||||
// Synchronously load settings on construction for immediate availability
|
||||
this.loadSettingsSync();
|
||||
}
|
||||
|
||||
public static getInstance(): TelemetryService {
|
||||
if (!TelemetryService.instance) {
|
||||
TelemetryService.instance = new TelemetryService();
|
||||
}
|
||||
return TelemetryService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously load settings from MMKV (for immediate availability)
|
||||
*/
|
||||
private loadSettingsSync(): void {
|
||||
try {
|
||||
const analytics = readSettingSync(TELEMETRY_KEYS.ANALYTICS_ENABLED);
|
||||
const errorReporting = readSettingSync(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED);
|
||||
const sessionReplay = readSettingSync(TELEMETRY_KEYS.SESSION_REPLAY_ENABLED);
|
||||
const pii = readSettingSync(TELEMETRY_KEYS.PII_ENABLED);
|
||||
|
||||
this.settings = {
|
||||
analyticsEnabled: analytics === 'true',
|
||||
errorReportingEnabled: errorReporting !== 'false', // Default true
|
||||
sessionReplayEnabled: sessionReplay === 'true',
|
||||
piiEnabled: pii === 'true',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TelemetryService] Error loading settings sync:', error);
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize telemetry service and load saved preferences
|
||||
*/
|
||||
async initialize(): Promise<TelemetrySettings> {
|
||||
if (this.initialized) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if this is first run (no telemetry preferences saved yet)
|
||||
const telemetryInitialized = await mmkvStorage.getItem(TELEMETRY_KEYS.TELEMETRY_INITIALIZED);
|
||||
|
||||
if (telemetryInitialized !== 'true') {
|
||||
// First run - use defaults and mark as initialized
|
||||
await this.saveSettings(DEFAULT_SETTINGS);
|
||||
await mmkvStorage.setItem(TELEMETRY_KEYS.TELEMETRY_INITIALIZED, 'true');
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
} else {
|
||||
// Load saved preferences
|
||||
const [analytics, errorReporting, sessionReplay, pii] = await Promise.all([
|
||||
mmkvStorage.getItem(TELEMETRY_KEYS.ANALYTICS_ENABLED),
|
||||
mmkvStorage.getItem(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED),
|
||||
mmkvStorage.getItem(TELEMETRY_KEYS.SESSION_REPLAY_ENABLED),
|
||||
mmkvStorage.getItem(TELEMETRY_KEYS.PII_ENABLED),
|
||||
]);
|
||||
|
||||
this.settings = {
|
||||
analyticsEnabled: analytics === 'true',
|
||||
errorReportingEnabled: errorReporting !== 'false', // Default true if not explicitly disabled
|
||||
sessionReplayEnabled: sessionReplay === 'true',
|
||||
piiEnabled: pii === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[TelemetryService] Initialized with settings:', this.settings);
|
||||
} catch (error) {
|
||||
console.error('[TelemetryService] Error initializing:', error);
|
||||
// Use defaults on error
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current telemetry settings
|
||||
*/
|
||||
getSettings(): TelemetrySettings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analytics is enabled
|
||||
*/
|
||||
isAnalyticsEnabled(): boolean {
|
||||
return this.settings.analyticsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error reporting is enabled
|
||||
*/
|
||||
isErrorReportingEnabled(): boolean {
|
||||
return this.settings.errorReportingEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session replay is enabled
|
||||
*/
|
||||
isSessionReplayEnabled(): boolean {
|
||||
return this.settings.sessionReplayEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if PII collection is enabled
|
||||
*/
|
||||
isPiiEnabled(): boolean {
|
||||
return this.settings.piiEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update analytics setting
|
||||
*/
|
||||
async setAnalyticsEnabled(enabled: boolean): Promise<void> {
|
||||
this.settings.analyticsEnabled = enabled;
|
||||
await mmkvStorage.setItem(TELEMETRY_KEYS.ANALYTICS_ENABLED, enabled.toString());
|
||||
this.emitSettingsChanged();
|
||||
console.log('[TelemetryService] Analytics enabled:', enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update error reporting setting
|
||||
*/
|
||||
async setErrorReportingEnabled(enabled: boolean): Promise<void> {
|
||||
this.settings.errorReportingEnabled = enabled;
|
||||
await mmkvStorage.setItem(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED, enabled.toString());
|
||||
this.emitSettingsChanged();
|
||||
console.log('[TelemetryService] Error reporting enabled:', enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session replay setting
|
||||
*/
|
||||
async setSessionReplayEnabled(enabled: boolean): Promise<void> {
|
||||
this.settings.sessionReplayEnabled = enabled;
|
||||
await mmkvStorage.setItem(TELEMETRY_KEYS.SESSION_REPLAY_ENABLED, enabled.toString());
|
||||
this.emitSettingsChanged();
|
||||
console.log('[TelemetryService] Session replay enabled:', enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update PII collection setting
|
||||
*/
|
||||
async setPiiEnabled(enabled: boolean): Promise<void> {
|
||||
this.settings.piiEnabled = enabled;
|
||||
await mmkvStorage.setItem(TELEMETRY_KEYS.PII_ENABLED, enabled.toString());
|
||||
this.emitSettingsChanged();
|
||||
console.log('[TelemetryService] PII enabled:', enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all telemetry (global opt-out)
|
||||
*/
|
||||
async disableAllTelemetry(): Promise<void> {
|
||||
this.settings = {
|
||||
analyticsEnabled: false,
|
||||
errorReportingEnabled: false,
|
||||
sessionReplayEnabled: false,
|
||||
piiEnabled: false,
|
||||
};
|
||||
await this.saveSettings(this.settings);
|
||||
this.emitSettingsChanged();
|
||||
console.log('[TelemetryService] All telemetry disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable recommended telemetry (error reporting only, no PII)
|
||||
*/
|
||||
async enableRecommendedTelemetry(): Promise<void> {
|
||||
this.settings = {
|
||||
analyticsEnabled: false,
|
||||
errorReportingEnabled: true,
|
||||
sessionReplayEnabled: false,
|
||||
piiEnabled: false,
|
||||
};
|
||||
await this.saveSettings(this.settings);
|
||||
this.emitSettingsChanged();
|
||||
console.log('[TelemetryService] Recommended telemetry enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to default settings
|
||||
*/
|
||||
async resetToDefaults(): Promise<void> {
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
await this.saveSettings(this.settings);
|
||||
this.emitSettingsChanged();
|
||||
console.log('[TelemetryService] Reset to defaults');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all settings to storage
|
||||
*/
|
||||
private async saveSettings(settings: TelemetrySettings): Promise<void> {
|
||||
await Promise.all([
|
||||
mmkvStorage.setItem(TELEMETRY_KEYS.ANALYTICS_ENABLED, settings.analyticsEnabled.toString()),
|
||||
mmkvStorage.setItem(TELEMETRY_KEYS.ERROR_REPORTING_ENABLED, settings.errorReportingEnabled.toString()),
|
||||
mmkvStorage.setItem(TELEMETRY_KEYS.SESSION_REPLAY_ENABLED, settings.sessionReplayEnabled.toString()),
|
||||
mmkvStorage.setItem(TELEMETRY_KEYS.PII_ENABLED, settings.piiEnabled.toString()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event when settings change
|
||||
*/
|
||||
private emitSettingsChanged(): void {
|
||||
DeviceEventEmitter.emit(TELEMETRY_EVENTS.SETTINGS_CHANGED, this.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Sentry configuration based on current settings
|
||||
*/
|
||||
getSentryConfig(): {
|
||||
enabled: boolean;
|
||||
sendDefaultPii: boolean;
|
||||
replaysSessionSampleRate: number;
|
||||
replaysOnErrorSampleRate: number;
|
||||
} {
|
||||
return {
|
||||
enabled: this.settings.errorReportingEnabled,
|
||||
sendDefaultPii: this.settings.piiEnabled,
|
||||
replaysSessionSampleRate: this.settings.sessionReplayEnabled ? 0.1 : 0,
|
||||
replaysOnErrorSampleRate: this.settings.sessionReplayEnabled ? 1.0 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PostHog configuration based on current settings
|
||||
*/
|
||||
getPostHogConfig(): {
|
||||
enabled: boolean;
|
||||
} {
|
||||
return {
|
||||
enabled: this.settings.analyticsEnabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const telemetryService = TelemetryService.getInstance();
|
||||
export default telemetryService;
|
||||
45
src/types/react-native-background-downloader.d.ts
vendored
Normal file
45
src/types/react-native-background-downloader.d.ts
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
declare module '@kesha-antonov/react-native-background-downloader' {
|
||||
export interface DownloadTask {
|
||||
id: string;
|
||||
percent: number;
|
||||
bytesWritten: number;
|
||||
totalBytes: number;
|
||||
state: 'DOWNLOADING' | 'PAUSED' | 'DONE' | 'STOPPED' | 'FAILED';
|
||||
pause(): void;
|
||||
resume(): void;
|
||||
stop(): void;
|
||||
start(): void;
|
||||
begin(handler: (expectedBytes: number) => void): DownloadTask;
|
||||
progress(handler: (percent: number, bytesWritten: number, totalBytes: number) => void): DownloadTask;
|
||||
done(handler: () => void): DownloadTask;
|
||||
error(handler: (error: any) => void): DownloadTask;
|
||||
}
|
||||
|
||||
export const directories: {
|
||||
documents: string;
|
||||
};
|
||||
|
||||
export function download(options: { id: string; url: string; destination: string; headers?: Record<string, string>; metadata?: any }): DownloadTask;
|
||||
|
||||
export function checkForExistingDownloads(): Promise<DownloadTask[]>;
|
||||
|
||||
// Legacy exports to match library behavior
|
||||
export function completeHandler(id: string): void;
|
||||
|
||||
export function createDownloadTask(options: { id: string; url: string; destination: string; headers?: Record<string, string>; metadata?: any }): DownloadTask;
|
||||
|
||||
export function getExistingDownloadTasks(): Promise<DownloadTask[]>;
|
||||
|
||||
const RNBackgroundDownloader: {
|
||||
download(options: { id: string; url: string; destination: string; headers?: Record<string, string>; metadata?: any }): DownloadTask;
|
||||
checkForExistingDownloads(): Promise<DownloadTask[]>;
|
||||
directories: {
|
||||
documents: string;
|
||||
};
|
||||
completeHandler(id: string): void;
|
||||
createDownloadTask(options: { id: string; url: string; destination: string; headers?: Record<string, string>; metadata?: any }): DownloadTask;
|
||||
getExistingDownloadTasks(): Promise<DownloadTask[]>;
|
||||
};
|
||||
|
||||
export default RNBackgroundDownloader;
|
||||
}
|
||||
Loading…
Reference in a new issue