diff --git a/ALPHA_BUILD_2_ANNOUNCEMENT.md b/ALPHA_BUILD_2_ANNOUNCEMENT.md new file mode 100644 index 00000000..6c9054bb --- /dev/null +++ b/ALPHA_BUILD_2_ANNOUNCEMENT.md @@ -0,0 +1,10 @@ +# Nuvio Alpha Build 2 + +This is the second alpha release of Nuvio! + +## What's New +- **Intro Submission:** You can now submit intro timestamps directly to IntroDB! +- **Bug Fixes:** Various improvements and stability fixes. + +## Installation +Download the attached APK and install it on your Android device. diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 54221ef1..72007063 100644 --- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -442,7 +442,7 @@ public class ReactExoplayerView extends FrameLayout implements private void initializePlayerControl() { exoPlayerView.setPlayer(player); - + exoPlayerView.setControllerVisibilityListener(visibility -> { boolean isVisible = visibility == View.VISIBLE; eventEmitter.onControlsVisibilityChange.invoke(isVisible); @@ -458,19 +458,19 @@ public class ReactExoplayerView extends FrameLayout implements private void updateControllerConfig() { if (exoPlayerView == null) return; - + exoPlayerView.setControllerShowTimeoutMs(5000); - + exoPlayerView.setControllerAutoShow(true); exoPlayerView.setControllerHideOnTouch(true); - + updateControllerVisibility(); } private void updateControllerVisibility() { if (exoPlayerView == null) return; - + exoPlayerView.setUseController(controls && !controlsConfig.getHideFullscreen()); } @@ -520,7 +520,7 @@ public class ReactExoplayerView extends FrameLayout implements /** * Update the layout - * + * * @param view view needs to update layout * * This is a workaround for the open bug in react-native: subtitleConfigurations = buildSubtitleConfigurations(); if (subtitleConfigurations != null) { mediaItemBuilder.setSubtitleConfigurations(subtitleConfigurations); } - + if (source.getAdsProps() != null) { Uri adTagUrl = source.getAdsProps().getAdTagUrl(); if (adTagUrl != null) { @@ -1205,19 +1205,19 @@ public class ReactExoplayerView extends FrameLayout implements label += " (" + track.getLanguage() + ")"; } } - + MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder( track.getUri()) .setId(trackId) .setMimeType(track.getType()) .setLabel(label) .setRoleFlags(C.ROLE_FLAG_SUBTITLE); - + // Set language if available if (track.getLanguage() != null && !track.getLanguage().isEmpty()) { configBuilder.setLanguage(track.getLanguage()); } - + // Set selection flags - make first track default if no specific track is // selected if (trackIndex == 0 && (textTrackType == null || "disabled".equals(textTrackType))) { @@ -1225,10 +1225,10 @@ public class ReactExoplayerView extends FrameLayout implements } else { configBuilder.setSelectionFlags(0); } - + MediaItem.SubtitleConfiguration subtitleConfiguration = configBuilder.build(); subtitleConfigurations.add(subtitleConfiguration); - + DebugLog.d(TAG, "Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")"); trackIndex++; @@ -1549,7 +1549,7 @@ public class ReactExoplayerView extends FrameLayout implements } eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height, audioTracks, textTracks, videoTracks, trackId); - + updateSubtitleButtonVisibility(); }); return; @@ -1589,19 +1589,19 @@ public class ReactExoplayerView extends FrameLayout implements for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { TrackGroup group = groups.get(groupIndex); Format format = group.getFormat(0); - + // Check if this specific group is the currently selected one boolean isSelected = false; if (selection != null && selection.getTrackGroup() == group) { isSelected = true; } - + Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group); audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); audioTrack.setSelected(isSelected); audioTracks.add(audioTrack); } - + return audioTracks; } @@ -1736,13 +1736,13 @@ public class ReactExoplayerView extends FrameLayout implements if (trackSelector == null) { return textTracks; } - + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT); if (info == null || index == C.INDEX_UNSET) { return textTracks; } - + TrackSelectionArray selectionArray = player.getCurrentTrackSelections(); TrackSelection selection = selectionArray.get(C.TRACK_TYPE_TEXT); TrackGroupArray groups = info.getTrackGroups(index); @@ -1752,12 +1752,12 @@ public class ReactExoplayerView extends FrameLayout implements for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { Format format = group.getFormat(trackIndex); Track textTrack = exoplayerTrackToGenericTrack(format, trackIndex, selection, group); - + boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-"); boolean isSelected = isTrackSelected(selection, group, trackIndex); - + textTrack.setIndex(textTracks.size()); - + if (textTrack.getTitle() == null || textTrack.getTitle().isEmpty()) { if (isExternal) { textTrack.setTitle("External " + (trackIndex + 1)); @@ -1765,7 +1765,7 @@ public class ReactExoplayerView extends FrameLayout implements textTrack.setTitle("Track " + (textTracks.size() + 1)); } } - + textTracks.add(textTrack); } } @@ -1785,11 +1785,11 @@ public class ReactExoplayerView extends FrameLayout implements } TrackGroupArray groups = info.getTrackGroups(index); - + for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { TrackGroup group = groups.get(groupIndex); Format format = group.getFormat(0); - + // Create track without trying to determine selection status Track track = new Track(); track.setIndex(groupIndex); @@ -1799,10 +1799,10 @@ public class ReactExoplayerView extends FrameLayout implements if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); - + tracks.add(track); } - + DebugLog.d(TAG, "getBasicAudioTrackInfo: returning " + tracks.size() + " audio tracks (no selection status)"); return tracks; } @@ -1812,29 +1812,29 @@ public class ReactExoplayerView extends FrameLayout implements if (trackSelector == null) { return textTracks; } - + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT); if (info == null || index == C.INDEX_UNSET) { return textTracks; } - + TrackGroupArray groups = info.getTrackGroups(index); for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { TrackGroup group = groups.get(groupIndex); for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { Format format = group.getFormat(trackIndex); - + Track textTrack = new Track(); textTrack.setIndex(textTracks.size()); if (format.sampleMimeType != null) textTrack.setMimeType(format.sampleMimeType); if (format.language != null) textTrack.setLanguage(format.language); - + boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-"); - + if (format.label != null && !format.label.isEmpty()) { textTrack.setTitle(format.label); } else if (isExternal) { @@ -1842,7 +1842,7 @@ public class ReactExoplayerView extends FrameLayout implements } else { textTrack.setTitle("Track " + (textTracks.size() + 1)); } - + textTrack.setSelected(false); // Don't report selection status - let PlayerView handle it textTracks.add(textTrack); } @@ -1912,12 +1912,12 @@ public class ReactExoplayerView extends FrameLayout implements @Override public void onTracksChanged(@NonNull Tracks tracks) { DebugLog.d(TAG, "onTracksChanged called - updating track information, controls=" + controls); - + if (controls) { ArrayList textTracks = getBasicTextTrackInfo(); - ArrayList audioTracks = getBasicAudioTrackInfo(); + ArrayList audioTracks = getBasicAudioTrackInfo(); ArrayList 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 textTracks = getTextTrackInfo(); ArrayList audioTracks = getAudioTrackInfo(); ArrayList 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 adTagParameters = adsProps.getAdTagParameters(); if (adTagParameters != null && !adTagParameters.isEmpty()) { for (Map.Entry 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; } } diff --git a/package-lock.json b/package-lock.json index 4a2e3040..bb70659e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1333166a..e5f102df 100644 --- a/package.json +++ b/package.json @@ -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 } diff --git a/patches/react-native-bottom-tabs+1.1.0.patch b/patches/react-native-bottom-tabs+1.1.0.patch new file mode 100644 index 00000000..3ccc3ef9 --- /dev/null +++ b/patches/react-native-bottom-tabs+1.1.0.patch @@ -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 diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index e22c2847..12a843e3 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -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'; @@ -917,6 +918,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} @@ -932,6 +934,7 @@ const AndroidVideoPlayer: React.FC = () => { onSwitchToMPV={handleManualSwitchToMPV} useExoPlayer={useExoPlayer} isBuffering={playerState.isBuffering} + imdbId={imdbId} /> { setHoldToSpeedValue={speedControl.setHoldToSpeedValue} /> + modals.setShowSubmitIntroModal(false)} + currentTime={playerState.currentTime} + imdbId={imdbId} + season={season} + episode={episode} + /> + { setShowAudioModal={modals.setShowAudioModal} setShowSubtitleModal={modals.setShowSubtitleModal} setShowSpeedModal={modals.setShowSpeedModal} + setShowSubmitIntroModal={modals.setShowSubmitIntroModal} isSubtitleModalOpen={modals.showSubtitleModal} setShowSourcesModal={modals.setShowSourcesModal} setShowEpisodesModal={type === 'series' ? modals.setShowEpisodesModal : undefined} @@ -881,6 +883,7 @@ const KSPlayerCore: React.FC = () => { allowsAirPlay={allowsAirPlay} onAirPlayPress={() => ksPlayerRef.current?.showAirPlayPicker()} isBuffering={isBuffering} + imdbId={imdbId} /> )} @@ -998,6 +1001,15 @@ const KSPlayerCore: React.FC = () => { setHoldToSpeedValue={speedControl.setHoldToSpeedValue} /> + modals.setShowSubmitIntroModal(false)} + currentTime={currentTime} + imdbId={imdbId} + season={season} + episode={episode} + /> + 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 = ({ @@ -85,6 +91,7 @@ export const PlayerControls: React.FC = ({ setShowAudioModal, setShowSubtitleModal, setShowSpeedModal, + setShowSubmitIntroModal, isSubtitleModalOpen, setShowSourcesModal, setShowEpisodesModal, @@ -100,10 +107,17 @@ export const PlayerControls: React.FC = ({ 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 = ({ /> + {/* Submit Intro Button */} + {season !== undefined && episode !== undefined && settings.introSubmitEnabled && settings.introDbApiKey && ( + + + + )} + {/* Right Side: Episodes Button */} {setShowEpisodesModal && ( { 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(null); @@ -31,6 +32,7 @@ export const usePlayerModals = () => { showErrorModal, setShowErrorModal, showSubtitleLanguageModal, setShowSubtitleLanguageModal, showCastDetails, setShowCastDetails, + showSubmitIntroModal, setShowSubmitIntroModal, selectedEpisodeForStreams, setSelectedEpisodeForStreams, errorDetails, setErrorDetails, selectedCastMember, setSelectedCastMember diff --git a/src/components/player/modals/SubmitIntroModal.tsx b/src/components/player/modals/SubmitIntroModal.tsx new file mode 100644 index 00000000..42873ba0 --- /dev/null +++ b/src/components/player/modals/SubmitIntroModal.tsx @@ -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 = ({ + 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 ( + + + + + + + + + Submit Intro Timestamp + + + + + + + {/* Start Time Input */} + + + Start Time (MM:SS) + + + + + Capture + + + + {/* End Time Input */} + + + End Time (MM:SS) + + + + + Capture + + + + {/* Action Buttons */} + + + Cancel + + + + {isSubmitting ? ( + + ) : ( + <> + + Submit + + )} + + + + + + + ); +}; + +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', + }, +}); diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index c285a1de..befff6bb 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index fbd6deb4..9dca455b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -417,7 +417,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", diff --git a/src/screens/settings/PlaybackSettingsScreen.tsx b/src/screens/settings/PlaybackSettingsScreen.tsx index 8cd69710..fb549e1e 100644 --- a/src/screens/settings/PlaybackSettingsScreen.tsx +++ b/src/screens/settings/PlaybackSettingsScreen.tsx @@ -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 } 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,7 @@ 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'; const { width } = Dimensions.get('window'); @@ -77,6 +78,16 @@ export const PlaybackSettingsContent: React.FC = ( const config = useRealtimeConfig(); const [introDbLogoXml, setIntroDbLogoXml] = useState(null); + const [apiKeyInput, setApiKeyInput] = useState(settings?.introDbApiKey || ''); + + useEffect(() => { + setApiKeyInput(settings?.introDbApiKey || ''); + }, [settings?.introDbApiKey]); + + const handleApiKeySubmit = () => { + updateSetting('introDbApiKey', apiKeyInput); + toastService.success(t('settings.items.api_key_saved', { defaultValue: 'API Key Saved' })); + }; useEffect(() => { let cancelled = false; @@ -225,6 +236,49 @@ export const PlaybackSettingsContent: React.FC = ( /> + {/* IntroDB Contribution Section */} + + ( + updateSetting('introSubmitEnabled', value)} + /> + )} + isLast={!settings?.introSubmitEnabled} + isTablet={isTablet} + /> + + {settings?.introSubmitEnabled && ( + + + {t('settings.items.introdb_api_key', { defaultValue: 'INTRODB API KEY' })} + + + + + + + + + )} + + {/* Audio & Subtitle Preferences */} { + 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 */ @@ -266,7 +304,8 @@ export async function getIntroTimestamps( export const introService = { getIntroTimestamps, - getSkipTimes + getSkipTimes, + submitIntro }; export default introService; diff --git a/src/types/react-native-background-downloader.d.ts b/src/types/react-native-background-downloader.d.ts new file mode 100644 index 00000000..b220fc3f --- /dev/null +++ b/src/types/react-native-background-downloader.d.ts @@ -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; metadata?: any }): DownloadTask; + + export function checkForExistingDownloads(): Promise; + + // Legacy exports to match library behavior + export function completeHandler(id: string): void; + + export function createDownloadTask(options: { id: string; url: string; destination: string; headers?: Record; metadata?: any }): DownloadTask; + + export function getExistingDownloadTasks(): Promise; + + const RNBackgroundDownloader: { + download(options: { id: string; url: string; destination: string; headers?: Record; metadata?: any }): DownloadTask; + checkForExistingDownloads(): Promise; + directories: { + documents: string; + }; + completeHandler(id: string): void; + createDownloadTask(options: { id: string; url: string; destination: string; headers?: Record; metadata?: any }): DownloadTask; + getExistingDownloadTasks(): Promise; + }; + + export default RNBackgroundDownloader; +}