Merge pull request #466 from paregi12/Upscale

Upscale
This commit is contained in:
Nayif 2026-02-09 21:33:41 +05:30 committed by GitHub
commit 9faf4d6337
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1290 additions and 25 deletions

View file

@ -33,9 +33,19 @@ class MPVView @JvmOverloads constructor(
// GPU mode setting: 'gpu', 'gpu-next' (default: gpu) // GPU mode setting: 'gpu', 'gpu-next' (default: gpu)
var gpuMode: String = "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) // Flag to track if onLoad has been fired (prevents multiple fires for HLS streams)
private var hasLoadEventFired: Boolean = false 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 // Event listener for React Native
var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null
var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null
@ -78,6 +88,7 @@ class MPVView @JvmOverloads constructor(
MPVLib.addObserver(this) MPVLib.addObserver(this)
MPVLib.setPropertyString("android-surface-size", "${width}x${height}") MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
observeProperties() observeProperties()
applyPostInitSettings()
isMpvInitialized = true isMpvInitialized = true
// If a data source was set before surface was ready, load it now // 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 // MPVLib.EventObserver implementation
override fun eventProperty(property: String) { override fun eventProperty(property: String) {

View file

@ -191,6 +191,11 @@ class MpvPlayerViewManager(
view.gpuMode = gpuMode ?: "gpu" view.gpuMode = gpuMode ?: "gpu"
} }
@ReactProp(name = "glslShaders")
fun setGlslShaders(view: MPVView, glslShaders: String?) {
view.setGlslShaders(glslShaders)
}
// Subtitle Styling Props // Subtitle Styling Props
@ReactProp(name = "subtitleSize", defaultInt = 48) @ReactProp(name = "subtitleSize", defaultInt = 48)
@ -238,4 +243,31 @@ class MpvPlayerViewManager(
fun setSubtitleAlignment(view: MPVView, align: String?) { fun setSubtitleAlignment(view: MPVView, align: String?) {
view.setSubtitleAlignment(align ?: "center") 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)
}
} }

Binary file not shown.

28
package-lock.json generated
View file

@ -68,6 +68,7 @@
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.8",
"i18next": "^25.7.3", "i18next": "^25.7.3",
"intl-pluralrules": "^2.0.1", "intl-pluralrules": "^2.0.1",
"jszip": "^3.10.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "~7.3.1", "lottie-react-native": "~7.3.1",
"posthog-react-native": "^4.4.0", "posthog-react-native": "^4.4.0",
@ -7766,6 +7767,12 @@
"node": ">=16.x" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -8435,6 +8442,18 @@
"node": ">=0.6.0" "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": { "node_modules/klaw-sync": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
@ -8486,6 +8505,15 @@
"node": ">= 0.8.0" "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": { "node_modules/lighthouse-logger": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",

View file

@ -69,6 +69,7 @@
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.8",
"i18next": "^25.7.3", "i18next": "^25.7.3",
"intl-pluralrules": "^2.0.1", "intl-pluralrules": "^2.0.1",
"jszip": "^3.10.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "~7.3.1", "lottie-react-native": "~7.3.1",
"posthog-react-native": "^4.4.0", "posthog-react-native": "^4.4.0",

View file

@ -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) { fun setSubtitleTrack(trackId: Int) {
Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized") Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized")
if (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) { fun setResizeMode(mode: String) {
Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized") Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized")
if (isMpvInitialized) { if (isMpvInitialized) {

View file

@ -180,4 +180,35 @@ class MpvPlayerViewManager(
view.setHeaders(null) 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 ?: "")
}
} }

View file

@ -40,6 +40,7 @@ import { SourcesModal } from './modals/SourcesModal';
import { EpisodesModal } from './modals/EpisodesModal'; import { EpisodesModal } from './modals/EpisodesModal';
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
import { ErrorModal } from './modals/ErrorModal'; import { ErrorModal } from './modals/ErrorModal';
import VisualEnhancementModal from './modals/VisualEnhancementModal';
import { CustomSubtitles } from './subtitles/CustomSubtitles'; import { CustomSubtitles } from './subtitles/CustomSubtitles';
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay'; import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
import SkipIntroButton from './overlays/SkipIntroButton'; import SkipIntroButton from './overlays/SkipIntroButton';
@ -57,6 +58,8 @@ import { styles } from './utils/playerStyles';
import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils'; import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils';
import { storageService } from '../../services/storageService'; import { storageService } from '../../services/storageService';
import stremioService from '../../services/stremioService'; import stremioService from '../../services/stremioService';
import { shaderService, ShaderMode } from '../../services/shaderService';
import { visualEnhancementService, VideoSettings } from '../../services/colorProfileService';
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes'; import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils'; import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
@ -81,7 +84,7 @@ const AndroidVideoPlayer: React.FC = () => {
const playerState = usePlayerState(); const playerState = usePlayerState();
const modals = usePlayerModals(); const modals = usePlayerModals();
const speedControl = useSpeedControl(); const speedControl = useSpeedControl();
const { settings } = useSettings(); const { settings, updateSetting } = useSettings();
const videoRef = useRef<any>(null); const videoRef = useRef<any>(null);
const mpvPlayerRef = useRef<MpvPlayerRef>(null); const mpvPlayerRef = useRef<MpvPlayerRef>(null);
@ -145,6 +148,72 @@ const AndroidVideoPlayer: React.FC = () => {
// Subtitle sync modal state // Subtitle sync modal state
const [showSyncModal, setShowSyncModal] = useState(false); const [showSyncModal, setShowSyncModal] = useState(false);
// Shader / Video Enhancement State
const [showEnhancementModal, setShowEnhancementModal] = useState(false);
const [shaderMode, setShaderModeState] = useState<ShaderMode>(settings.defaultShaderMode || 'none');
const [glslShaders, setGlslShaders] = useState<string>('');
// Color Profile State
const [activeProfile, setActiveProfile] = useState<string>('natural');
const [customSettings, setCustomSettings] = useState<VideoSettings>(visualEnhancementService.getCustomSettings());
const [currentVideoSettings, setCurrentVideoSettings] = useState<VideoSettings>(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<VideoSettings>) => {
await visualEnhancementService.updateCustomSettings(newSettings);
setCustomSettings(visualEnhancementService.getCustomSettings());
setActiveProfile('custom');
setCurrentVideoSettings(visualEnhancementService.getCurrentSettings());
}, []);
// Track auto-selection ref to prevent duplicate selections // Track auto-selection ref to prevent duplicate selections
const hasAutoSelectedTracks = useRef(false); const hasAutoSelectedTracks = useRef(false);
@ -814,6 +883,13 @@ const AndroidVideoPlayer: React.FC = () => {
screenDimensions={playerState.screenDimensions} screenDimensions={playerState.screenDimensions}
decoderMode={settings.decoderMode} decoderMode={settings.decoderMode}
gpuMode={settings.gpuMode} 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 // Dual video engine props
useExoPlayer={useExoPlayer} useExoPlayer={useExoPlayer}
onCodecError={handleCodecError} onCodecError={handleCodecError}
@ -922,6 +998,7 @@ const AndroidVideoPlayer: React.FC = () => {
isSubtitleModalOpen={modals.showSubtitleModal} isSubtitleModalOpen={modals.showSubtitleModal}
setShowSourcesModal={modals.setShowSourcesModal} setShowSourcesModal={modals.setShowSourcesModal}
setShowEpisodesModal={type === 'series' ? modals.setShowEpisodesModal : undefined} setShowEpisodesModal={type === 'series' ? modals.setShowEpisodesModal : undefined}
setShowEnhancementModal={setShowEnhancementModal}
onSliderValueChange={(val) => { playerState.isDragging.current = true; }} onSliderValueChange={(val) => { playerState.isDragging.current = true; }}
onSlidingStart={() => { playerState.isDragging.current = true; }} onSlidingStart={() => { playerState.isDragging.current = true; }}
onSlidingComplete={(val) => { onSlidingComplete={(val) => {
@ -1098,6 +1175,17 @@ const AndroidVideoPlayer: React.FC = () => {
onSelectStream={(stream) => handleSelectStream(stream)} onSelectStream={(stream) => handleSelectStream(stream)}
/> />
<VisualEnhancementModal
visible={showEnhancementModal}
onClose={() => setShowEnhancementModal(false)}
shaderMode={shaderMode}
setShaderMode={setShaderMode}
activeProfile={activeProfile}
setProfile={handleSetProfile}
customSettings={customSettings}
updateCustomSettings={handleUpdateCustomSettings}
/>
<SpeedModal <SpeedModal
showSpeedModal={modals.showSpeedModal} showSpeedModal={modals.showSpeedModal}
setShowSpeedModal={modals.setShowSpeedModal} setShowSpeedModal={modals.setShowSpeedModal}

View file

@ -28,6 +28,13 @@ export interface MpvPlayerProps {
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void; onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+'; decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
gpuMode?: 'gpu' | 'gpu-next'; gpuMode?: 'gpu' | 'gpu-next';
glslShaders?: string;
// Video EQ Props
brightness?: number;
contrast?: number;
saturation?: number;
gamma?: number;
hue?: number;
// Subtitle Styling // Subtitle Styling
subtitleSize?: number; subtitleSize?: number;
subtitleColor?: string; subtitleColor?: string;
@ -121,6 +128,12 @@ const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
onTracksChanged={handleTracksChanged} onTracksChanged={handleTracksChanged}
decoderMode={props.decoderMode ?? 'auto'} decoderMode={props.decoderMode ?? 'auto'}
gpuMode={props.gpuMode ?? 'gpu'} 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 // Subtitle Styling
subtitleSize={props.subtitleSize ?? 48} subtitleSize={props.subtitleSize ?? 48}
subtitleColor={props.subtitleColor ?? '#FFFFFF'} subtitleColor={props.subtitleColor ?? '#FFFFFF'}

View file

@ -50,6 +50,13 @@ interface VideoSurfaceProps {
selectedTextTrack?: SelectedTrack; selectedTextTrack?: SelectedTrack;
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+'; decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
gpuMode?: 'gpu' | 'gpu-next'; gpuMode?: 'gpu' | 'gpu-next';
glslShaders?: string;
// Video EQ Props
brightness?: number;
contrast?: number;
saturation?: number;
gamma?: number;
hue?: number;
// Dual Engine Props // Dual Engine Props
useExoPlayer?: boolean; useExoPlayer?: boolean;
@ -105,6 +112,12 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
selectedTextTrack, selectedTextTrack,
decoderMode, decoderMode,
gpuMode, gpuMode,
glslShaders,
brightness,
contrast,
saturation,
gamma,
hue,
// Dual Engine // Dual Engine
useExoPlayer = true, useExoPlayer = true,
onCodecError, onCodecError,
@ -401,6 +414,12 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
onTracksChanged={onTracksChanged} onTracksChanged={onTracksChanged}
decoderMode={decoderMode} decoderMode={decoderMode}
gpuMode={gpuMode} gpuMode={gpuMode}
glslShaders={glslShaders}
brightness={brightness}
contrast={contrast}
saturation={saturation}
gamma={gamma}
hue={hue}
subtitleSize={subtitleSize} subtitleSize={subtitleSize}
subtitleColor={subtitleColor} subtitleColor={subtitleColor}
subtitleBackgroundOpacity={subtitleBackgroundOpacity} subtitleBackgroundOpacity={subtitleBackgroundOpacity}

View file

@ -45,6 +45,7 @@ interface PlayerControlsProps {
isSubtitleModalOpen?: boolean; isSubtitleModalOpen?: boolean;
setShowSourcesModal?: (show: boolean) => void; setShowSourcesModal?: (show: boolean) => void;
setShowEpisodesModal?: (show: boolean) => void; setShowEpisodesModal?: (show: boolean) => void;
setShowEnhancementModal?: (show: boolean) => void;
// Slider-specific props // Slider-specific props
onSliderValueChange: (value: number) => void; onSliderValueChange: (value: number) => void;
onSlidingStart: () => void; onSlidingStart: () => void;
@ -95,6 +96,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
isSubtitleModalOpen, isSubtitleModalOpen,
setShowSourcesModal, setShowSourcesModal,
setShowEpisodesModal, setShowEpisodesModal,
setShowEnhancementModal,
onSliderValueChange, onSliderValueChange,
onSlidingStart, onSlidingStart,
onSlidingComplete, onSlidingComplete,
@ -391,6 +393,21 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/> />
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* Video Enhancement Button (Top Access) */}
{playerBackend === 'MPV' && setShowEnhancementModal && settings.enableShaders && (
<TouchableOpacity
style={{ padding: 8 }}
onPress={() => setShowEnhancementModal(true)}
>
<Ionicons
name="sparkles-outline"
size={closeIconSize}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.closeButton} onPress={handleClose}> <TouchableOpacity style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={closeIconSize} color="white" /> <Ionicons name="close" size={closeIconSize} color="white" />
</TouchableOpacity> </TouchableOpacity>
@ -592,7 +609,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
<View style={styles.bottomControls} pointerEvents="box-none"> <View style={styles.bottomControls} pointerEvents="box-none">
{/* Center Buttons Container with rounded background - wraps all buttons */} {/* Center Buttons Container with rounded background - wraps all buttons */}
<View style={styles.centerControlsContainer} pointerEvents="box-none"> <View style={styles.centerControlsContainer} pointerEvents="box-none">
{/* Left Side: Aspect Ratio Button */} {/* Aspect Ratio Button */}
<TouchableOpacity style={styles.iconButton} onPress={cycleAspectRatio}> <TouchableOpacity style={styles.iconButton} onPress={cycleAspectRatio}>
<Ionicons name="expand-outline" size={24} color="white" /> <Ionicons name="expand-outline" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
@ -605,16 +622,6 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
<Ionicons name="text" size={24} color="white" /> <Ionicons name="text" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
{/* Change Source Button */}
{setShowSourcesModal && (
<TouchableOpacity
style={styles.iconButton}
onPress={() => setShowSourcesModal(true)}
>
<Ionicons name="cloud-outline" size={24} color="white" />
</TouchableOpacity>
)}
{/* Playback Speed Button */} {/* Playback Speed Button */}
<TouchableOpacity style={styles.iconButton} onPress={() => setShowSpeedModal(true)}> <TouchableOpacity style={styles.iconButton} onPress={() => setShowSpeedModal(true)}>
<Ionicons name="speedometer-outline" size={24} color="white" /> <Ionicons name="speedometer-outline" size={24} color="white" />
@ -633,6 +640,16 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/> />
</TouchableOpacity> </TouchableOpacity>
{/* Change Source Button */}
{setShowSourcesModal && (
<TouchableOpacity
style={styles.iconButton}
onPress={() => setShowSourcesModal(true)}
>
<Ionicons name="cloud-outline" size={24} color="white" />
</TouchableOpacity>
)}
{/* Submit Intro Button */} {/* Submit Intro Button */}
{season !== undefined && episode !== undefined && settings.introSubmitEnabled && settings.introDbApiKey && ( {season !== undefined && episode !== undefined && settings.introSubmitEnabled && settings.introDbApiKey && (
<TouchableOpacity <TouchableOpacity
@ -647,7 +664,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* Right Side: Episodes Button */} {/* Episodes Button */}
{setShowEpisodesModal && ( {setShowEpisodesModal && (
<TouchableOpacity <TouchableOpacity
style={styles.iconButton} style={styles.iconButton}
@ -659,6 +676,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</View> </View>
</View> </View>
</LinearGradient> </LinearGradient>
</View> </View>
</Animated.View> </Animated.View>
); );

View file

@ -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<VideoSettings>) => void;
}
const TabButton = ({ label, icon, isSelected, onPress }: any) => {
const { currentTheme } = useTheme();
return (
<TouchableOpacity onPress={onPress} style={{ flex: 1, alignItems: 'center', paddingVertical: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', opacity: isSelected ? 1 : 0.5 }}>
<Ionicons name={icon} size={18} color="white" style={{ marginRight: 6 }} />
<Text style={{ color: 'white', fontWeight: '700', fontSize: 14 }}>{label}</Text>
</View>
{isSelected && (
<View style={{
height: 3,
backgroundColor: currentTheme.colors.primary,
width: '60%',
borderRadius: 2,
position: 'absolute',
bottom: 0
}} />
)}
</TouchableOpacity>
);
};
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 (
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 20 }}>
<View style={styles.sectionHeader}>
<Ionicons name="options-outline" size={16} color="rgba(255,255,255,0.4)" style={{ marginRight: 8 }} />
<Text style={styles.sectionTitle}>GENERAL</Text>
</View>
<PresetItem
label="None (Standard)"
description="Native source resolution"
isSelected={currentMode === 'none'}
onPress={() => setMode('none')}
/>
<View style={styles.sectionHeader}>
<Ionicons name="color-palette-outline" size={16} color="rgba(255,255,255,0.4)" style={{ marginRight: 8 }} />
<Text style={styles.sectionTitle}>ANIME (ANIME4K)</Text>
</View>
{animeModes.map((mode) => (
<PresetItem
key={mode}
label={mode}
description={getModeDescription(mode)}
isSelected={currentMode === mode}
onPress={() => setMode(mode)}
isHQ={selectedCategory === 'HIGH-END'}
/>
))}
{cinemaModes.length > 0 && (
<>
<View style={styles.sectionHeader}>
<Ionicons name="film-outline" size={16} color="rgba(255,255,255,0.4)" style={{ marginRight: 8 }} />
<Text style={styles.sectionTitle}>CINEMA</Text>
</View>
{cinemaModes.map((mode) => (
<PresetItem
key={mode}
label={mode}
description={getModeDescription(mode)}
isSelected={currentMode === mode}
onPress={() => setMode(mode)}
/>
))}
</>
)}
</ScrollView>
);
};
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 (
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 20 }}>
{Object.entries(groups).map(([group, profiles]) => (
<View key={group}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{group.toUpperCase()}</Text>
</View>
{profiles.map(profile => (
<PresetItem
key={profile}
label={profile.replace(/_/g, ' ').toUpperCase()}
description={PROFILE_DESCRIPTIONS[profile]}
isSelected={activeProfile === profile}
onPress={() => setProfile(profile)}
/>
))}
</View>
))}
</ScrollView>
);
};
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 (
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 20 }}>
<View style={{ padding: 16, backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 12, marginBottom: 20 }}>
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, textAlign: 'center' }}>
Fine-tune video properties. Changes are applied immediately.
</Text>
</View>
{sliders.map(({ key, label, min, max }) => (
<View key={key} style={{ marginBottom: 24 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>{label}</Text>
<View style={{ backgroundColor: currentTheme.colors.primary, paddingHorizontal: 8, borderRadius: 4 }}>
<Text style={{ color: 'black', fontWeight: 'bold', fontSize: 12 }}>{settings[key]}</Text>
</View>
</View>
<Slider
style={{ width: '100%', height: 40 }}
minimumValue={min}
maximumValue={max}
step={1}
value={settings[key]}
onValueChange={(val) => updateSettings({ [key]: val })}
minimumTrackTintColor={currentTheme.colors.primary}
maximumTrackTintColor="rgba(255,255,255,0.2)"
thumbTintColor="white"
/>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ color: 'rgba(255,255,255,0.3)', fontSize: 10 }}>{min}</Text>
<Text style={{ color: 'rgba(255,255,255,0.3)', fontSize: 10 }}>{max}</Text>
</View>
</View>
))}
<TouchableOpacity
onPress={onReset}
style={{
backgroundColor: 'rgba(255,255,255,0.1)',
padding: 16,
borderRadius: 12,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginTop: 10
}}
>
<Ionicons name="refresh" size={20} color="white" style={{ marginRight: 8 }} />
<Text style={{ color: 'white', fontWeight: '700' }}>Reset to Default</Text>
</TouchableOpacity>
</ScrollView>
);
};
const PresetItem = ({ label, description, isSelected, onPress, isHQ }: any) => {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7} style={{ marginBottom: 8 }}>
<View style={{
padding: 16,
backgroundColor: isSelected ? 'white' : 'rgba(255,255,255,0.05)',
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{
color: isSelected ? 'black' : 'white',
fontWeight: '700',
fontSize: 15,
marginBottom: 4
}}>
{label}
</Text>
{isHQ && (
<View style={{ backgroundColor: '#FFD700', paddingHorizontal: 6, paddingVertical: 1, borderRadius: 4, marginLeft: 8 }}>
<Text style={{ color: 'black', fontSize: 9, fontWeight: '900' }}>HQ</Text>
</View>
)}
</View>
{description && (
<Text style={{
color: isSelected ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.5)',
fontSize: 12
}}>
{description}
</Text>
)}
</View>
{isSelected && (
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: 'black' }} />
)}
</View>
</TouchableOpacity>
);
};
const VisualEnhancementModal: React.FC<VisualEnhancementModalProps> = ({
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 (
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={onClose}>
<Animated.View entering={FadeIn} exiting={FadeOut} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.6)' }} />
</TouchableOpacity>
<View pointerEvents="box-none" style={{ ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center' }}>
<Animated.View
entering={SlideInDown}
exiting={SlideOutDown}
style={{
width: Math.min(width * 0.9, 450),
height: height * 0.85,
backgroundColor: '#121212',
borderRadius: 24,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}
>
{/* Header */}
<View style={{ padding: 20, paddingBottom: 0 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<Text style={{ color: 'white', fontSize: 20, fontWeight: '800' }}>Filters & Appearance</Text>
<TouchableOpacity onPress={onClose} style={{ padding: 4 }}>
<Ionicons name="close" size={24} color="rgba(255,255,255,0.5)" />
</TouchableOpacity>
</View>
{/* Tabs */}
<View style={{ flexDirection: 'row', backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 4 }}>
<TabButton
label="Upscalers"
icon="eye-outline"
isSelected={activeTab === 'shaders'}
onPress={() => setActiveTab('shaders')}
/>
<TabButton
label="Profiles"
icon="color-filter-outline"
isSelected={activeTab === 'presets'}
onPress={() => setActiveTab('presets')}
/>
<TabButton
label="Custom"
icon="options-outline"
isSelected={activeTab === 'custom'}
onPress={() => setActiveTab('custom')}
/>
</View>
</View>
{/* Content */}
<View style={{ flex: 1, padding: 20 }}>
{activeTab === 'shaders' && (
<ShaderTab currentMode={shaderMode} setMode={setShaderMode} />
)}
{activeTab === 'presets' && (
<PresetsTab activeProfile={activeProfile} setProfile={setProfile} />
)}
{activeTab === 'custom' && (
<CustomTab
settings={customSettings}
updateSettings={updateCustomSettings}
onReset={() => setProfile('natural')}
/>
)}
</View>
</Animated.View>
</View>
</View>
);
};
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;

View file

@ -115,6 +115,10 @@ export interface AppSettings {
preferredAudioLanguage: string; // Preferred language for audio tracks (ISO 639-1 code) preferredAudioLanguage: string; // Preferred language for audio tracks (ISO 639-1 code)
subtitleSourcePreference: 'internal' | 'external' | 'any'; // Prefer internal (embedded), external (addon), or any subtitleSourcePreference: 'internal' | 'external' | 'any'; // Prefer internal (embedded), external (addon), or any
enableSubtitleAutoSelect: boolean; // Auto-select subtitles based on preferences 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 = { export const DEFAULT_SETTINGS: AppSettings = {
@ -203,6 +207,10 @@ export const DEFAULT_SETTINGS: AppSettings = {
preferredAudioLanguage: 'en', // Default to English audio preferredAudioLanguage: 'en', // Default to English audio
subtitleSourcePreference: 'internal', // Prefer internal/embedded subtitles first subtitleSourcePreference: 'internal', // Prefer internal/embedded subtitles first
enableSubtitleAutoSelect: true, // Auto-select subtitles by default enableSubtitleAutoSelect: true, // Auto-select subtitles by default
// Upscaler defaults
enableShaders: false,
shaderProfile: 'MID-END',
defaultShaderMode: 'none',
}; };
const SETTINGS_STORAGE_KEY = 'app_settings'; const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -1924,7 +1924,6 @@ const ConditionalPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ c
apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C" apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C"
options={{ options={{
host: "https://us.i.posthog.com", host: "https://us.i.posthog.com",
autocapture: analyticsEnabled,
// Start opted out if analytics is disabled // Start opted out if analytics is disabled
defaultOptIn: analyticsEnabled, defaultOptIn: analyticsEnabled,
}} }}

View file

@ -15,6 +15,8 @@ import { useTranslation } from 'react-i18next';
import { SvgXml } from 'react-native-svg'; import { SvgXml } from 'react-native-svg';
import { toastService } from '../../services/toastService'; import { toastService } from '../../services/toastService';
import { introService } from '../../services/introService'; import { introService } from '../../services/introService';
import { shaderService } from '../../services/shaderService';
import { Ionicons } from '@expo/vector-icons';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@ -69,7 +71,6 @@ interface PlaybackSettingsContentProps {
/** /**
* Reusable PlaybackSettingsContent component * Reusable PlaybackSettingsContent component
* Can be used inline (tablets) or wrapped in a screen (mobile)
*/ */
export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = ({ isTablet = false }) => { export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = ({ isTablet = false }) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -82,8 +83,39 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
const [apiKeyInput, setApiKeyInput] = useState(settings?.introDbApiKey || ''); const [apiKeyInput, setApiKeyInput] = useState(settings?.introDbApiKey || '');
const [isVerifyingKey, setIsVerifyingKey] = useState(false); 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 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(() => { useEffect(() => {
isMounted.current = true; isMounted.current = true;
return () => { return () => {
@ -122,13 +154,10 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
try { try {
const res = await fetch(INTRODB_LOGO_URI); const res = await fetch(INTRODB_LOGO_URI);
let xml = await res.text(); let xml = await res.text();
// Inline CSS class-based styles because react-native-svg doesn't support <style> class selectors
// Map known classes from the IntroDB logo to equivalent inline attributes
xml = xml.replace(/class="cls-4"/g, 'fill="url(#linear-gradient)"'); xml = xml.replace(/class="cls-4"/g, 'fill="url(#linear-gradient)"');
xml = xml.replace(/class="cls-3"/g, 'fill="#141414" opacity=".38"'); xml = xml.replace(/class="cls-3"/g, 'fill="#141414" opacity=".38"');
xml = xml.replace(/class="cls-1"/g, 'fill="url(#linear-gradient-2)" opacity=".53"'); xml = xml.replace(/class="cls-1"/g, 'fill="url(#linear-gradient-2)" opacity=".53"');
xml = xml.replace(/class="cls-2"/g, 'fill="url(#linear-gradient-3)" opacity=".53"'); xml = xml.replace(/class="cls-2"/g, 'fill="url(#linear-gradient-3)" opacity=".53"');
// Remove the <style> block to avoid unsupported CSS
xml = xml.replace(/<style>[\s\S]*?<\/style>/, ''); xml = xml.replace(/<style>[\s\S]*?<\/style>/, '');
if (!cancelled) setIntroDbLogoXml(xml); if (!cancelled) setIntroDbLogoXml(xml);
} catch { } catch {
@ -156,7 +185,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
const languageSnapPoints = useMemo(() => ['70%'], []); const languageSnapPoints = useMemo(() => ['70%'], []);
const sourceSnapPoints = useMemo(() => ['45%'], []); const sourceSnapPoints = useMemo(() => ['45%'], []);
// Handlers to present sheets - ensure only one is open at a time // Handlers
const openAudioLanguageSheet = useCallback(() => { const openAudioLanguageSheet = useCallback(() => {
subtitleLanguageSheetRef.current?.dismiss(); subtitleLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss(); subtitleSourceSheetRef.current?.dismiss();
@ -198,7 +227,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
return t('settings.options.internal_first'); return t('settings.options.internal_first');
}; };
// Render backdrop for bottom sheets
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: any) => ( (props: any) => (
<BottomSheetBackdrop <BottomSheetBackdrop
@ -311,6 +339,143 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
)} )}
</SettingsCard> </SettingsCard>
{/* Shaders Section */}
<SettingsCard title="Shaders" isTablet={isTablet}>
{/* Enable Shaders Toggle */}
<SettingItem
title={t('settings.items.enable_shaders', { defaultValue: 'Enable Shaders' })}
description={t('settings.items.enable_shaders_desc', { defaultValue: 'Apply real-time shaders (Anime4K/FSR) to the player.' })}
icon="eye"
renderControl={() => (
<CustomSwitch
value={settings?.enableShaders ?? false}
onValueChange={(value) => updateSetting('enableShaders', value)}
/>
)}
isTablet={isTablet}
/>
{settings?.enableShaders && (
<>
{/* Profile Selection */}
<SettingItem
title={t('settings.items.shader_profile', { defaultValue: 'Shader Profile' })}
description={t('settings.items.shader_profile_desc', { defaultValue: 'Choose based on your device performance.' })}
icon="activity"
renderControl={() => (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ color: currentTheme.colors.primary, marginRight: 8, fontWeight: '700' }}>
{settings?.shaderProfile || 'MID-END'}
</Text>
<ChevronRight />
</View>
)}
onPress={() => {
const newProfile = settings?.shaderProfile === 'HIGH-END' ? 'MID-END' : 'HIGH-END';
updateSetting('shaderProfile', newProfile);
toastService.success(`Profile switched to ${newProfile}`);
}}
isTablet={isTablet}
/>
{/* Warning Box */}
<View style={{
backgroundColor: 'rgba(220, 38, 38, 0.1)',
borderWidth: 1,
borderColor: 'rgba(220, 38, 38, 0.3)',
borderRadius: 12,
padding: 16,
marginHorizontal: 16,
marginTop: 12,
marginBottom: 12,
flexDirection: 'row'
}}>
<Ionicons name="alert-circle" size={24} color="#EF5350" style={{ marginRight: 12 }} />
<View style={{ flex: 1 }}>
<Text style={{ color: '#EF5350', fontWeight: '700', fontSize: 15, marginBottom: 4 }}>Warning</Text>
<Text style={{ color: 'rgba(239, 83, 80, 0.8)', fontSize: 13, lineHeight: 18 }}>
Using high-end shaders on older devices may cause lag, overheating, or black screens. Use 'MID-END' for better compatibility.
</Text>
</View>
</View>
{/* Install Assets / Status */}
<View style={{
backgroundColor: isEnhancementDownloaded ? 'rgba(76, 175, 80, 0.1)' : 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: isEnhancementDownloaded ? 'rgba(76, 175, 80, 0.3)' : 'rgba(255, 255, 255, 0.1)',
borderRadius: 12,
padding: 16,
marginHorizontal: 16,
marginBottom: 16,
}}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}>
<Ionicons
name={isEnhancementDownloaded ? "checkmark-circle" : "cloud-download"}
size={20}
color={isEnhancementDownloaded ? "#4CAF50" : currentTheme.colors.primary}
style={{ marginRight: 10 }}
/>
<Text style={{ color: 'white', fontWeight: '600', fontSize: 15 }}>
{isEnhancementDownloaded ? 'Shader Assets Ready' : 'Install Shader Pack'}
</Text>
</View>
{!isEnhancementDownloaded && (
<TouchableOpacity
style={{
backgroundColor: currentTheme.colors.primary,
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
marginTop: 8
}}
onPress={handleDownloadEnhancements}
disabled={isDownloadingEnhancement}
>
{isDownloadingEnhancement ? (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<ActivityIndicator size="small" color="black" style={{ marginRight: 8 }} />
<Text style={{ color: 'black', fontWeight: '700' }}>
Extracting {Math.round(enhancementProgress * 100)}%
</Text>
</View>
) : (
<Text style={{ color: 'black', fontWeight: '700' }}>Initialize Assets</Text>
)}
</TouchableOpacity>
)}
{isEnhancementDownloaded && (
<View style={{ marginTop: 8 }}>
<Text style={{ color: 'rgba(255, 255, 255, 0.6)', fontSize: 12, marginBottom: 12 }}>
All required shader files are ready. You can switch profiles in the video player menu.
</Text>
<TouchableOpacity
style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)'
}}
onPress={async () => {
await shaderService.clearShaders();
setIsEnhancementDownloaded(false);
toastService.success('Shader assets cleared');
}}
>
<Text style={{ color: 'rgba(255, 255, 255, 0.6)', fontWeight: '600', fontSize: 13 }}>Reset Assets</Text>
</TouchableOpacity>
</View>
)}
</View>
</>
)}
</SettingsCard>
{/* Audio & Subtitle Preferences */} {/* Audio & Subtitle Preferences */}
<SettingsCard title={t('settings.sections.audio_subtitles')} isTablet={isTablet}> <SettingsCard title={t('settings.sections.audio_subtitles')} isTablet={isTablet}>
<SettingItem <SettingItem
@ -402,7 +567,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
</SettingsCard> </SettingsCard>
)} )}
{/* Audio Language Bottom Sheet */}
<BottomSheetModal <BottomSheetModal
ref={audioLanguageSheetRef} ref={audioLanguageSheetRef}
index={0} index={0}
@ -443,7 +607,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </BottomSheetModal>
{/* Subtitle Language Bottom Sheet */}
<BottomSheetModal <BottomSheetModal
ref={subtitleLanguageSheetRef} ref={subtitleLanguageSheetRef}
index={0} index={0}
@ -484,7 +647,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </BottomSheetModal>
{/* Subtitle Source Priority Bottom Sheet */}
<BottomSheetModal <BottomSheetModal
ref={subtitleSourceSheetRef} ref={subtitleSourceSheetRef}
index={0} index={0}
@ -534,7 +696,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
/** /**
* PlaybackSettingsScreen - Wrapper for mobile navigation * PlaybackSettingsScreen - Wrapper for mobile navigation
* Uses PlaybackSettingsContent internally
*/ */
const PlaybackSettingsScreen: React.FC = () => { const PlaybackSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -663,4 +824,4 @@ const styles = StyleSheet.create({
}, },
}); });
export default PlaybackSettingsScreen; export default PlaybackSettingsScreen;

View file

@ -0,0 +1,117 @@
import { mmkvStorage } from './mmkvStorage';
import { logger } from '../utils/logger';
export interface VideoSettings {
brightness: number;
contrast: number;
saturation: number;
gamma: number;
hue: number;
}
export const COLOR_PROFILES: Record<string, VideoSettings> = {
"cinema": { brightness: 2, contrast: 12, saturation: 8, gamma: 5, hue: 0 },
"cinema_dark": { brightness: -12, contrast: 18, saturation: 6, gamma: 12, hue: -1 },
"cinema_hdr": { brightness: 5, contrast: 22, saturation: 12, gamma: 3, hue: -1 },
"anime": { brightness: 10, contrast: 22, saturation: 30, gamma: -3, hue: 3 },
"anime_vibrant": { brightness: 14, contrast: 28, saturation: 42, gamma: -6, hue: 4 },
"anime_soft": { brightness: 8, contrast: 16, saturation: 25, gamma: -1, hue: 2 },
"anime_4k": { brightness: 0, contrast: 20, saturation: 100, gamma: 1, hue: 2 },
"vivid": { brightness: 8, contrast: 25, saturation: 35, gamma: 2, hue: 1 },
"vivid_pop": { brightness: 12, contrast: 32, saturation: 48, gamma: 4, hue: 2 },
"vivid_warm": { brightness: 6, contrast: 24, saturation: 32, gamma: 1, hue: 8 },
"natural": { brightness: 0, contrast: 0, saturation: 0, gamma: 0, hue: 0 },
"dark": { brightness: -18, contrast: 15, saturation: -8, gamma: 15, hue: -2 },
"warm": { brightness: 3, contrast: 10, saturation: 15, gamma: 2, hue: 6 },
"cool": { brightness: 1, contrast: 8, saturation: 12, gamma: 1, hue: -6 },
"grayscale": { brightness: 2, contrast: 20, saturation: -100, gamma: 8, hue: 0 },
"custom": { brightness: 0, contrast: 0, saturation: 0, gamma: 0, hue: 0 },
};
export const PROFILE_DESCRIPTIONS: Record<string, string> = {
"cinema": "Balanced colors for movie watching",
"cinema_dark": "Optimized for dark room cinema viewing",
"cinema_hdr": "Enhanced cinema with HDR-like contrast",
"anime": "Enhanced colors perfect for animation",
"anime_vibrant": "Maximum saturation for colorful anime",
"anime_soft": "Gentle enhancement for pastel anime",
"anime_4k": "Ultra-sharp with vibrant 4K clarity",
"vivid": "Bright and punchy colors",
"vivid_pop": "Maximum vibrancy for eye-catching content",
"vivid_warm": "Vivid colors with warm temperature",
"natural": "Default balanced settings",
"dark": "Optimized for dark environments",
"warm": "Warmer tones for comfort viewing",
"cool": "Cooler tones for clarity",
"grayscale": "Black and white viewing",
"custom": "Your personalized settings",
};
const STORAGE_KEYS = {
ACTIVE_PROFILE: 'video_color_profile',
CUSTOM_SETTINGS: 'video_custom_settings',
};
class VisualEnhancementService {
private static instance: VisualEnhancementService;
private activeProfile: string = 'natural';
private customSettings: VideoSettings = { ...COLOR_PROFILES['custom'] };
private constructor() {
this.loadSettings();
}
public static getInstance(): VisualEnhancementService {
if (!VisualEnhancementService.instance) {
VisualEnhancementService.instance = new VisualEnhancementService();
}
return VisualEnhancementService.instance;
}
private async loadSettings() {
try {
const savedProfile = await mmkvStorage.getItem(STORAGE_KEYS.ACTIVE_PROFILE);
if (savedProfile && COLOR_PROFILES[savedProfile]) {
this.activeProfile = savedProfile;
}
const savedCustom = await mmkvStorage.getItem(STORAGE_KEYS.CUSTOM_SETTINGS);
if (savedCustom) {
this.customSettings = JSON.parse(savedCustom);
}
} catch (e) {
logger.error('[VisualEnhancementService] Failed to load settings', e);
}
}
getActiveProfile(): string {
return this.activeProfile;
}
getCustomSettings(): VideoSettings {
return { ...this.customSettings };
}
getCurrentSettings(): VideoSettings {
if (this.activeProfile === 'custom') {
return this.customSettings;
}
return COLOR_PROFILES[this.activeProfile] || COLOR_PROFILES['natural'];
}
async setProfile(profile: string) {
if (COLOR_PROFILES[profile]) {
this.activeProfile = profile;
await mmkvStorage.setItem(STORAGE_KEYS.ACTIVE_PROFILE, profile);
}
}
async updateCustomSettings(settings: Partial<VideoSettings>) {
this.customSettings = { ...this.customSettings, ...settings };
this.activeProfile = 'custom';
await mmkvStorage.setItem(STORAGE_KEYS.CUSTOM_SETTINGS, JSON.stringify(this.customSettings));
await mmkvStorage.setItem(STORAGE_KEYS.ACTIVE_PROFILE, 'custom');
}
}
export const visualEnhancementService = VisualEnhancementService.getInstance();

View file

@ -0,0 +1,274 @@
import * as FileSystem from 'expo-file-system/legacy';
import { logger } from '../utils/logger';
import { Asset } from 'expo-asset';
import JSZip from 'jszip';
// Local Shader Pack Asset
const SHADER_ZIP = require('../../assets/shaders/shaders_new.zip');
// Key files to verify installation
const ESSENTIAL_SHADERS = [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_M.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
'FSR.glsl',
'SSimSuperRes.glsl',
];
// Exact profiles
export const SHADER_PROFILES = {
"MID-END": {
'Anime4K: Mode A (Fast)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_M.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
'Anime4K: Mode B (Fast)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_Soft_M.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
'Anime4K: Mode C (Fast)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Upscale_Denoise_CNN_x2_M.glsl',
],
'Anime4K: Mode A+A (Fast)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_VL.glsl',
'Anime4K_Upscale_CNN_x2_VL.glsl',
'Anime4K_Restore_CNN_M.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
'Anime4K: Mode B+B (Fast)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_Soft_VL.glsl',
'Anime4K_Upscale_CNN_x2_VL.glsl',
'Anime4K_Restore_CNN_Soft_M.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
'Anime4K: Mode C+A (Fast)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Upscale_Denoise_CNN_x2_VL.glsl',
'Anime4K_Restore_CNN_M.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
},
"HIGH-END": {
'Anime4K: Mode A (HQ)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_VL.glsl',
'Anime4K_Upscale_CNN_x2_VL.glsl',
'Anime4K_AutoDownscalePre_x2.glsl',
'Anime4K_AutoDownscalePre_x4.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
'Anime4K: Mode B (HQ)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_Soft_VL.glsl',
'Anime4K_Upscale_CNN_x2_VL.glsl',
'Anime4K_AutoDownscalePre_x2.glsl',
'Anime4K_AutoDownscalePre_x4.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
'Anime4K: Mode C (HQ)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Upscale_Denoise_CNN_x2_VL.glsl',
'Anime4K_AutoDownscalePre_x2.glsl',
'Anime4K_AutoDownscalePre_x4.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
'Anime4K: Mode A+A (HQ)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_VL.glsl',
'Anime4K_Upscale_CNN_x2_VL.glsl',
'Anime4K_Restore_CNN_M.glsl',
'Anime4K_AutoDownscalePre_x2.glsl',
'Anime4K_AutoDownscalePre_x4.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
'Anime4K: Mode B+B (HQ)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_Soft_VL.glsl',
'Anime4K_Upscale_CNN_x2_VL.glsl',
'Anime4K_AutoDownscalePre_x2.glsl',
'Anime4K_AutoDownscalePre_x4.glsl',
'Anime4K_Restore_CNN_Soft_M.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
'Anime4K: Mode C+A (HQ)': [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Upscale_Denoise_CNN_x2_VL.glsl',
'Anime4K_AutoDownscalePre_x2.glsl',
'Anime4K_AutoDownscalePre_x4.glsl',
'Anime4K_Restore_CNN_M.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
],
},
"CINEMA": {
'FidelityFX Super Resolution': ['FSR.glsl'],
'SSimSuperRes (Natural)': ['SSimSuperRes.glsl'],
}
};
export type ShaderCategory = keyof typeof SHADER_PROFILES;
export type ShaderMode = string;
class ShaderService {
private static instance: ShaderService;
private initialized = false;
private shaderDir = `${FileSystem.documentDirectory}shaders/`;
private constructor() {}
public static getInstance(): ShaderService {
if (!ShaderService.instance) {
ShaderService.instance = new ShaderService();
}
return ShaderService.instance;
}
/**
* Check if shaders are available
*/
async checkAvailability(): Promise<boolean> {
try {
const dirInfo = await FileSystem.getInfoAsync(this.shaderDir);
if (!dirInfo.exists) return false;
const files = await FileSystem.readDirectoryAsync(this.shaderDir);
// Ensure essential files exist to avoid false positives on partial extraction
this.initialized = ESSENTIAL_SHADERS.every((name) => files.includes(name));
return this.initialized;
} catch {
return false;
}
}
/**
* Initialize service (called on app/player start)
*/
async initialize(): Promise<void> {
await this.checkAvailability();
}
// Manually update initialization state (e.g. after download)
setInitialized(value: boolean) {
this.initialized = value;
}
/**
* Delete extracted shaders (troubleshooting)
*/
async clearShaders(): Promise<void> {
try {
const dirInfo = await FileSystem.getInfoAsync(this.shaderDir);
if (dirInfo.exists) {
await FileSystem.deleteAsync(this.shaderDir, { idempotent: true });
this.initialized = false;
logger.info('[ShaderService] Shaders cleared');
}
} catch (e) {
logger.error('[ShaderService] Failed to clear shaders', e);
}
}
/**
* Extract shaders from bundled assets
*/
async downloadShaders(onProgress?: (progress: number) => void): Promise<boolean> {
try {
if (onProgress) onProgress(0.1);
const dirInfo = await FileSystem.getInfoAsync(this.shaderDir);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(this.shaderDir, { intermediates: true });
}
// 1. Load local asset
logger.info('[ShaderService] Loading bundled shader pack...');
const asset = Asset.fromModule(SHADER_ZIP);
await asset.downloadAsync();
if (onProgress) onProgress(0.3);
// Use copyAsync to avoid loading entire binary into memory as string
const tempZip = `${FileSystem.cacheDirectory}shaders_temp.zip`;
await FileSystem.copyAsync({
from: asset.localUri || asset.uri,
to: tempZip
});
// 2. Read Zip (Small file so Base64 is okay, but using safer approach)
const zipContent = await FileSystem.readAsStringAsync(tempZip, { encoding: FileSystem.EncodingType.Base64 });
const zip = await JSZip.loadAsync(zipContent, { base64: true });
if (onProgress) onProgress(0.6);
logger.info(`[ShaderService] Extracting ${Object.keys(zip.files).length} files...`);
// 3. Extract Files
const files = Object.keys(zip.files);
let extractedCount = 0;
for (const filename of files) {
if (!zip.files[filename].dir) {
const base64 = await zip.files[filename].async('base64');
await FileSystem.writeAsStringAsync(
`${this.shaderDir}${filename}`,
base64,
{ encoding: FileSystem.EncodingType.Base64 }
);
}
extractedCount++;
if (onProgress) onProgress(0.6 + (extractedCount / files.length) * 0.4);
}
// Clean up temp zip
await FileSystem.deleteAsync(tempZip, { idempotent: true });
this.initialized = true;
logger.info('[ShaderService] Shaders installed successfully');
return true;
} catch (error) {
logger.error('[ShaderService] Extraction failed', error);
return false;
}
}
/**
* Get the MPV configuration string for the selected profile
*/
getShaderConfig(profileName: string, category: ShaderCategory = 'MID-END'): string {
if (!this.initialized || profileName === 'none') {
return "";
}
const profileList = SHADER_PROFILES[category] as Record<string, string[]>;
const shaderNames = profileList[profileName];
if (!shaderNames) {
// Fallback checks for other categories if simple name passed
for (const cat of Object.keys(SHADER_PROFILES)) {
const list = SHADER_PROFILES[cat as ShaderCategory] as Record<string, string[]>;
if (list[profileName]) {
const cleanDir = this.shaderDir.replace('file://', '');
return list[profileName].map(name => `${cleanDir}${name}`).join(':');
}
}
return "";
}
// Map filenames to full local paths and join with ':' (MPV separator)
// IMPORTANT: Strip 'file://' prefix for MPV native path compatibility
const cleanDir = this.shaderDir.replace('file://', '');
const config = shaderNames
.map(name => `${cleanDir}${name}`)
.join(':');
logger.info(`[ShaderService] Generated config for ${profileName}:`, config);
return config;
}
}
export const shaderService = ShaderService.getInstance();