import 'dart:async'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../mpv/player/player.dart'; import '../utils/app_logger.dart'; /// Generates and manages an ambient lighting GLSL shader that fills letterbox/pillarbox /// bars with a blurred, dimmed version of the video edges. /// /// Uses video-aspect-override to fill the window (eliminating black bars), then /// a GLSL shader composites the sharp original video centered at correct aspect /// over a blurred background. /// /// The shader uses MPV's built-in `input_size` and `target_size` uniforms to /// dynamically compute the video rect position. On window resize, only /// `video-aspect-override` needs updating — no shader regeneration required. class AmbientLightingService { final Player _player; String? _shaderPath; bool _enabled = false; /// Brightness multiplier for the blurred background (0.0-1.0). static const double _brightness = 0.5; AmbientLightingService(this._player); bool get isEnabled => _enabled; bool get isSupported => _player.playerType == 'mpv' && !Platform.isIOS; /// Enable ambient lighting effect. /// /// [videoAspect] - the source video's display aspect ratio (width/height). /// [outputAspect] - the player widget's aspect ratio (width/height). Future enable(double videoAspect, double outputAspect) async { if (!isSupported) return; try { _shaderPath ??= await _writeShaderToTemp(_generateShader()); appLogger.d('AmbientLightingService: Shader path: $_shaderPath'); await _player.setProperty('video-aspect-override', outputAspect.toString()); await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]); _enabled = true; appLogger.d('AmbientLightingService: Enabled (video=$videoAspect, output=$outputAspect)'); } catch (e, st) { appLogger.w('AmbientLightingService: Failed to enable', error: e, stackTrace: st); } } /// Disable ambient lighting effect and restore normal letterboxing. Future disable() async { if (!_enabled) return; try { if (_shaderPath != null) { await _player.command(['change-list', 'glsl-shaders', 'remove', _shaderPath!]); } await _player.setProperty('video-aspect-override', 'no'); _enabled = false; appLogger.d('AmbientLightingService: Disabled'); } catch (e, st) { appLogger.w('AmbientLightingService: Failed to disable', error: e, stackTrace: st); } } /// Re-append the ambient lighting shader to the chain. /// Called by ShaderService after it rebuilds the shader chain (clr + append). Future reappendShader() async { if (!_enabled || _shaderPath == null) return; await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]); } /// Update video-aspect-override when the window resizes. /// The shader adapts automatically via dynamic `target_size` uniform. void updateOutputAspect(double outputAspect) { if (!_enabled) return; unawaited(() async { try { await _player.setProperty('video-aspect-override', outputAspect.toString()); } catch (error, stackTrace) { appLogger.w('AmbientLightingService: Failed to update output aspect', error: error, stackTrace: stackTrace); } }()); } /// Generate a static multi-pass GLSL shader. /// /// Uses MPV's built-in `input_size` (video dimensions) and `target_size` /// (output dimensions) uniforms which update automatically on resize. /// No baked-in aspect constants — fully adaptive. /// /// Pipeline (all MAIN hooks): /// 1. Save original video as ORIGINAL /// 2. Downscale to 1/8 as SMALL (center 60% zoom) /// 3-5. Three Kawase blur passes at 1/8 -> BLUR8C /// 6. Downscale BLUR8C to 1/64 as TINY /// 7-8. Two more Kawase blur passes at 1/64 -> GLOW /// 9. Composite: video rect -> ORIGINAL, bars -> GLOW String _generateShader() { final br = _brightness.toStringAsFixed(2); final buf = StringBuffer(); // Pass 1: Save original video buf.writeln('//!HOOK MAIN'); buf.writeln('//!BIND HOOKED'); buf.writeln('//!SAVE ORIGINAL'); buf.writeln('//!DESC Ambient Lighting Save'); buf.writeln('vec4 hook() {'); buf.writeln(' return HOOKED_tex(HOOKED_pos);'); buf.writeln('}'); buf.writeln(); // Pass 2: Downscale to 1/8 with center zoom. buf.writeln('//!HOOK MAIN'); buf.writeln('//!BIND ORIGINAL'); buf.writeln('//!SAVE SMALL'); buf.writeln('//!WIDTH ORIGINAL.w 8 /'); buf.writeln('//!HEIGHT ORIGINAL.h 8 /'); buf.writeln('//!DESC Ambient Lighting Downscale'); buf.writeln('vec4 hook() {'); buf.writeln(' vec2 uv = ORIGINAL_pos * 0.6 + 0.2;'); buf.writeln(' return ORIGINAL_tex(uv);'); buf.writeln('}'); buf.writeln(); // Pass 3-5: Three Kawase blur passes at 1/8 resolution. const blur8Steps = [ ('SMALL', 'BLUR8A', '2.0', 'Blur1'), ('BLUR8A', 'BLUR8B', '6.0', 'Blur2'), ('BLUR8B', 'BLUR8C', '12.0', 'Blur3'), ]; for (final step in blur8Steps) { _writeKawasePass(buf, step.$1, step.$2, step.$3, step.$4); } // Pass 6: Downscale the already-blurred 1/8 texture to 1/64. buf.writeln('//!HOOK MAIN'); buf.writeln('//!BIND BLUR8C'); buf.writeln('//!SAVE TINY'); buf.writeln('//!WIDTH BLUR8C.w 8 /'); buf.writeln('//!HEIGHT BLUR8C.h 8 /'); buf.writeln('//!DESC Ambient Lighting Downscale2'); buf.writeln('vec4 hook() {'); buf.writeln(' return BLUR8C_tex(BLUR8C_pos);'); buf.writeln('}'); buf.writeln(); // Pass 7-8: Two more Kawase blur passes at 1/64 for maximum diffusion. const blur64Steps = [('TINY', 'GLOW1', '3.0', 'Blur4'), ('GLOW1', 'GLOW', '6.0', 'Blur5')]; for (final step in blur64Steps) { _writeKawasePass(buf, step.$1, step.$2, step.$3, step.$4); } // Pass 9: Composite — no //!SAVE so this replaces MAIN. // Uses built-in input_size (video coded dims) and target_size (output dims) // to dynamically compute where the video rect sits. These auto-update on resize. buf.writeln('//!HOOK MAIN'); buf.writeln('//!BIND ORIGINAL'); buf.writeln('//!BIND GLOW'); buf.writeln('//!DESC Ambient Lighting Composite'); buf.writeln('vec4 hook() {'); buf.writeln(' float vid_aspect = input_size.x / input_size.y;'); buf.writeln(' float out_aspect = target_size.x / target_size.y;'); buf.writeln(); buf.writeln(' vec2 scale, off;'); buf.writeln(' if (vid_aspect > out_aspect) {'); buf.writeln(' float s = out_aspect / vid_aspect;'); buf.writeln(' scale = vec2(1.0, s);'); buf.writeln(' off = vec2(0.0, (1.0 - s) * 0.5);'); buf.writeln(' } else {'); buf.writeln(' float s = vid_aspect / out_aspect;'); buf.writeln(' scale = vec2(s, 1.0);'); buf.writeln(' off = vec2((1.0 - s) * 0.5, 0.0);'); buf.writeln(' }'); buf.writeln(); buf.writeln(' vec2 vid_pos = (ORIGINAL_pos - off) / scale;'); buf.writeln(); buf.writeln(' if (all(greaterThanEqual(vid_pos, vec2(0.0))) &&'); buf.writeln(' all(lessThanEqual(vid_pos, vec2(1.0)))) {'); buf.writeln(' return ORIGINAL_tex(vid_pos);'); buf.writeln(' }'); buf.writeln(); buf.writeln(' return GLOW_tex(ORIGINAL_pos) * $br;'); buf.writeln('}'); return buf.toString(); } void _writeKawasePass(StringBuffer buf, String input, String output, String offset, String desc) { buf.writeln('//!HOOK MAIN'); buf.writeln('//!BIND $input'); buf.writeln('//!SAVE $output'); buf.writeln('//!WIDTH $input.w'); buf.writeln('//!HEIGHT $input.h'); buf.writeln('//!DESC Ambient Lighting $desc'); buf.writeln('vec4 hook() {'); buf.writeln(' vec2 ps = ${input}_pt;'); buf.writeln(' vec4 s = ${input}_tex(${input}_pos + vec2( $offset, $offset) * ps)'); buf.writeln(' + ${input}_tex(${input}_pos + vec2( $offset, -$offset) * ps)'); buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, $offset) * ps)'); buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, -$offset) * ps);'); buf.writeln(' return s * 0.25;'); buf.writeln('}'); buf.writeln(); } /// Write the shader to a temp file and return the path. Future _writeShaderToTemp(String shader) async { final cacheDir = await getTemporaryDirectory(); final shaderDir = Directory(path.join(cacheDir.path, 'shaders')); if (!shaderDir.existsSync()) { shaderDir.createSync(recursive: true); } final file = File(path.join(shaderDir.path, 'ambient_lighting.glsl')); await file.writeAsString(shader); return file.path; } }