feat: add ambient lighting effect

close #477
This commit is contained in:
edde746
2026-02-18 06:51:20 +01:00
parent 83e04b64da
commit 756eb8d6f0
27 changed files with 528 additions and 26 deletions
+3
View File
@@ -320,6 +320,9 @@
"versionsButton": "Videoversionen",
"pipButton": "Bild-in-Bild Modus",
"aspectRatioButton": "Seitenverhältnis",
"ambientLighting": "Umgebungsbeleuchtung",
"ambientLightingOn": "Umgebungsbeleuchtung aktivieren",
"ambientLightingOff": "Umgebungsbeleuchtung deaktivieren",
"fullscreenButton": "Vollbild aktivieren",
"exitFullscreenButton": "Vollbild verlassen",
"alwaysOnTopButton": "Immer im Vordergrund",
+3
View File
@@ -320,6 +320,9 @@
"versionsButton": "Video versions",
"pipButton": "Picture-in-Picture mode",
"aspectRatioButton": "Aspect ratio",
"ambientLighting": "Ambient lighting",
"ambientLightingOn": "Enable ambient lighting",
"ambientLightingOff": "Disable ambient lighting",
"fullscreenButton": "Enter fullscreen",
"exitFullscreenButton": "Exit fullscreen",
"alwaysOnTopButton": "Always on top",
+3
View File
@@ -320,6 +320,9 @@
"versionsButton": "Versiones de video",
"pipButton": "Modo PiP (Imagen en Imagen)",
"aspectRatioButton": "Relación de aspecto",
"ambientLighting": "Iluminación ambiental",
"ambientLightingOn": "Activar iluminación ambiental",
"ambientLightingOff": "Desactivar iluminación ambiental",
"fullscreenButton": "Entrar en pantalla completa",
"exitFullscreenButton": "Salir de pantalla completa",
"alwaysOnTopButton": "Siempre visible",
+3
View File
@@ -320,6 +320,9 @@
"versionsButton": "Versions vidéo",
"pipButton": "Mode PiP (Picture-in-Picture)",
"aspectRatioButton": "Format d'image",
"ambientLighting": "Éclairage ambiant",
"ambientLightingOn": "Activer l'éclairage ambiant",
"ambientLightingOff": "Désactiver l'éclairage ambiant",
"fullscreenButton": "Passer en mode plein écran",
"exitFullscreenButton": "Quitter le mode plein écran",
"alwaysOnTopButton": "Always on top",
+3
View File
@@ -320,6 +320,9 @@
"versionsButton": "Versioni video",
"pipButton": "Modalità Picture-in-Picture",
"aspectRatioButton": "Proporzioni",
"ambientLighting": "Illuminazione ambientale",
"ambientLightingOn": "Attiva illuminazione ambientale",
"ambientLightingOff": "Disattiva illuminazione ambientale",
"fullscreenButton": "Attiva schermo intero",
"exitFullscreenButton": "Esci da schermo intero",
"alwaysOnTopButton": "Sempre in primo piano",
+3
View File
@@ -320,6 +320,9 @@
"versionsButton": "동영상 버전",
"pipButton": "픽처 인 픽처 모드",
"aspectRatioButton": "화면비율",
"ambientLighting": "주변 조명",
"ambientLightingOn": "주변 조명 켜기",
"ambientLightingOff": "주변 조명 끄기",
"fullscreenButton": "전체화면",
"exitFullscreenButton": "전체화면 종료",
"alwaysOnTopButton": "창 최상위 고정",
+3
View File
@@ -320,6 +320,9 @@
"versionsButton": "Videoversies",
"pipButton": "Beeld-in-beeld modus",
"aspectRatioButton": "Beeldverhouding",
"ambientLighting": "Omgevingsverlichting",
"ambientLightingOn": "Omgevingsverlichting inschakelen",
"ambientLightingOff": "Omgevingsverlichting uitschakelen",
"fullscreenButton": "Volledig scherm activeren",
"exitFullscreenButton": "Volledig scherm verlaten",
"alwaysOnTopButton": "Altijd bovenop",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 9
/// Strings: 6642 (738 per locale)
/// Strings: 6669 (741 per locale)
///
/// Built on 2026-02-16 at 00:20 UTC
/// Built on 2026-02-18 at 03:36 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+8 -2
View File
@@ -474,6 +474,9 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn {
@override String get versionsButton => 'Videoversionen';
@override String get pipButton => 'Bild-in-Bild Modus';
@override String get aspectRatioButton => 'Seitenverhältnis';
@override String get ambientLighting => 'Umgebungsbeleuchtung';
@override String get ambientLightingOn => 'Umgebungsbeleuchtung aktivieren';
@override String get ambientLightingOff => 'Umgebungsbeleuchtung deaktivieren';
@override String get fullscreenButton => 'Vollbild aktivieren';
@override String get exitFullscreenButton => 'Vollbild verlassen';
@override String get alwaysOnTopButton => 'Immer im Vordergrund';
@@ -1517,6 +1520,9 @@ extension on TranslationsDe {
'videoControls.versionsButton' => 'Videoversionen',
'videoControls.pipButton' => 'Bild-in-Bild Modus',
'videoControls.aspectRatioButton' => 'Seitenverhältnis',
'videoControls.ambientLighting' => 'Umgebungsbeleuchtung',
'videoControls.ambientLightingOn' => 'Umgebungsbeleuchtung aktivieren',
'videoControls.ambientLightingOff' => 'Umgebungsbeleuchtung deaktivieren',
'videoControls.fullscreenButton' => 'Vollbild aktivieren',
'videoControls.exitFullscreenButton' => 'Vollbild verlassen',
'videoControls.alwaysOnTopButton' => 'Immer im Vordergrund',
@@ -1734,11 +1740,11 @@ extension on TranslationsDe {
'liveTv.overnight' => 'Nacht',
'liveTv.morning' => 'Morgen',
'liveTv.daytime' => 'Tagsüber',
_ => null,
} ?? switch (path) {
'liveTv.evening' => 'Abend',
'liveTv.lateNight' => 'Spätnacht',
'liveTv.whatsOn' => 'Jetzt im TV',
_ => null,
} ?? switch (path) {
'liveTv.watchChannel' => 'Kanal ansehen',
'downloads.title' => 'Downloads',
'downloads.manage' => 'Verwalten',
+14 -2
View File
@@ -1025,6 +1025,15 @@ class TranslationsVideoControlsEn {
/// en: 'Aspect ratio'
String get aspectRatioButton => 'Aspect ratio';
/// en: 'Ambient lighting'
String get ambientLighting => 'Ambient lighting';
/// en: 'Enable ambient lighting'
String get ambientLightingOn => 'Enable ambient lighting';
/// en: 'Disable ambient lighting'
String get ambientLightingOff => 'Disable ambient lighting';
/// en: 'Enter fullscreen'
String get fullscreenButton => 'Enter fullscreen';
@@ -3000,6 +3009,9 @@ extension on Translations {
'videoControls.versionsButton' => 'Video versions',
'videoControls.pipButton' => 'Picture-in-Picture mode',
'videoControls.aspectRatioButton' => 'Aspect ratio',
'videoControls.ambientLighting' => 'Ambient lighting',
'videoControls.ambientLightingOn' => 'Enable ambient lighting',
'videoControls.ambientLightingOff' => 'Disable ambient lighting',
'videoControls.fullscreenButton' => 'Enter fullscreen',
'videoControls.exitFullscreenButton' => 'Exit fullscreen',
'videoControls.alwaysOnTopButton' => 'Always on top',
@@ -3217,11 +3229,11 @@ extension on Translations {
'liveTv.overnight' => 'Overnight',
'liveTv.morning' => 'Morning',
'liveTv.daytime' => 'Daytime',
_ => null,
} ?? switch (path) {
'liveTv.evening' => 'Evening',
'liveTv.lateNight' => 'Late Night',
'liveTv.whatsOn' => 'What\'s On',
_ => null,
} ?? switch (path) {
'liveTv.watchChannel' => 'Watch Channel',
'collections.title' => 'Collections',
'collections.collection' => 'Collection',
+8 -2
View File
@@ -474,6 +474,9 @@ class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn {
@override String get versionsButton => 'Versiones de video';
@override String get pipButton => 'Modo PiP (Imagen en Imagen)';
@override String get aspectRatioButton => 'Relación de aspecto';
@override String get ambientLighting => 'Iluminación ambiental';
@override String get ambientLightingOn => 'Activar iluminación ambiental';
@override String get ambientLightingOff => 'Desactivar iluminación ambiental';
@override String get fullscreenButton => 'Entrar en pantalla completa';
@override String get exitFullscreenButton => 'Salir de pantalla completa';
@override String get alwaysOnTopButton => 'Siempre visible';
@@ -1517,6 +1520,9 @@ extension on TranslationsEs {
'videoControls.versionsButton' => 'Versiones de video',
'videoControls.pipButton' => 'Modo PiP (Imagen en Imagen)',
'videoControls.aspectRatioButton' => 'Relación de aspecto',
'videoControls.ambientLighting' => 'Iluminación ambiental',
'videoControls.ambientLightingOn' => 'Activar iluminación ambiental',
'videoControls.ambientLightingOff' => 'Desactivar iluminación ambiental',
'videoControls.fullscreenButton' => 'Entrar en pantalla completa',
'videoControls.exitFullscreenButton' => 'Salir de pantalla completa',
'videoControls.alwaysOnTopButton' => 'Siempre visible',
@@ -1734,11 +1740,11 @@ extension on TranslationsEs {
'liveTv.overnight' => 'Madrugada',
'liveTv.morning' => 'Mañana',
'liveTv.daytime' => 'Día',
_ => null,
} ?? switch (path) {
'liveTv.evening' => 'Noche',
'liveTv.lateNight' => 'Trasnoche',
'liveTv.whatsOn' => 'En emisión',
_ => null,
} ?? switch (path) {
'liveTv.watchChannel' => 'Ver canal',
'collections.title' => 'Colecciones',
'collections.collection' => 'Colección',
+8 -2
View File
@@ -474,6 +474,9 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn {
@override String get versionsButton => 'Versions vidéo';
@override String get pipButton => 'Mode PiP (Picture-in-Picture)';
@override String get aspectRatioButton => 'Format d\'image';
@override String get ambientLighting => 'Éclairage ambiant';
@override String get ambientLightingOn => 'Activer l\'éclairage ambiant';
@override String get ambientLightingOff => 'Désactiver l\'éclairage ambiant';
@override String get fullscreenButton => 'Passer en mode plein écran';
@override String get exitFullscreenButton => 'Quitter le mode plein écran';
@override String get alwaysOnTopButton => 'Always on top';
@@ -1517,6 +1520,9 @@ extension on TranslationsFr {
'videoControls.versionsButton' => 'Versions vidéo',
'videoControls.pipButton' => 'Mode PiP (Picture-in-Picture)',
'videoControls.aspectRatioButton' => 'Format d\'image',
'videoControls.ambientLighting' => 'Éclairage ambiant',
'videoControls.ambientLightingOn' => 'Activer l\'éclairage ambiant',
'videoControls.ambientLightingOff' => 'Désactiver l\'éclairage ambiant',
'videoControls.fullscreenButton' => 'Passer en mode plein écran',
'videoControls.exitFullscreenButton' => 'Quitter le mode plein écran',
'videoControls.alwaysOnTopButton' => 'Always on top',
@@ -1734,11 +1740,11 @@ extension on TranslationsFr {
'liveTv.overnight' => 'Nuit',
'liveTv.morning' => 'Matin',
'liveTv.daytime' => 'Journée',
_ => null,
} ?? switch (path) {
'liveTv.evening' => 'Soirée',
'liveTv.lateNight' => 'Nuit tardive',
'liveTv.whatsOn' => 'En ce moment',
_ => null,
} ?? switch (path) {
'liveTv.watchChannel' => 'Regarder la chaîne',
'collections.title' => 'Collections',
'collections.collection' => 'Collection',
+8 -2
View File
@@ -474,6 +474,9 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn {
@override String get versionsButton => 'Versioni video';
@override String get pipButton => 'Modalità Picture-in-Picture';
@override String get aspectRatioButton => 'Proporzioni';
@override String get ambientLighting => 'Illuminazione ambientale';
@override String get ambientLightingOn => 'Attiva illuminazione ambientale';
@override String get ambientLightingOff => 'Disattiva illuminazione ambientale';
@override String get fullscreenButton => 'Attiva schermo intero';
@override String get exitFullscreenButton => 'Esci da schermo intero';
@override String get alwaysOnTopButton => 'Sempre in primo piano';
@@ -1517,6 +1520,9 @@ extension on TranslationsIt {
'videoControls.versionsButton' => 'Versioni video',
'videoControls.pipButton' => 'Modalità Picture-in-Picture',
'videoControls.aspectRatioButton' => 'Proporzioni',
'videoControls.ambientLighting' => 'Illuminazione ambientale',
'videoControls.ambientLightingOn' => 'Attiva illuminazione ambientale',
'videoControls.ambientLightingOff' => 'Disattiva illuminazione ambientale',
'videoControls.fullscreenButton' => 'Attiva schermo intero',
'videoControls.exitFullscreenButton' => 'Esci da schermo intero',
'videoControls.alwaysOnTopButton' => 'Sempre in primo piano',
@@ -1734,11 +1740,11 @@ extension on TranslationsIt {
'liveTv.overnight' => 'Notte',
'liveTv.morning' => 'Mattina',
'liveTv.daytime' => 'Giorno',
_ => null,
} ?? switch (path) {
'liveTv.evening' => 'Sera',
'liveTv.lateNight' => 'Notte tarda',
'liveTv.whatsOn' => 'In onda ora',
_ => null,
} ?? switch (path) {
'liveTv.watchChannel' => 'Guarda canale',
'downloads.title' => 'Download',
'downloads.manage' => 'Gestisci',
+8 -2
View File
@@ -474,6 +474,9 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn {
@override String get versionsButton => '동영상 버전';
@override String get pipButton => '픽처 인 픽처 모드';
@override String get aspectRatioButton => '화면비율';
@override String get ambientLighting => '주변 조명';
@override String get ambientLightingOn => '주변 조명 켜기';
@override String get ambientLightingOff => '주변 조명 끄기';
@override String get fullscreenButton => '전체화면';
@override String get exitFullscreenButton => '전체화면 종료';
@override String get alwaysOnTopButton => '창 최상위 고정';
@@ -1517,6 +1520,9 @@ extension on TranslationsKo {
'videoControls.versionsButton' => '동영상 버전',
'videoControls.pipButton' => '픽처 인 픽처 모드',
'videoControls.aspectRatioButton' => '화면비율',
'videoControls.ambientLighting' => '주변 조명',
'videoControls.ambientLightingOn' => '주변 조명 켜기',
'videoControls.ambientLightingOff' => '주변 조명 끄기',
'videoControls.fullscreenButton' => '전체화면',
'videoControls.exitFullscreenButton' => '전체화면 종료',
'videoControls.alwaysOnTopButton' => '창 최상위 고정',
@@ -1734,11 +1740,11 @@ extension on TranslationsKo {
'liveTv.overnight' => '심야',
'liveTv.morning' => '아침',
'liveTv.daytime' => '',
_ => null,
} ?? switch (path) {
'liveTv.evening' => '저녁',
'liveTv.lateNight' => '심야 방송',
'liveTv.whatsOn' => '지금 방송 중',
_ => null,
} ?? switch (path) {
'liveTv.watchChannel' => '채널 시청',
'collections.title' => '컬렉션',
'collections.collection' => '컬렉션',
+8 -2
View File
@@ -474,6 +474,9 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn {
@override String get versionsButton => 'Videoversies';
@override String get pipButton => 'Beeld-in-beeld modus';
@override String get aspectRatioButton => 'Beeldverhouding';
@override String get ambientLighting => 'Omgevingsverlichting';
@override String get ambientLightingOn => 'Omgevingsverlichting inschakelen';
@override String get ambientLightingOff => 'Omgevingsverlichting uitschakelen';
@override String get fullscreenButton => 'Volledig scherm activeren';
@override String get exitFullscreenButton => 'Volledig scherm verlaten';
@override String get alwaysOnTopButton => 'Altijd bovenop';
@@ -1517,6 +1520,9 @@ extension on TranslationsNl {
'videoControls.versionsButton' => 'Videoversies',
'videoControls.pipButton' => 'Beeld-in-beeld modus',
'videoControls.aspectRatioButton' => 'Beeldverhouding',
'videoControls.ambientLighting' => 'Omgevingsverlichting',
'videoControls.ambientLightingOn' => 'Omgevingsverlichting inschakelen',
'videoControls.ambientLightingOff' => 'Omgevingsverlichting uitschakelen',
'videoControls.fullscreenButton' => 'Volledig scherm activeren',
'videoControls.exitFullscreenButton' => 'Volledig scherm verlaten',
'videoControls.alwaysOnTopButton' => 'Altijd bovenop',
@@ -1734,11 +1740,11 @@ extension on TranslationsNl {
'liveTv.overnight' => 'Nacht',
'liveTv.morning' => 'Ochtend',
'liveTv.daytime' => 'Overdag',
_ => null,
} ?? switch (path) {
'liveTv.evening' => 'Avond',
'liveTv.lateNight' => 'Late avond',
'liveTv.whatsOn' => 'Nu op TV',
_ => null,
} ?? switch (path) {
'liveTv.watchChannel' => 'Kanaal bekijken',
'downloads.title' => 'Downloads',
'downloads.manage' => 'Beheren',
+8 -2
View File
@@ -474,6 +474,9 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn {
@override String get versionsButton => 'Videoversioner';
@override String get pipButton => 'Bild-i-bild läge';
@override String get aspectRatioButton => 'Bildförhållande';
@override String get ambientLighting => 'Ambientbelysning';
@override String get ambientLightingOn => 'Aktivera ambientbelysning';
@override String get ambientLightingOff => 'Inaktivera ambientbelysning';
@override String get fullscreenButton => 'Aktivera helskärm';
@override String get exitFullscreenButton => 'Avsluta helskärm';
@override String get alwaysOnTopButton => 'Alltid överst';
@@ -1517,6 +1520,9 @@ extension on TranslationsSv {
'videoControls.versionsButton' => 'Videoversioner',
'videoControls.pipButton' => 'Bild-i-bild läge',
'videoControls.aspectRatioButton' => 'Bildförhållande',
'videoControls.ambientLighting' => 'Ambientbelysning',
'videoControls.ambientLightingOn' => 'Aktivera ambientbelysning',
'videoControls.ambientLightingOff' => 'Inaktivera ambientbelysning',
'videoControls.fullscreenButton' => 'Aktivera helskärm',
'videoControls.exitFullscreenButton' => 'Avsluta helskärm',
'videoControls.alwaysOnTopButton' => 'Alltid överst',
@@ -1734,11 +1740,11 @@ extension on TranslationsSv {
'liveTv.overnight' => 'Natt',
'liveTv.morning' => 'Morgon',
'liveTv.daytime' => 'Dagtid',
_ => null,
} ?? switch (path) {
'liveTv.evening' => 'Kväll',
'liveTv.lateNight' => 'Sen kväll',
'liveTv.whatsOn' => 'På TV nu',
_ => null,
} ?? switch (path) {
'liveTv.watchChannel' => 'Titta på kanal',
'downloads.title' => 'Nedladdningar',
'downloads.manage' => 'Hantera',
+8 -2
View File
@@ -474,6 +474,9 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn {
@override String get versionsButton => '视频版本';
@override String get pipButton => '画中画模式';
@override String get aspectRatioButton => '宽高比';
@override String get ambientLighting => '氛围灯光';
@override String get ambientLightingOn => '开启氛围灯光';
@override String get ambientLightingOff => '关闭氛围灯光';
@override String get fullscreenButton => '进入全屏';
@override String get exitFullscreenButton => '退出全屏';
@override String get alwaysOnTopButton => '置顶窗口';
@@ -1517,6 +1520,9 @@ extension on TranslationsZh {
'videoControls.versionsButton' => '视频版本',
'videoControls.pipButton' => '画中画模式',
'videoControls.aspectRatioButton' => '宽高比',
'videoControls.ambientLighting' => '氛围灯光',
'videoControls.ambientLightingOn' => '开启氛围灯光',
'videoControls.ambientLightingOff' => '关闭氛围灯光',
'videoControls.fullscreenButton' => '进入全屏',
'videoControls.exitFullscreenButton' => '退出全屏',
'videoControls.alwaysOnTopButton' => '置顶窗口',
@@ -1734,11 +1740,11 @@ extension on TranslationsZh {
'liveTv.overnight' => '凌晨',
'liveTv.morning' => '上午',
'liveTv.daytime' => '白天',
_ => null,
} ?? switch (path) {
'liveTv.evening' => '晚上',
'liveTv.lateNight' => '深夜',
'liveTv.whatsOn' => '正在播出',
_ => null,
} ?? switch (path) {
'liveTv.watchChannel' => '观看频道',
'downloads.title' => '下载',
'downloads.manage' => '管理',
+3
View File
@@ -320,6 +320,9 @@
"versionsButton": "Videoversioner",
"pipButton": "Bild-i-bild läge",
"aspectRatioButton": "Bildförhållande",
"ambientLighting": "Ambientbelysning",
"ambientLightingOn": "Aktivera ambientbelysning",
"ambientLightingOff": "Inaktivera ambientbelysning",
"fullscreenButton": "Aktivera helskärm",
"exitFullscreenButton": "Avsluta helskärm",
"alwaysOnTopButton": "Alltid överst",
+3
View File
@@ -320,6 +320,9 @@
"versionsButton": "视频版本",
"pipButton": "画中画模式",
"aspectRatioButton": "宽高比",
"ambientLighting": "氛围灯光",
"ambientLightingOn": "开启氛围灯光",
"ambientLightingOff": "关闭氛围灯光",
"fullscreenButton": "进入全屏",
"exitFullscreenButton": "退出全屏",
"alwaysOnTopButton": "置顶窗口",
+57 -3
View File
@@ -36,6 +36,7 @@ import '../services/offline_watch_sync_service.dart';
import '../services/settings_service.dart';
import '../services/sleep_timer_service.dart';
import '../services/track_selection_service.dart';
import '../services/ambient_lighting_service.dart';
import '../services/video_filter_manager.dart';
import '../services/video_pip_manager.dart';
import '../services/pip_service.dart';
@@ -167,6 +168,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
VideoFilterManager? _videoFilterManager;
VideoPIPManager? _videoPIPManager;
ShaderService? _shaderService;
AmbientLightingService? _ambientLightingService;
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
// Watch Together provider reference (stored early to use in dispose)
@@ -1023,6 +1025,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Shader Service (MPV only)
_shaderService = ShaderService(player!);
if (_shaderService!.isSupported) {
// Ambient Lighting Service
_ambientLightingService = AmbientLightingService(player!);
_shaderService!.ambientLightingService = _ambientLightingService;
_videoFilterManager?.ambientLightingService = _ambientLightingService;
await _applySavedShaderPreset();
}
}
@@ -1141,11 +1148,56 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// Cycle through BoxFit modes: contain → cover → fill → contain (for button)
void _cycleBoxFitMode() {
// Disable ambient lighting when switching boxfit modes
// (cover/fill change the video rect, making the baked-in shader incorrect)
_ambientLightingService?.disable();
setState(() {
_videoFilterManager?.cycleBoxFitMode();
});
}
/// Update video-aspect-override when player size changes.
/// The shader adapts automatically via built-in target_size uniform.
void _updateAmbientLightingOnResize(Size newSize) {
final ambientLighting = _ambientLightingService;
if (ambientLighting == null || !ambientLighting.isEnabled) return;
if (newSize.height == 0) return;
ambientLighting.updateOutputAspect(newSize.width / newSize.height);
}
/// Toggle ambient lighting effect on/off
Future<void> _toggleAmbientLighting() async {
final ambientLighting = _ambientLightingService;
if (ambientLighting == null || !ambientLighting.isSupported) return;
if (ambientLighting.isEnabled) {
await ambientLighting.disable();
_videoFilterManager?.updateVideoFilter();
} else {
// Get video display aspect ratio
final dwidth = await player?.getProperty('dwidth');
final dheight = await player?.getProperty('dheight');
if (dwidth == null || dheight == null) return;
final w = double.tryParse(dwidth);
final h = double.tryParse(dheight);
if (w == null || h == null || h == 0) return;
final videoAspect = w / h;
// Get player widget aspect ratio
final playerSize = _videoFilterManager?.playerSize;
if (playerSize == null || playerSize.height == 0) return;
final outputAspect = playerSize.width / playerSize.height;
// Force contain mode when enabling ambient lighting
_videoFilterManager?.resetToContain();
await ambientLighting.enable(videoAspect, outputAspect);
}
setState(() {});
}
/// Toggle between contain and cover modes only (for pinch gesture)
void _toggleContainCover() {
setState(() {
@@ -2151,9 +2203,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
}
return event.logicalKey.isNavigationKey
? KeyEventResult.handled
: KeyEventResult.ignored;
return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored;
},
child: _isPlayerInitialized && player != null ? _buildVideoPlayer(context) : _buildLoadingSpinner(),
);
@@ -2217,6 +2267,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (mounted && player != null) {
_videoFilterManager?.updatePlayerSize(newSize);
_videoPIPManager?.updatePlayerSize(newSize);
// Update ambient lighting shader if active (output aspect changed)
_updateAmbientLightingOnResize(newSize);
// Update Metal layer frame on iOS/macOS for rotation
player!.updateFrame();
}
@@ -2286,6 +2338,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
: null,
isLive: widget.isLive,
liveChannelName: _liveChannelName,
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
onToggleAmbientLighting: _toggleAmbientLighting,
),
);
},
+249
View File
@@ -0,0 +1,249 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import '../mpv/player/player.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';
/// Enable ambient lighting effect.
///
/// [videoAspect] - the source video's display aspect ratio (width/height).
/// [outputAspect] - the player widget's aspect ratio (width/height).
Future<void> enable(double videoAspect, double outputAspect) async {
if (!isSupported) return;
try {
// Write static shader (only needs to happen once)
if (_shaderPath == null) {
_shaderPath = await _writeShaderToTemp(_generateShader());
}
if (kDebugMode) {
debugPrint('AmbientLightingService: Shader path: $_shaderPath');
}
// Set video-aspect-override to fill the entire output area
await _player.setProperty('video-aspect-override', outputAspect.toString());
// Append ambient lighting shader
await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]);
_enabled = true;
if (kDebugMode) {
debugPrint('AmbientLightingService: Enabled (video=$videoAspect, output=$outputAspect)');
}
} catch (e) {
if (kDebugMode) {
debugPrint('AmbientLightingService: Failed to enable: $e');
}
}
}
/// Disable ambient lighting effect and restore normal letterboxing.
Future<void> 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;
if (kDebugMode) {
debugPrint('AmbientLightingService: Disabled');
}
} catch (e) {
if (kDebugMode) {
debugPrint('AmbientLightingService: Failed to disable: $e');
}
}
}
/// Re-append the ambient lighting shader to the chain.
/// Called by ShaderService after it rebuilds the shader chain (clr + append).
Future<void> 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;
_player.setProperty('video-aspect-override', outputAspect.toString());
}
/// 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 (input, output, offset, desc) in blur8Steps) {
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();
}
// 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 (input, output, offset, desc) in blur64Steps) {
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();
}
// 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();
}
/// Write the shader to a temp file and return the path.
Future<String> _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;
}
}
+26
View File
@@ -2,15 +2,21 @@ import 'package:flutter/foundation.dart';
import '../models/shader_preset.dart';
import '../mpv/player/player.dart';
import 'ambient_lighting_service.dart';
import 'shader_asset_loader.dart';
/// Service for applying GLSL shaders to the MPV video player.
///
/// Handles shader chain building, HDR detection, and runtime switching.
/// When ambient lighting is active, the ambient lighting shader is always appended last
/// in the chain after any upscaling/processing shaders.
class ShaderService {
final Player _player;
ShaderPreset _currentPreset = ShaderPreset.none;
/// Reference to ambient lighting service for re-appending its shader after chain rebuilds.
AmbientLightingService? ambientLightingService;
ShaderService(this._player);
/// The currently applied shader preset
@@ -41,6 +47,7 @@ class ShaderService {
}
await _clearShaders();
_currentPreset = ShaderPreset.none;
await _reappendAmbientLighting();
return;
}
}
@@ -52,6 +59,7 @@ class ShaderService {
// No shaders - clear any existing ones
await _clearShaders();
_currentPreset = preset;
await _reappendAmbientLighting();
return;
}
@@ -65,6 +73,9 @@ class ShaderService {
_currentPreset = preset;
// Re-append ambient lighting shader at end of chain
await _reappendAmbientLighting();
if (kDebugMode) {
debugPrint('ShaderService: Applied ${preset.name} with ${shaderPaths.length} shaders');
}
@@ -87,6 +98,21 @@ class ShaderService {
}
}
/// Re-append the ambient lighting shader if it's active.
/// Called after shader chain rebuilds to keep ambient lighting last.
Future<void> _reappendAmbientLighting() async {
final service = ambientLightingService;
if (service == null || !service.isEnabled) return;
try {
await service.reappendShader();
} catch (e) {
if (kDebugMode) {
debugPrint('ShaderService: Failed to re-append ambient lighting: $e');
}
}
}
/// Check if the current video content is HDR.
Future<bool> _isHdrContent() async {
try {
+18 -3
View File
@@ -5,6 +5,7 @@ import '../mpv/mpv.dart';
import '../models/plex_media_version.dart';
import '../utils/app_logger.dart';
import 'ambient_lighting_service.dart';
/// Manages video filtering, aspect ratio modes, and subtitle positioning for video playback.
///
@@ -13,6 +14,7 @@ import '../utils/app_logger.dart';
/// - Video cropping calculations for fill screen mode
/// - Subtitle positioning adjustments based on crop parameters
/// - Debounced video filter updates on resize events
/// - Ambient-lighting-friendly reset to contain mode
class VideoFilterManager {
final Player player;
final List<PlexMediaVersion> availableVersions;
@@ -24,6 +26,9 @@ class VideoFilterManager {
/// Store the boxFitMode before entering PiP so it can be restored
int? _prePipBoxFitMode;
/// Ambient lighting service reference - when active, video-aspect-override is managed by ambient lighting
AmbientLightingService? ambientLightingService;
/// Track if a pinch gesture is occurring (public for gesture tracking)
bool isPinching = false;
@@ -54,6 +59,14 @@ class VideoFilterManager {
updateVideoFilter();
}
/// Reset to contain mode (mode 0). Used when enabling ambient lighting.
void resetToContain() {
if (_boxFitMode != 0) {
_boxFitMode = 0;
updateVideoFilter();
}
}
/// Toggle between contain and cover modes only (for pinch gesture)
void toggleContainCover() {
_boxFitMode = _boxFitMode == 0 ? 1 : 0;
@@ -89,11 +102,13 @@ class VideoFilterManager {
}
}
/// Update the video scaling and positioning based on current display mode
/// Update the video scaling and positioning based on current display mode.
/// When ambient lighting is active, video-aspect-override is managed by ambient lighting.
void updateVideoFilter() async {
try {
// Clear all video filters and manual scaling first
await player.setProperty('video-aspect-override', 'no');
if (ambientLightingService?.isEnabled != true) {
await player.setProperty('video-aspect-override', 'no');
}
await player.setProperty('sub-ass-force-margins', 'no');
await player.setProperty('panscan', '0');
@@ -89,6 +89,12 @@ class DesktopVideoControls extends StatefulWidget {
/// Channel name for live TV display
final String? liveChannelName;
/// Whether ambient lighting is enabled (passed to settings sheet)
final bool isAmbientLightingEnabled;
/// Called to toggle ambient lighting (passed to settings sheet)
final VoidCallback? onToggleAmbientLighting;
const DesktopVideoControls({
super.key,
required this.player,
@@ -135,6 +141,8 @@ class DesktopVideoControls extends StatefulWidget {
this.thumbnailUrlBuilder,
this.isLive = false,
this.liveChannelName,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
});
@override
@@ -680,6 +688,8 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
isLive: widget.isLive,
shaderService: widget.shaderService,
onShaderChanged: widget.onShaderChanged,
isAmbientLightingEnabled: widget.isAmbientLightingEnabled,
onToggleAmbientLighting: widget.onToggleAmbientLighting,
),
],
),
@@ -86,6 +86,12 @@ class VideoSettingsSheet extends StatefulWidget {
/// Called when shader preset changes
final VoidCallback? onShaderChanged;
/// Whether ambient lighting is currently enabled
final bool isAmbientLightingEnabled;
/// Called to toggle ambient lighting on/off (null if unsupported)
final VoidCallback? onToggleAmbientLighting;
const VideoSettingsSheet({
super.key,
required this.player,
@@ -95,6 +101,8 @@ class VideoSettingsSheet extends StatefulWidget {
this.isLive = false,
this.shaderService,
this.onShaderChanged,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
});
static Future<void> show(
@@ -108,6 +116,8 @@ class VideoSettingsSheet extends StatefulWidget {
bool isLive = false,
ShaderService? shaderService,
VoidCallback? onShaderChanged,
bool isAmbientLightingEnabled = false,
VoidCallback? onToggleAmbientLighting,
}) {
return BaseVideoControlSheet.showSheet(
context: context,
@@ -121,6 +131,8 @@ class VideoSettingsSheet extends StatefulWidget {
isLive: isLive,
shaderService: shaderService,
onShaderChanged: onShaderChanged,
isAmbientLightingEnabled: isAmbientLightingEnabled,
onToggleAmbientLighting: onToggleAmbientLighting,
),
);
}
@@ -369,6 +381,29 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
onTap: () => _navigateTo(_SettingsView.shader),
),
// Ambient Lighting (MPV only)
if (widget.onToggleAmbientLighting != null)
ListTile(
leading: AppIcon(
Symbols.blur_on,
fill: 1,
color: widget.isAmbientLightingEnabled ? Colors.amber : Colors.white70,
),
title: Text(t.videoControls.ambientLighting, style: const TextStyle(color: Colors.white)),
trailing: Switch(
value: widget.isAmbientLightingEnabled,
onChanged: (_) {
widget.onToggleAmbientLighting?.call();
Navigator.pop(context);
},
activeThumbColor: Colors.amber,
),
onTap: () {
widget.onToggleAmbientLighting?.call();
Navigator.pop(context);
},
),
// Performance Overlay Toggle
ListTile(
leading: AppIcon(
@@ -77,6 +77,8 @@ Widget plexVideoControlsBuilder(
String Function(Duration time)? thumbnailUrlBuilder,
bool isLive = false,
String? liveChannelName,
bool isAmbientLightingEnabled = false,
VoidCallback? onToggleAmbientLighting,
}) {
return PlexVideoControls(
player: player,
@@ -101,6 +103,8 @@ Widget plexVideoControlsBuilder(
thumbnailUrlBuilder: thumbnailUrlBuilder,
isLive: isLive,
liveChannelName: liveChannelName,
isAmbientLightingEnabled: isAmbientLightingEnabled,
onToggleAmbientLighting: onToggleAmbientLighting,
);
}
@@ -150,6 +154,12 @@ class PlexVideoControls extends StatefulWidget {
/// Channel name for live TV display
final String? liveChannelName;
/// Whether ambient lighting is enabled (passed to settings sheet)
final bool isAmbientLightingEnabled;
/// Called to toggle ambient lighting (passed to settings sheet)
final VoidCallback? onToggleAmbientLighting;
const PlexVideoControls({
super.key,
required this.player,
@@ -174,6 +184,8 @@ class PlexVideoControls extends StatefulWidget {
this.thumbnailUrlBuilder,
this.isLive = false,
this.liveChannelName,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
});
@override
@@ -905,6 +917,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
isLive: widget.isLive,
shaderService: widget.shaderService,
onShaderChanged: widget.onShaderChanged,
isAmbientLightingEnabled: widget.isAmbientLightingEnabled,
onToggleAmbientLighting: widget.player.playerType != 'exoplayer' ? widget.onToggleAmbientLighting : null,
);
}
@@ -1895,6 +1909,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
isLive: widget.isLive,
liveChannelName: widget.liveChannelName,
isAmbientLightingEnabled: widget.isAmbientLightingEnabled,
onToggleAmbientLighting: widget.player.playerType != 'exoplayer' ? widget.onToggleAmbientLighting : null,
),
);
}
@@ -48,6 +48,12 @@ class TrackChapterControls extends StatelessWidget {
final ShaderService? shaderService;
final VoidCallback? onShaderChanged;
/// Whether ambient lighting is enabled (passed to settings sheet)
final bool isAmbientLightingEnabled;
/// Called to toggle ambient lighting (passed to settings sheet)
final VoidCallback? onToggleAmbientLighting;
/// List of FocusNodes for the buttons (passed from parent for navigation)
final List<FocusNode>? focusNodes;
@@ -95,6 +101,8 @@ class TrackChapterControls extends StatelessWidget {
this.isLive = false,
this.shaderService,
this.onShaderChanged,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
});
/// Handle key event for button navigation
@@ -201,6 +209,8 @@ class TrackChapterControls extends StatelessWidget {
isLive: isLive,
shaderService: shaderService,
onShaderChanged: onShaderChanged,
isAmbientLightingEnabled: isAmbientLightingEnabled,
onToggleAmbientLighting: onToggleAmbientLighting,
);
onLoadSeekTimes?.call();
},