mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 09:35:42 +00:00
Merge branch 'main' of https://github.com/tapframe/NuvioStreaming
This commit is contained in:
commit
682c429f1a
17 changed files with 1304 additions and 35 deletions
25
README.md
25
README.md
|
|
@ -11,16 +11,16 @@
|
|||
[![License][license-shield]][license-url]
|
||||
|
||||
<p>
|
||||
A modern media hub built with React Native and Expo.
|
||||
A modern media hub for Android and iOS built with React Native and Expo.
|
||||
<br />
|
||||
Stremio Addon ecosystem • Cross-platform • Offline metadata & sync
|
||||
Stremio Addon ecosystem • Cross-platform
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
## About
|
||||
|
||||
Nuvio Media Hub is a cross-platform app for managing, discovering, and streaming your media via a flexible addon ecosystem. Built with React Native and Expo.
|
||||
Nuvio Media Hub is a cross-platform app for managing and discovering media, with a playback-focused interface that can integrate with the Stremio addon ecosystem through user-installed extensions.
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -30,9 +30,9 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
|
|||
|
||||
### iOS
|
||||
|
||||
* [TestFlight](https://testflight.apple.com/join/QkKMGRqp)
|
||||
* [AltStore](https://tinyurl.com/NuvioAltstore)
|
||||
* [SideStore](https://tinyurl.com/NuvioSidestore)
|
||||
- [TestFlight](https://testflight.apple.com/join/QkKMGRqp)
|
||||
- [AltStore](https://tinyurl.com/NuvioAltstore)
|
||||
- [SideStore](https://tinyurl.com/NuvioSidestore)
|
||||
|
||||
**Manual source:** `https://raw.githubusercontent.com/tapframe/NuvioStreaming/main/nuvio-source.json`
|
||||
|
||||
|
|
@ -41,7 +41,8 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
|
|||
```bash
|
||||
git clone https://github.com/tapframe/NuvioStreaming.git
|
||||
cd NuvioStreaming
|
||||
npm install
|
||||
npm install --legacy-peer-deps
|
||||
npx expo prebuild
|
||||
npx expo run:android
|
||||
# or
|
||||
npx expo run:ios
|
||||
|
|
@ -49,15 +50,17 @@ npx expo run:ios
|
|||
|
||||
## Legal & DMCA
|
||||
|
||||
Nuvio functions solely as a client-side interface for browsing metadata and playing media files provided by user-installed extensions. It does not host, store, or distribute any media content.
|
||||
Nuvio functions solely as a client-side interface for browsing metadata and playing media provided by user-installed extensions and/or user-provided sources. It is intended for content the user owns or is otherwise authorized to access.
|
||||
|
||||
Nuvio is not affiliated with any third-party extensions, catalogs, sources, or content providers. It does not host, store, or distribute any media content.
|
||||
|
||||
For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://tapframe.github.io/NuvioStreaming/#legal)**.
|
||||
|
||||
## Built With
|
||||
|
||||
* React Native
|
||||
* Expo
|
||||
* TypeScript
|
||||
- React Native
|
||||
- Expo
|
||||
- TypeScript
|
||||
|
||||
## Star History
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,19 @@ class MPVView @JvmOverloads constructor(
|
|||
// GPU mode setting: 'gpu', 'gpu-next' (default: gpu)
|
||||
var gpuMode: String = "gpu"
|
||||
|
||||
// GLSL shaders setting (for upscalers)
|
||||
private var glslShadersVal: String? = null
|
||||
|
||||
// Flag to track if onLoad has been fired (prevents multiple fires for HLS streams)
|
||||
private var hasLoadEventFired: Boolean = false
|
||||
|
||||
// Video Equalizer state
|
||||
private var brightnessVal: Int = 0
|
||||
private var contrastVal: Int = 0
|
||||
private var saturationVal: Int = 0
|
||||
private var gammaVal: Int = 0
|
||||
private var hueVal: Int = 0
|
||||
|
||||
// Event listener for React Native
|
||||
var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null
|
||||
var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null
|
||||
|
|
@ -78,6 +88,7 @@ class MPVView @JvmOverloads constructor(
|
|||
MPVLib.addObserver(this)
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
|
||||
observeProperties()
|
||||
applyPostInitSettings()
|
||||
isMpvInitialized = true
|
||||
|
||||
// If a data source was set before surface was ready, load it now
|
||||
|
|
@ -477,6 +488,69 @@ class MPVView @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// Video Equalizer Methods
|
||||
|
||||
fun setBrightness(value: Int) {
|
||||
brightnessVal = value
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting brightness: $value")
|
||||
MPVLib.setPropertyDouble("brightness", value.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
fun setContrast(value: Int) {
|
||||
contrastVal = value
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting contrast: $value")
|
||||
MPVLib.setPropertyDouble("contrast", value.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
fun setSaturation(value: Int) {
|
||||
saturationVal = value
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting saturation: $value")
|
||||
MPVLib.setPropertyDouble("saturation", value.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
fun setGamma(value: Int) {
|
||||
gammaVal = value
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting gamma: $value")
|
||||
MPVLib.setPropertyDouble("gamma", value.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
fun setHue(value: Int) {
|
||||
hueVal = value
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting hue: $value")
|
||||
MPVLib.setPropertyDouble("hue", value.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
fun setGlslShaders(shaders: String?) {
|
||||
glslShadersVal = shaders
|
||||
if (isMpvInitialized) {
|
||||
Log.d(TAG, "Setting glsl-shaders: $shaders")
|
||||
MPVLib.setPropertyString("glsl-shaders", shaders ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPostInitSettings() {
|
||||
Log.d(TAG, "Applying post-init settings: B=$brightnessVal, C=$contrastVal, S=$saturationVal, G=$gammaVal, H=$hueVal, Shaders=$glslShadersVal")
|
||||
MPVLib.setPropertyDouble("brightness", brightnessVal.toDouble())
|
||||
MPVLib.setPropertyDouble("contrast", contrastVal.toDouble())
|
||||
MPVLib.setPropertyDouble("saturation", saturationVal.toDouble())
|
||||
MPVLib.setPropertyDouble("gamma", gammaVal.toDouble())
|
||||
MPVLib.setPropertyDouble("hue", hueVal.toDouble())
|
||||
|
||||
glslShadersVal?.let {
|
||||
MPVLib.setPropertyString("glsl-shaders", it)
|
||||
}
|
||||
}
|
||||
|
||||
// MPVLib.EventObserver implementation
|
||||
|
||||
override fun eventProperty(property: String) {
|
||||
|
|
|
|||
|
|
@ -191,6 +191,11 @@ class MpvPlayerViewManager(
|
|||
view.gpuMode = gpuMode ?: "gpu"
|
||||
}
|
||||
|
||||
@ReactProp(name = "glslShaders")
|
||||
fun setGlslShaders(view: MPVView, glslShaders: String?) {
|
||||
view.setGlslShaders(glslShaders)
|
||||
}
|
||||
|
||||
// Subtitle Styling Props
|
||||
|
||||
@ReactProp(name = "subtitleSize", defaultInt = 48)
|
||||
|
|
@ -238,4 +243,31 @@ class MpvPlayerViewManager(
|
|||
fun setSubtitleAlignment(view: MPVView, align: String?) {
|
||||
view.setSubtitleAlignment(align ?: "center")
|
||||
}
|
||||
|
||||
// Video Equalizer Props
|
||||
|
||||
@ReactProp(name = "brightness", defaultInt = 0)
|
||||
fun setBrightness(view: MPVView, brightness: Int) {
|
||||
view.setBrightness(brightness)
|
||||
}
|
||||
|
||||
@ReactProp(name = "contrast", defaultInt = 0)
|
||||
fun setContrast(view: MPVView, contrast: Int) {
|
||||
view.setContrast(contrast)
|
||||
}
|
||||
|
||||
@ReactProp(name = "saturation", defaultInt = 0)
|
||||
fun setSaturation(view: MPVView, saturation: Int) {
|
||||
view.setSaturation(saturation)
|
||||
}
|
||||
|
||||
@ReactProp(name = "gamma", defaultInt = 0)
|
||||
fun setGamma(view: MPVView, gamma: Int) {
|
||||
view.setGamma(gamma)
|
||||
}
|
||||
|
||||
@ReactProp(name = "hue", defaultInt = 0)
|
||||
fun setHue(view: MPVView, hue: Int) {
|
||||
view.setHue(hue)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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",
|
||||
"i18next": "^25.7.3",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react-native": "~7.3.1",
|
||||
"posthog-react-native": "^4.4.0",
|
||||
|
|
@ -7766,6 +7767,12 @@
|
|||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -8435,6 +8442,18 @@
|
|||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/klaw-sync": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||
|
|
@ -8486,6 +8505,15 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lighthouse-logger": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@
|
|||
"expo-web-browser": "~15.0.8",
|
||||
"i18next": "^25.7.3",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react-native": "~7.3.1",
|
||||
"posthog-react-native": "^4.4.0",
|
||||
|
|
|
|||
|
|
@ -247,6 +247,27 @@ class MPVView @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// Video EQ Properties
|
||||
fun setBrightness(value: Int) {
|
||||
if (isMpvInitialized) MPVLib.setPropertyInt("brightness", value)
|
||||
}
|
||||
|
||||
fun setContrast(value: Int) {
|
||||
if (isMpvInitialized) MPVLib.setPropertyInt("contrast", value)
|
||||
}
|
||||
|
||||
fun setSaturation(value: Int) {
|
||||
if (isMpvInitialized) MPVLib.setPropertyInt("saturation", value)
|
||||
}
|
||||
|
||||
fun setGamma(value: Int) {
|
||||
if (isMpvInitialized) MPVLib.setPropertyInt("gamma", value)
|
||||
}
|
||||
|
||||
fun setHue(value: Int) {
|
||||
if (isMpvInitialized) MPVLib.setPropertyInt("hue", value)
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized")
|
||||
if (isMpvInitialized) {
|
||||
|
|
@ -270,6 +291,21 @@ class MPVView @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun setGlslShaders(paths: String) {
|
||||
Log.d(TAG, "setGlslShaders called with paths: $paths")
|
||||
if (isMpvInitialized) {
|
||||
if (paths.isEmpty()) {
|
||||
Log.d(TAG, "Clearing GLSL shaders")
|
||||
MPVLib.setPropertyString("glsl-shaders", "")
|
||||
} else {
|
||||
Log.d(TAG, "Setting GLSL shaders")
|
||||
// MPV expects a list of paths string like "path1,path2" or specialized list commands
|
||||
// Using setPropertyString on "glsl-shaders" usually overwrites the list
|
||||
MPVLib.setPropertyString("glsl-shaders", paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setResizeMode(mode: String) {
|
||||
Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized")
|
||||
if (isMpvInitialized) {
|
||||
|
|
|
|||
|
|
@ -180,4 +180,35 @@ class MpvPlayerViewManager(
|
|||
view.setHeaders(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Video EQ Props
|
||||
@ReactProp(name = "brightness", defaultInt = 0)
|
||||
fun setBrightness(view: MPVView, value: Int) {
|
||||
view.setBrightness(value)
|
||||
}
|
||||
|
||||
@ReactProp(name = "contrast", defaultInt = 0)
|
||||
fun setContrast(view: MPVView, value: Int) {
|
||||
view.setContrast(value)
|
||||
}
|
||||
|
||||
@ReactProp(name = "saturation", defaultInt = 0)
|
||||
fun setSaturation(view: MPVView, value: Int) {
|
||||
view.setSaturation(value)
|
||||
}
|
||||
|
||||
@ReactProp(name = "gamma", defaultInt = 0)
|
||||
fun setGamma(view: MPVView, value: Int) {
|
||||
view.setGamma(value)
|
||||
}
|
||||
|
||||
@ReactProp(name = "hue", defaultInt = 0)
|
||||
fun setHue(view: MPVView, value: Int) {
|
||||
view.setHue(value)
|
||||
}
|
||||
|
||||
@ReactProp(name = "glslShaders")
|
||||
fun setGlslShaders(view: MPVView, paths: String?) {
|
||||
view.setGlslShaders(paths ?: "")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { SourcesModal } from './modals/SourcesModal';
|
|||
import { EpisodesModal } from './modals/EpisodesModal';
|
||||
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||
import { ErrorModal } from './modals/ErrorModal';
|
||||
import VisualEnhancementModal from './modals/VisualEnhancementModal';
|
||||
import { CustomSubtitles } from './subtitles/CustomSubtitles';
|
||||
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
||||
import SkipIntroButton from './overlays/SkipIntroButton';
|
||||
|
|
@ -57,6 +58,8 @@ import { styles } from './utils/playerStyles';
|
|||
import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils';
|
||||
import { storageService } from '../../services/storageService';
|
||||
import stremioService from '../../services/stremioService';
|
||||
import { shaderService, ShaderMode } from '../../services/shaderService';
|
||||
import { visualEnhancementService, VideoSettings } from '../../services/colorProfileService';
|
||||
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
|
||||
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -81,7 +84,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const playerState = usePlayerState();
|
||||
const modals = usePlayerModals();
|
||||
const speedControl = useSpeedControl();
|
||||
const { settings } = useSettings();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
|
||||
const videoRef = useRef<any>(null);
|
||||
const mpvPlayerRef = useRef<MpvPlayerRef>(null);
|
||||
|
|
@ -145,6 +148,72 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Subtitle sync modal state
|
||||
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||
|
||||
// Shader / Video Enhancement State
|
||||
const [showEnhancementModal, setShowEnhancementModal] = useState(false);
|
||||
const [shaderMode, setShaderModeState] = useState<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
|
||||
const hasAutoSelectedTracks = useRef(false);
|
||||
|
||||
|
|
@ -814,6 +883,13 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
screenDimensions={playerState.screenDimensions}
|
||||
decoderMode={settings.decoderMode}
|
||||
gpuMode={settings.gpuMode}
|
||||
glslShaders={glslShaders}
|
||||
// Color Profile Props
|
||||
brightness={currentVideoSettings.brightness}
|
||||
contrast={currentVideoSettings.contrast}
|
||||
saturation={currentVideoSettings.saturation}
|
||||
gamma={currentVideoSettings.gamma}
|
||||
hue={currentVideoSettings.hue}
|
||||
// Dual video engine props
|
||||
useExoPlayer={useExoPlayer}
|
||||
onCodecError={handleCodecError}
|
||||
|
|
@ -922,6 +998,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
isSubtitleModalOpen={modals.showSubtitleModal}
|
||||
setShowSourcesModal={modals.setShowSourcesModal}
|
||||
setShowEpisodesModal={type === 'series' ? modals.setShowEpisodesModal : undefined}
|
||||
setShowEnhancementModal={setShowEnhancementModal}
|
||||
onSliderValueChange={(val) => { playerState.isDragging.current = true; }}
|
||||
onSlidingStart={() => { playerState.isDragging.current = true; }}
|
||||
onSlidingComplete={(val) => {
|
||||
|
|
@ -1098,6 +1175,17 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
onSelectStream={(stream) => handleSelectStream(stream)}
|
||||
/>
|
||||
|
||||
<VisualEnhancementModal
|
||||
visible={showEnhancementModal}
|
||||
onClose={() => setShowEnhancementModal(false)}
|
||||
shaderMode={shaderMode}
|
||||
setShaderMode={setShaderMode}
|
||||
activeProfile={activeProfile}
|
||||
setProfile={handleSetProfile}
|
||||
customSettings={customSettings}
|
||||
updateCustomSettings={handleUpdateCustomSettings}
|
||||
/>
|
||||
|
||||
<SpeedModal
|
||||
showSpeedModal={modals.showSpeedModal}
|
||||
setShowSpeedModal={modals.setShowSpeedModal}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ export interface MpvPlayerProps {
|
|||
onTracksChanged?: (data: { audioTracks: any[]; subtitleTracks: any[] }) => void;
|
||||
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
|
||||
gpuMode?: 'gpu' | 'gpu-next';
|
||||
glslShaders?: string;
|
||||
// Video EQ Props
|
||||
brightness?: number;
|
||||
contrast?: number;
|
||||
saturation?: number;
|
||||
gamma?: number;
|
||||
hue?: number;
|
||||
// Subtitle Styling
|
||||
subtitleSize?: number;
|
||||
subtitleColor?: string;
|
||||
|
|
@ -121,6 +128,12 @@ const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
|
|||
onTracksChanged={handleTracksChanged}
|
||||
decoderMode={props.decoderMode ?? 'auto'}
|
||||
gpuMode={props.gpuMode ?? 'gpu'}
|
||||
glslShaders={props.glslShaders}
|
||||
brightness={props.brightness ?? 0}
|
||||
contrast={props.contrast ?? 0}
|
||||
saturation={props.saturation ?? 0}
|
||||
gamma={props.gamma ?? 0}
|
||||
hue={props.hue ?? 0}
|
||||
// Subtitle Styling
|
||||
subtitleSize={props.subtitleSize ?? 48}
|
||||
subtitleColor={props.subtitleColor ?? '#FFFFFF'}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ interface VideoSurfaceProps {
|
|||
selectedTextTrack?: SelectedTrack;
|
||||
decoderMode?: 'auto' | 'sw' | 'hw' | 'hw+';
|
||||
gpuMode?: 'gpu' | 'gpu-next';
|
||||
glslShaders?: string;
|
||||
// Video EQ Props
|
||||
brightness?: number;
|
||||
contrast?: number;
|
||||
saturation?: number;
|
||||
gamma?: number;
|
||||
hue?: number;
|
||||
|
||||
// Dual Engine Props
|
||||
useExoPlayer?: boolean;
|
||||
|
|
@ -105,6 +112,12 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
selectedTextTrack,
|
||||
decoderMode,
|
||||
gpuMode,
|
||||
glslShaders,
|
||||
brightness,
|
||||
contrast,
|
||||
saturation,
|
||||
gamma,
|
||||
hue,
|
||||
// Dual Engine
|
||||
useExoPlayer = true,
|
||||
onCodecError,
|
||||
|
|
@ -401,6 +414,12 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
onTracksChanged={onTracksChanged}
|
||||
decoderMode={decoderMode}
|
||||
gpuMode={gpuMode}
|
||||
glslShaders={glslShaders}
|
||||
brightness={brightness}
|
||||
contrast={contrast}
|
||||
saturation={saturation}
|
||||
gamma={gamma}
|
||||
hue={hue}
|
||||
subtitleSize={subtitleSize}
|
||||
subtitleColor={subtitleColor}
|
||||
subtitleBackgroundOpacity={subtitleBackgroundOpacity}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ interface PlayerControlsProps {
|
|||
isSubtitleModalOpen?: boolean;
|
||||
setShowSourcesModal?: (show: boolean) => void;
|
||||
setShowEpisodesModal?: (show: boolean) => void;
|
||||
setShowEnhancementModal?: (show: boolean) => void;
|
||||
// Slider-specific props
|
||||
onSliderValueChange: (value: number) => void;
|
||||
onSlidingStart: () => void;
|
||||
|
|
@ -95,6 +96,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
isSubtitleModalOpen,
|
||||
setShowSourcesModal,
|
||||
setShowEpisodesModal,
|
||||
setShowEnhancementModal,
|
||||
onSliderValueChange,
|
||||
onSlidingStart,
|
||||
onSlidingComplete,
|
||||
|
|
@ -391,6 +393,21 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
/>
|
||||
</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}>
|
||||
<Ionicons name="close" size={closeIconSize} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
|
@ -592,7 +609,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
<View style={styles.bottomControls} pointerEvents="box-none">
|
||||
{/* Center Buttons Container with rounded background - wraps all buttons */}
|
||||
<View style={styles.centerControlsContainer} pointerEvents="box-none">
|
||||
{/* Left Side: Aspect Ratio Button */}
|
||||
{/* Aspect Ratio Button */}
|
||||
<TouchableOpacity style={styles.iconButton} onPress={cycleAspectRatio}>
|
||||
<Ionicons name="expand-outline" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
|
@ -605,16 +622,6 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
<Ionicons name="text" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Change Source Button */}
|
||||
{setShowSourcesModal && (
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={() => setShowSourcesModal(true)}
|
||||
>
|
||||
<Ionicons name="cloud-outline" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Playback Speed Button */}
|
||||
<TouchableOpacity style={styles.iconButton} onPress={() => setShowSpeedModal(true)}>
|
||||
<Ionicons name="speedometer-outline" size={24} color="white" />
|
||||
|
|
@ -633,6 +640,16 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Change Source Button */}
|
||||
{setShowSourcesModal && (
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={() => setShowSourcesModal(true)}
|
||||
>
|
||||
<Ionicons name="cloud-outline" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Submit Intro Button */}
|
||||
{season !== undefined && episode !== undefined && settings.introSubmitEnabled && settings.introDbApiKey && (
|
||||
<TouchableOpacity
|
||||
|
|
@ -647,7 +664,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Right Side: Episodes Button */}
|
||||
{/* Episodes Button */}
|
||||
{setShowEpisodesModal && (
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
|
|
@ -659,6 +676,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
</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)
|
||||
subtitleSourcePreference: 'internal' | 'external' | 'any'; // Prefer internal (embedded), external (addon), or any
|
||||
enableSubtitleAutoSelect: boolean; // Auto-select subtitles based on preferences
|
||||
// Upscaler settings
|
||||
enableShaders: boolean; // Enable/disable real-time upscalers
|
||||
shaderProfile: 'MID-END' | 'HIGH-END'; // Hardware profile for upscalers
|
||||
defaultShaderMode: string; // Persisted shader preset (e.g. 'Anime4K: Mode A')
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
|
|
@ -203,6 +207,10 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
preferredAudioLanguage: 'en', // Default to English audio
|
||||
subtitleSourcePreference: 'internal', // Prefer internal/embedded subtitles first
|
||||
enableSubtitleAutoSelect: true, // Auto-select subtitles by default
|
||||
// Upscaler defaults
|
||||
enableShaders: false,
|
||||
shaderProfile: 'MID-END',
|
||||
defaultShaderMode: 'none',
|
||||
};
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'app_settings';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { useTranslation } from 'react-i18next';
|
|||
import { SvgXml } from 'react-native-svg';
|
||||
import { toastService } from '../../services/toastService';
|
||||
import { introService } from '../../services/introService';
|
||||
import { shaderService } from '../../services/shaderService';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
|
|
@ -69,7 +71,6 @@ interface PlaybackSettingsContentProps {
|
|||
|
||||
/**
|
||||
* Reusable PlaybackSettingsContent component
|
||||
* Can be used inline (tablets) or wrapped in a screen (mobile)
|
||||
*/
|
||||
export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = ({ isTablet = false }) => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
|
@ -82,8 +83,39 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
const [apiKeyInput, setApiKeyInput] = useState(settings?.introDbApiKey || '');
|
||||
const [isVerifyingKey, setIsVerifyingKey] = useState(false);
|
||||
|
||||
// Video Enhancement Assets state
|
||||
const [isEnhancementDownloaded, setIsEnhancementDownloaded] = useState(false);
|
||||
const [isDownloadingEnhancement, setIsDownloadingEnhancement] = useState(false);
|
||||
const [enhancementProgress, setEnhancementProgress] = useState(0);
|
||||
|
||||
const isMounted = useRef(true);
|
||||
|
||||
const checkEnhancementStatus = useCallback(async () => {
|
||||
const available = await shaderService.checkAvailability();
|
||||
setIsEnhancementDownloaded(available);
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
checkEnhancementStatus();
|
||||
}, [checkEnhancementStatus])
|
||||
);
|
||||
|
||||
const handleDownloadEnhancements = async () => {
|
||||
setIsDownloadingEnhancement(true);
|
||||
const success = await shaderService.downloadShaders((p) => setEnhancementProgress(p));
|
||||
if (isMounted.current) {
|
||||
setIsDownloadingEnhancement(false);
|
||||
if (success) {
|
||||
shaderService.setInitialized(true); // Force update service state
|
||||
setIsEnhancementDownloaded(true);
|
||||
toastService.success(t('settings.enhancement_download_success', { defaultValue: 'Enhancement assets installed!' }));
|
||||
} else {
|
||||
toastService.error(t('settings.enhancement_download_failed', { defaultValue: 'Failed to install assets' }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
|
|
@ -122,13 +154,10 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
try {
|
||||
const res = await fetch(INTRODB_LOGO_URI);
|
||||
let xml = await res.text();
|
||||
// Inline CSS class-based styles because react-native-svg doesn't support <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-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-2"/g, 'fill="url(#linear-gradient-3)" opacity=".53"');
|
||||
// Remove the <style> block to avoid unsupported CSS
|
||||
xml = xml.replace(/<style>[\s\S]*?<\/style>/, '');
|
||||
if (!cancelled) setIntroDbLogoXml(xml);
|
||||
} catch {
|
||||
|
|
@ -156,7 +185,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
const languageSnapPoints = useMemo(() => ['70%'], []);
|
||||
const sourceSnapPoints = useMemo(() => ['45%'], []);
|
||||
|
||||
// Handlers to present sheets - ensure only one is open at a time
|
||||
// Handlers
|
||||
const openAudioLanguageSheet = useCallback(() => {
|
||||
subtitleLanguageSheetRef.current?.dismiss();
|
||||
subtitleSourceSheetRef.current?.dismiss();
|
||||
|
|
@ -198,7 +227,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
return t('settings.options.internal_first');
|
||||
};
|
||||
|
||||
// Render backdrop for bottom sheets
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop
|
||||
|
|
@ -311,6 +339,143 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
)}
|
||||
</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 */}
|
||||
<SettingsCard title={t('settings.sections.audio_subtitles')} isTablet={isTablet}>
|
||||
<SettingItem
|
||||
|
|
@ -402,7 +567,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Audio Language Bottom Sheet */}
|
||||
<BottomSheetModal
|
||||
ref={audioLanguageSheetRef}
|
||||
index={0}
|
||||
|
|
@ -443,7 +607,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
|
||||
{/* Subtitle Language Bottom Sheet */}
|
||||
<BottomSheetModal
|
||||
ref={subtitleLanguageSheetRef}
|
||||
index={0}
|
||||
|
|
@ -484,7 +647,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
|
||||
{/* Subtitle Source Priority Bottom Sheet */}
|
||||
<BottomSheetModal
|
||||
ref={subtitleSourceSheetRef}
|
||||
index={0}
|
||||
|
|
@ -534,7 +696,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
|
|||
|
||||
/**
|
||||
* PlaybackSettingsScreen - Wrapper for mobile navigation
|
||||
* Uses PlaybackSettingsContent internally
|
||||
*/
|
||||
const PlaybackSettingsScreen: React.FC = () => {
|
||||
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