diff --git a/README.md b/README.md index cd7f51b7..671cef36 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ [![License][license-shield]][license-url]

- A modern media hub built with React Native and Expo. + A modern media hub for Android and iOS built with React Native and Expo.
- Stremio Addon ecosystem • Cross-platform • Offline metadata & sync + Stremio Addon ecosystem • Cross-platform

## About -Nuvio Media Hub is a cross-platform app for managing, discovering, and streaming your media via a flexible addon ecosystem. Built with React Native and Expo. +Nuvio Media Hub is a cross-platform app for managing and discovering media, with a playback-focused interface that can integrate with the Stremio addon ecosystem through user-installed extensions. ## Installation @@ -30,9 +30,9 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio ### iOS -* [TestFlight](https://testflight.apple.com/join/QkKMGRqp) -* [AltStore](https://tinyurl.com/NuvioAltstore) -* [SideStore](https://tinyurl.com/NuvioSidestore) +- [TestFlight](https://testflight.apple.com/join/QkKMGRqp) +- [AltStore](https://tinyurl.com/NuvioAltstore) +- [SideStore](https://tinyurl.com/NuvioSidestore) **Manual source:** `https://raw.githubusercontent.com/tapframe/NuvioStreaming/main/nuvio-source.json` @@ -41,7 +41,8 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio ```bash git clone https://github.com/tapframe/NuvioStreaming.git cd NuvioStreaming -npm install +npm install --legacy-peer-deps +npx expo prebuild npx expo run:android # or npx expo run:ios @@ -49,15 +50,17 @@ npx expo run:ios ## Legal & DMCA -Nuvio functions solely as a client-side interface for browsing metadata and playing media files provided by user-installed extensions. It does not host, store, or distribute any media content. +Nuvio functions solely as a client-side interface for browsing metadata and playing media provided by user-installed extensions and/or user-provided sources. It is intended for content the user owns or is otherwise authorized to access. + +Nuvio is not affiliated with any third-party extensions, catalogs, sources, or content providers. It does not host, store, or distribute any media content. For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://tapframe.github.io/NuvioStreaming/#legal)**. ## Built With -* React Native -* Expo -* TypeScript +- React Native +- Expo +- TypeScript ## Star History diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index f2f9cd4f..bb2a4e4a 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -33,9 +33,19 @@ class MPVView @JvmOverloads constructor( // GPU mode setting: 'gpu', 'gpu-next' (default: gpu) var gpuMode: String = "gpu" + // GLSL shaders setting (for upscalers) + private var glslShadersVal: String? = null + // Flag to track if onLoad has been fired (prevents multiple fires for HLS streams) private var hasLoadEventFired: Boolean = false + // Video Equalizer state + private var brightnessVal: Int = 0 + private var contrastVal: Int = 0 + private var saturationVal: Int = 0 + private var gammaVal: Int = 0 + private var hueVal: Int = 0 + // Event listener for React Native var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null @@ -78,6 +88,7 @@ class MPVView @JvmOverloads constructor( MPVLib.addObserver(this) MPVLib.setPropertyString("android-surface-size", "${width}x${height}") observeProperties() + applyPostInitSettings() isMpvInitialized = true // If a data source was set before surface was ready, load it now @@ -477,6 +488,69 @@ class MPVView @JvmOverloads constructor( } } + // Video Equalizer Methods + + fun setBrightness(value: Int) { + brightnessVal = value + if (isMpvInitialized) { + Log.d(TAG, "Setting brightness: $value") + MPVLib.setPropertyDouble("brightness", value.toDouble()) + } + } + + fun setContrast(value: Int) { + contrastVal = value + if (isMpvInitialized) { + Log.d(TAG, "Setting contrast: $value") + MPVLib.setPropertyDouble("contrast", value.toDouble()) + } + } + + fun setSaturation(value: Int) { + saturationVal = value + if (isMpvInitialized) { + Log.d(TAG, "Setting saturation: $value") + MPVLib.setPropertyDouble("saturation", value.toDouble()) + } + } + + fun setGamma(value: Int) { + gammaVal = value + if (isMpvInitialized) { + Log.d(TAG, "Setting gamma: $value") + MPVLib.setPropertyDouble("gamma", value.toDouble()) + } + } + + fun setHue(value: Int) { + hueVal = value + if (isMpvInitialized) { + Log.d(TAG, "Setting hue: $value") + MPVLib.setPropertyDouble("hue", value.toDouble()) + } + } + + fun setGlslShaders(shaders: String?) { + glslShadersVal = shaders + if (isMpvInitialized) { + Log.d(TAG, "Setting glsl-shaders: $shaders") + MPVLib.setPropertyString("glsl-shaders", shaders ?: "") + } + } + + private fun applyPostInitSettings() { + Log.d(TAG, "Applying post-init settings: B=$brightnessVal, C=$contrastVal, S=$saturationVal, G=$gammaVal, H=$hueVal, Shaders=$glslShadersVal") + MPVLib.setPropertyDouble("brightness", brightnessVal.toDouble()) + MPVLib.setPropertyDouble("contrast", contrastVal.toDouble()) + MPVLib.setPropertyDouble("saturation", saturationVal.toDouble()) + MPVLib.setPropertyDouble("gamma", gammaVal.toDouble()) + MPVLib.setPropertyDouble("hue", hueVal.toDouble()) + + glslShadersVal?.let { + MPVLib.setPropertyString("glsl-shaders", it) + } + } + // MPVLib.EventObserver implementation override fun eventProperty(property: String) { diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt index 5054e04c..0f9ff291 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt @@ -191,6 +191,11 @@ class MpvPlayerViewManager( view.gpuMode = gpuMode ?: "gpu" } + @ReactProp(name = "glslShaders") + fun setGlslShaders(view: MPVView, glslShaders: String?) { + view.setGlslShaders(glslShaders) + } + // Subtitle Styling Props @ReactProp(name = "subtitleSize", defaultInt = 48) @@ -238,4 +243,31 @@ class MpvPlayerViewManager( fun setSubtitleAlignment(view: MPVView, align: String?) { view.setSubtitleAlignment(align ?: "center") } + + // Video Equalizer Props + + @ReactProp(name = "brightness", defaultInt = 0) + fun setBrightness(view: MPVView, brightness: Int) { + view.setBrightness(brightness) + } + + @ReactProp(name = "contrast", defaultInt = 0) + fun setContrast(view: MPVView, contrast: Int) { + view.setContrast(contrast) + } + + @ReactProp(name = "saturation", defaultInt = 0) + fun setSaturation(view: MPVView, saturation: Int) { + view.setSaturation(saturation) + } + + @ReactProp(name = "gamma", defaultInt = 0) + fun setGamma(view: MPVView, gamma: Int) { + view.setGamma(gamma) + } + + @ReactProp(name = "hue", defaultInt = 0) + fun setHue(view: MPVView, hue: Int) { + view.setHue(hue) + } } diff --git a/assets/shaders/shaders_new.zip b/assets/shaders/shaders_new.zip new file mode 100644 index 00000000..4cd610c7 Binary files /dev/null and b/assets/shaders/shaders_new.zip differ diff --git a/package-lock.json b/package-lock.json index bb70659e..4d76900f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "expo-web-browser": "~15.0.8", "i18next": "^25.7.3", "intl-pluralrules": "^2.0.1", + "jszip": "^3.10.1", "lodash": "^4.17.21", "lottie-react-native": "~7.3.1", "posthog-react-native": "^4.4.0", @@ -7766,6 +7767,12 @@ "node": ">=16.x" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8435,6 +8442,18 @@ "node": ">=0.6.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/klaw-sync": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", @@ -8486,6 +8505,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lighthouse-logger": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", diff --git a/package.json b/package.json index e5f102df..04723a85 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "expo-web-browser": "~15.0.8", "i18next": "^25.7.3", "intl-pluralrules": "^2.0.1", + "jszip": "^3.10.1", "lodash": "^4.17.21", "lottie-react-native": "~7.3.1", "posthog-react-native": "^4.4.0", diff --git a/plugins/mpv-bridge/android/mpv/MPVView.kt b/plugins/mpv-bridge/android/mpv/MPVView.kt index 6f5727e5..c54af23c 100644 --- a/plugins/mpv-bridge/android/mpv/MPVView.kt +++ b/plugins/mpv-bridge/android/mpv/MPVView.kt @@ -247,6 +247,27 @@ class MPVView @JvmOverloads constructor( } } + // Video EQ Properties + fun setBrightness(value: Int) { + if (isMpvInitialized) MPVLib.setPropertyInt("brightness", value) + } + + fun setContrast(value: Int) { + if (isMpvInitialized) MPVLib.setPropertyInt("contrast", value) + } + + fun setSaturation(value: Int) { + if (isMpvInitialized) MPVLib.setPropertyInt("saturation", value) + } + + fun setGamma(value: Int) { + if (isMpvInitialized) MPVLib.setPropertyInt("gamma", value) + } + + fun setHue(value: Int) { + if (isMpvInitialized) MPVLib.setPropertyInt("hue", value) + } + fun setSubtitleTrack(trackId: Int) { Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized") if (isMpvInitialized) { @@ -270,6 +291,21 @@ class MPVView @JvmOverloads constructor( } } + fun setGlslShaders(paths: String) { + Log.d(TAG, "setGlslShaders called with paths: $paths") + if (isMpvInitialized) { + if (paths.isEmpty()) { + Log.d(TAG, "Clearing GLSL shaders") + MPVLib.setPropertyString("glsl-shaders", "") + } else { + Log.d(TAG, "Setting GLSL shaders") + // MPV expects a list of paths string like "path1,path2" or specialized list commands + // Using setPropertyString on "glsl-shaders" usually overwrites the list + MPVLib.setPropertyString("glsl-shaders", paths) + } + } + } + fun setResizeMode(mode: String) { Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized") if (isMpvInitialized) { diff --git a/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt b/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt index 27d48527..6cec01c6 100644 --- a/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt +++ b/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt @@ -180,4 +180,35 @@ class MpvPlayerViewManager( view.setHeaders(null) } } + + // Video EQ Props + @ReactProp(name = "brightness", defaultInt = 0) + fun setBrightness(view: MPVView, value: Int) { + view.setBrightness(value) + } + + @ReactProp(name = "contrast", defaultInt = 0) + fun setContrast(view: MPVView, value: Int) { + view.setContrast(value) + } + + @ReactProp(name = "saturation", defaultInt = 0) + fun setSaturation(view: MPVView, value: Int) { + view.setSaturation(value) + } + + @ReactProp(name = "gamma", defaultInt = 0) + fun setGamma(view: MPVView, value: Int) { + view.setGamma(value) + } + + @ReactProp(name = "hue", defaultInt = 0) + fun setHue(view: MPVView, value: Int) { + view.setHue(value) + } + + @ReactProp(name = "glslShaders") + fun setGlslShaders(view: MPVView, paths: String?) { + view.setGlslShaders(paths ?: "") + } } diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 12a843e3..340c0329 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -40,6 +40,7 @@ import { SourcesModal } from './modals/SourcesModal'; import { EpisodesModal } from './modals/EpisodesModal'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; import { ErrorModal } from './modals/ErrorModal'; +import VisualEnhancementModal from './modals/VisualEnhancementModal'; import { CustomSubtitles } from './subtitles/CustomSubtitles'; import ParentalGuideOverlay from './overlays/ParentalGuideOverlay'; import SkipIntroButton from './overlays/SkipIntroButton'; @@ -57,6 +58,8 @@ import { styles } from './utils/playerStyles'; import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils'; import { storageService } from '../../services/storageService'; import stremioService from '../../services/stremioService'; +import { shaderService, ShaderMode } from '../../services/shaderService'; +import { visualEnhancementService, VideoSettings } from '../../services/colorProfileService'; import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes'; import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils'; import { useTheme } from '../../contexts/ThemeContext'; @@ -81,7 +84,7 @@ const AndroidVideoPlayer: React.FC = () => { const playerState = usePlayerState(); const modals = usePlayerModals(); const speedControl = useSpeedControl(); - const { settings } = useSettings(); + const { settings, updateSetting } = useSettings(); const videoRef = useRef(null); const mpvPlayerRef = useRef(null); @@ -145,6 +148,72 @@ const AndroidVideoPlayer: React.FC = () => { // Subtitle sync modal state const [showSyncModal, setShowSyncModal] = useState(false); + // Shader / Video Enhancement State + const [showEnhancementModal, setShowEnhancementModal] = useState(false); + const [shaderMode, setShaderModeState] = useState(settings.defaultShaderMode || 'none'); + const [glslShaders, setGlslShaders] = useState(''); + + // Color Profile State + const [activeProfile, setActiveProfile] = useState('natural'); + const [customSettings, setCustomSettings] = useState(visualEnhancementService.getCustomSettings()); + const [currentVideoSettings, setCurrentVideoSettings] = useState(visualEnhancementService.getCurrentSettings()); + + // Initialize shader and color services + useEffect(() => { + // Shaders + shaderService.initialize().catch(e => { + logger.error('[AndroidVideoPlayer] Failed to init shader service', e); + }); + + // Visual Enhancements + setActiveProfile(visualEnhancementService.getActiveProfile()); + setCustomSettings(visualEnhancementService.getCustomSettings()); + setCurrentVideoSettings(visualEnhancementService.getCurrentSettings()); + }, []); + + // Initialize shader config from persisted setting. + // Respect global shader toggle at runtime. + useEffect(() => { + if (!settings.enableShaders) { + setGlslShaders(''); + return; + } + + if (settings.defaultShaderMode && settings.defaultShaderMode !== 'none') { + const config = shaderService.getShaderConfig(settings.defaultShaderMode, settings.shaderProfile as any || 'MID-END'); + setGlslShaders(config); + } else { + setGlslShaders(''); + } + }, [settings.enableShaders, settings.defaultShaderMode, settings.shaderProfile]); + + const setShaderMode = useCallback((mode: ShaderMode) => { + setShaderModeState(mode); + updateSetting('defaultShaderMode', mode); // Persist selection + if (!settings.enableShaders) { + setGlslShaders(''); + return; + } + + const config = shaderService.getShaderConfig(mode, settings.shaderProfile as any || 'MID-END'); + setGlslShaders(config); + // Don't close modal here, let user close it + // setShowEnhancementModal(false); + }, [settings.enableShaders, settings.shaderProfile, updateSetting]); + + const handleSetProfile = useCallback(async (profile: string) => { + await visualEnhancementService.setProfile(profile); + setActiveProfile(profile); + setCurrentVideoSettings(visualEnhancementService.getCurrentSettings()); + }, []); + + const handleUpdateCustomSettings = useCallback(async (newSettings: Partial) => { + await visualEnhancementService.updateCustomSettings(newSettings); + setCustomSettings(visualEnhancementService.getCustomSettings()); + setActiveProfile('custom'); + setCurrentVideoSettings(visualEnhancementService.getCurrentSettings()); + }, []); + // Track auto-selection ref to prevent duplicate selections const hasAutoSelectedTracks = useRef(false); @@ -814,6 +883,13 @@ const AndroidVideoPlayer: React.FC = () => { screenDimensions={playerState.screenDimensions} decoderMode={settings.decoderMode} gpuMode={settings.gpuMode} + glslShaders={glslShaders} + // Color Profile Props + brightness={currentVideoSettings.brightness} + contrast={currentVideoSettings.contrast} + saturation={currentVideoSettings.saturation} + gamma={currentVideoSettings.gamma} + hue={currentVideoSettings.hue} // Dual video engine props useExoPlayer={useExoPlayer} onCodecError={handleCodecError} @@ -922,6 +998,7 @@ const AndroidVideoPlayer: React.FC = () => { isSubtitleModalOpen={modals.showSubtitleModal} setShowSourcesModal={modals.setShowSourcesModal} setShowEpisodesModal={type === 'series' ? modals.setShowEpisodesModal : undefined} + setShowEnhancementModal={setShowEnhancementModal} onSliderValueChange={(val) => { playerState.isDragging.current = true; }} onSlidingStart={() => { playerState.isDragging.current = true; }} onSlidingComplete={(val) => { @@ -1098,6 +1175,17 @@ const AndroidVideoPlayer: React.FC = () => { onSelectStream={(stream) => handleSelectStream(stream)} /> + setShowEnhancementModal(false)} + shaderMode={shaderMode} + setShaderMode={setShaderMode} + activeProfile={activeProfile} + setProfile={handleSetProfile} + customSettings={customSettings} + updateCustomSettings={handleUpdateCustomSettings} + /> + void; decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+'; gpuMode?: 'gpu' | 'gpu-next'; + glslShaders?: string; + // Video EQ Props + brightness?: number; + contrast?: number; + saturation?: number; + gamma?: number; + hue?: number; // Subtitle Styling subtitleSize?: number; subtitleColor?: string; @@ -121,6 +128,12 @@ const MpvPlayer = forwardRef((props, ref) => { onTracksChanged={handleTracksChanged} decoderMode={props.decoderMode ?? 'auto'} gpuMode={props.gpuMode ?? 'gpu'} + glslShaders={props.glslShaders} + brightness={props.brightness ?? 0} + contrast={props.contrast ?? 0} + saturation={props.saturation ?? 0} + gamma={props.gamma ?? 0} + hue={props.hue ?? 0} // Subtitle Styling subtitleSize={props.subtitleSize ?? 48} subtitleColor={props.subtitleColor ?? '#FFFFFF'} diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 57321d56..b033d640 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -50,6 +50,13 @@ interface VideoSurfaceProps { selectedTextTrack?: SelectedTrack; decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+'; gpuMode?: 'gpu' | 'gpu-next'; + glslShaders?: string; + // Video EQ Props + brightness?: number; + contrast?: number; + saturation?: number; + gamma?: number; + hue?: number; // Dual Engine Props useExoPlayer?: boolean; @@ -105,6 +112,12 @@ export const VideoSurface: React.FC = ({ selectedTextTrack, decoderMode, gpuMode, + glslShaders, + brightness, + contrast, + saturation, + gamma, + hue, // Dual Engine useExoPlayer = true, onCodecError, @@ -401,6 +414,12 @@ export const VideoSurface: React.FC = ({ onTracksChanged={onTracksChanged} decoderMode={decoderMode} gpuMode={gpuMode} + glslShaders={glslShaders} + brightness={brightness} + contrast={contrast} + saturation={saturation} + gamma={gamma} + hue={hue} subtitleSize={subtitleSize} subtitleColor={subtitleColor} subtitleBackgroundOpacity={subtitleBackgroundOpacity} diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 29861710..aee40ad7 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -45,6 +45,7 @@ interface PlayerControlsProps { isSubtitleModalOpen?: boolean; setShowSourcesModal?: (show: boolean) => void; setShowEpisodesModal?: (show: boolean) => void; + setShowEnhancementModal?: (show: boolean) => void; // Slider-specific props onSliderValueChange: (value: number) => void; onSlidingStart: () => void; @@ -95,6 +96,7 @@ export const PlayerControls: React.FC = ({ isSubtitleModalOpen, setShowSourcesModal, setShowEpisodesModal, + setShowEnhancementModal, onSliderValueChange, onSlidingStart, onSlidingComplete, @@ -391,6 +393,21 @@ export const PlayerControls: React.FC = ({ /> )} + + {/* Video Enhancement Button (Top Access) */} + {playerBackend === 'MPV' && setShowEnhancementModal && settings.enableShaders && ( + setShowEnhancementModal(true)} + > + + + )} + @@ -592,7 +609,7 @@ export const PlayerControls: React.FC = ({ {/* Center Buttons Container with rounded background - wraps all buttons */} - {/* Left Side: Aspect Ratio Button */} + {/* Aspect Ratio Button */} @@ -605,16 +622,6 @@ export const PlayerControls: React.FC = ({ - {/* Change Source Button */} - {setShowSourcesModal && ( - setShowSourcesModal(true)} - > - - - )} - {/* Playback Speed Button */} setShowSpeedModal(true)}> @@ -633,6 +640,16 @@ export const PlayerControls: React.FC = ({ /> + {/* Change Source Button */} + {setShowSourcesModal && ( + setShowSourcesModal(true)} + > + + + )} + {/* Submit Intro Button */} {season !== undefined && episode !== undefined && settings.introSubmitEnabled && settings.introDbApiKey && ( = ({ )} - {/* Right Side: Episodes Button */} + {/* Episodes Button */} {setShowEpisodesModal && ( = ({ + ); diff --git a/src/components/player/modals/VisualEnhancementModal.tsx b/src/components/player/modals/VisualEnhancementModal.tsx new file mode 100644 index 00000000..8b5263eb --- /dev/null +++ b/src/components/player/modals/VisualEnhancementModal.tsx @@ -0,0 +1,366 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, ScrollView } from 'react-native'; +import Animated, { + FadeIn, + FadeOut, + SlideInDown, + SlideOutDown, + useAnimatedStyle, + withTiming, +} from 'react-native-reanimated'; +import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; +import { useTheme } from '../../../contexts/ThemeContext'; +import { shaderService, ShaderMode, SHADER_PROFILES, ShaderCategory } from '../../../services/shaderService'; +import { visualEnhancementService, COLOR_PROFILES, PROFILE_DESCRIPTIONS, VideoSettings } from '../../../services/colorProfileService'; +import { useSettings } from '../../../hooks/useSettings'; +import Slider from '@react-native-community/slider'; + +interface VisualEnhancementModalProps { + visible: boolean; + onClose: () => void; + // Shader props + shaderMode: ShaderMode; + setShaderMode: (mode: ShaderMode) => void; + // Color props + activeProfile: string; + setProfile: (profile: string) => void; + customSettings: VideoSettings; + updateCustomSettings: (settings: Partial) => void; +} + +const TabButton = ({ label, icon, isSelected, onPress }: any) => { + const { currentTheme } = useTheme(); + return ( + + + + {label} + + {isSelected && ( + + )} + + ); +}; + +const ShaderTab = ({ currentMode, setMode }: { currentMode: string, setMode: (m: string) => void }) => { + const { settings } = useSettings(); + const selectedCategory = (settings?.shaderProfile || 'MID-END') as ShaderCategory; + + const animeModes = SHADER_PROFILES[selectedCategory] ? Object.keys(SHADER_PROFILES[selectedCategory]) : []; + const cinemaModes = SHADER_PROFILES['CINEMA'] ? Object.keys(SHADER_PROFILES['CINEMA']) : []; + + const getModeDescription = (name: string) => { + if (name.includes('Mode A')) return 'Best for high-quality sources.'; + if (name.includes('Mode B')) return 'Soft restore for noisy videos.'; + if (name.includes('Mode C')) return 'Balanced restore and upscale.'; + if (name.includes('FSR')) return 'Sharp upscaling for live-action.'; + if (name.includes('SSimSuperRes')) return 'Natural sharpness and anti-ringing.'; + return ''; + }; + + return ( + + + + GENERAL + + + setMode('none')} + /> + + + + ANIME (ANIME4K) + + + {animeModes.map((mode) => ( + setMode(mode)} + isHQ={selectedCategory === 'HIGH-END'} + /> + ))} + + {cinemaModes.length > 0 && ( + <> + + + CINEMA + + + {cinemaModes.map((mode) => ( + setMode(mode)} + /> + ))} + + )} + + ); +}; + +const PresetsTab = ({ activeProfile, setProfile }: { activeProfile: string, setProfile: (p: string) => void }) => { + const groups = { + 'Anime': ['anime_4k', 'anime', 'anime_vibrant', 'anime_soft'], + 'Cinema': ['cinema', 'cinema_dark', 'cinema_hdr'], + 'Vivid': ['vivid', 'vivid_pop', 'vivid_warm'], + 'Other': ['natural', 'dark', 'warm', 'cool', 'grayscale'], + }; + + return ( + + {Object.entries(groups).map(([group, profiles]) => ( + + + {group.toUpperCase()} + + {profiles.map(profile => ( + setProfile(profile)} + /> + ))} + + ))} + + ); +}; + +const CustomTab = ({ settings, updateSettings, onReset }: any) => { + const { currentTheme } = useTheme(); + const sliders = [ + { key: 'brightness', label: 'Brightness', min: -100, max: 100 }, + { key: 'contrast', label: 'Contrast', min: -100, max: 100 }, + { key: 'saturation', label: 'Saturation', min: -100, max: 100 }, + { key: 'gamma', label: 'Gamma', min: -100, max: 100 }, + { key: 'hue', label: 'Hue', min: -100, max: 100 }, + ]; + + return ( + + + + Fine-tune video properties. Changes are applied immediately. + + + + {sliders.map(({ key, label, min, max }) => ( + + + {label} + + {settings[key]} + + + updateSettings({ [key]: val })} + minimumTrackTintColor={currentTheme.colors.primary} + maximumTrackTintColor="rgba(255,255,255,0.2)" + thumbTintColor="white" + /> + + {min} + {max} + + + ))} + + + + Reset to Default + + + ); +}; + +const PresetItem = ({ label, description, isSelected, onPress, isHQ }: any) => { + return ( + + + + + + {label} + + {isHQ && ( + + HQ + + )} + + {description && ( + + {description} + + )} + + {isSelected && ( + + )} + + + ); +}; + +const VisualEnhancementModal: React.FC = ({ + visible, + onClose, + shaderMode, + setShaderMode, + activeProfile, + setProfile, + customSettings, + updateCustomSettings, +}) => { + const { height, width } = useWindowDimensions(); + const [activeTab, setActiveTab] = useState<'shaders' | 'presets' | 'custom'>('shaders'); + + if (!visible) return null; + + return ( + + + + + + + + {/* Header */} + + + Filters & Appearance + + + + + + {/* Tabs */} + + setActiveTab('shaders')} + /> + setActiveTab('presets')} + /> + setActiveTab('custom')} + /> + + + + {/* Content */} + + {activeTab === 'shaders' && ( + + )} + {activeTab === 'presets' && ( + + )} + {activeTab === 'custom' && ( + setProfile('natural')} + /> + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 10, + marginBottom: 12, + marginLeft: 4, + }, + sectionTitle: { + color: 'rgba(255,255,255,0.4)', + fontSize: 12, + fontWeight: '800', + textTransform: 'uppercase', + letterSpacing: 1, + }, +}); + +export default VisualEnhancementModal; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index befff6bb..b529286d 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -115,6 +115,10 @@ export interface AppSettings { preferredAudioLanguage: string; // Preferred language for audio tracks (ISO 639-1 code) subtitleSourcePreference: 'internal' | 'external' | 'any'; // Prefer internal (embedded), external (addon), or any enableSubtitleAutoSelect: boolean; // Auto-select subtitles based on preferences + // Upscaler settings + enableShaders: boolean; // Enable/disable real-time upscalers + shaderProfile: 'MID-END' | 'HIGH-END'; // Hardware profile for upscalers + defaultShaderMode: string; // Persisted shader preset (e.g. 'Anime4K: Mode A') } export const DEFAULT_SETTINGS: AppSettings = { @@ -203,6 +207,10 @@ export const DEFAULT_SETTINGS: AppSettings = { preferredAudioLanguage: 'en', // Default to English audio subtitleSourcePreference: 'internal', // Prefer internal/embedded subtitles first enableSubtitleAutoSelect: true, // Auto-select subtitles by default + // Upscaler defaults + enableShaders: false, + shaderProfile: 'MID-END', + defaultShaderMode: 'none', }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/settings/PlaybackSettingsScreen.tsx b/src/screens/settings/PlaybackSettingsScreen.tsx index c3559e3c..c8eb8962 100644 --- a/src/screens/settings/PlaybackSettingsScreen.tsx +++ b/src/screens/settings/PlaybackSettingsScreen.tsx @@ -15,6 +15,8 @@ import { useTranslation } from 'react-i18next'; import { SvgXml } from 'react-native-svg'; import { toastService } from '../../services/toastService'; import { introService } from '../../services/introService'; +import { shaderService } from '../../services/shaderService'; +import { Ionicons } from '@expo/vector-icons'; const { width } = Dimensions.get('window'); @@ -69,7 +71,6 @@ interface PlaybackSettingsContentProps { /** * Reusable PlaybackSettingsContent component - * Can be used inline (tablets) or wrapped in a screen (mobile) */ export const PlaybackSettingsContent: React.FC = ({ isTablet = false }) => { const navigation = useNavigation>(); @@ -82,8 +83,39 @@ export const PlaybackSettingsContent: React.FC = ( const [apiKeyInput, setApiKeyInput] = useState(settings?.introDbApiKey || ''); const [isVerifyingKey, setIsVerifyingKey] = useState(false); + // Video Enhancement Assets state + const [isEnhancementDownloaded, setIsEnhancementDownloaded] = useState(false); + const [isDownloadingEnhancement, setIsDownloadingEnhancement] = useState(false); + const [enhancementProgress, setEnhancementProgress] = useState(0); + const isMounted = useRef(true); + const checkEnhancementStatus = useCallback(async () => { + const available = await shaderService.checkAvailability(); + setIsEnhancementDownloaded(available); + }, []); + + useFocusEffect( + useCallback(() => { + checkEnhancementStatus(); + }, [checkEnhancementStatus]) + ); + + const handleDownloadEnhancements = async () => { + setIsDownloadingEnhancement(true); + const success = await shaderService.downloadShaders((p) => setEnhancementProgress(p)); + if (isMounted.current) { + setIsDownloadingEnhancement(false); + if (success) { + shaderService.setInitialized(true); // Force update service state + setIsEnhancementDownloaded(true); + toastService.success(t('settings.enhancement_download_success', { defaultValue: 'Enhancement assets installed!' })); + } else { + toastService.error(t('settings.enhancement_download_failed', { defaultValue: 'Failed to install assets' })); + } + } + }; + useEffect(() => { isMounted.current = true; return () => { @@ -122,13 +154,10 @@ export const PlaybackSettingsContent: React.FC = ( try { const res = await fetch(INTRODB_LOGO_URI); let xml = await res.text(); - // Inline CSS class-based styles because react-native-svg doesn't support