mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
commit
9faf4d6337
17 changed files with 1290 additions and 25 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
assets/shaders/shaders_new.zip
Normal file
BIN
assets/shaders/shaders_new.zip
Normal file
Binary file not shown.
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 ?: "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
366
src/components/player/modals/VisualEnhancementModal.tsx
Normal file
366
src/components/player/modals/VisualEnhancementModal.tsx
Normal 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;
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
117
src/services/colorProfileService.ts
Normal file
117
src/services/colorProfileService.ts
Normal 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();
|
||||||
274
src/services/shaderService.ts
Normal file
274
src/services/shaderService.ts
Normal 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();
|
||||||
Loading…
Reference in a new issue