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