feat(cef): scroll fluidity, video playback, and diagnostic improvements
- Detect monitor refresh rate via GdkMonitor and match CEF frame rate (120Hz monitor gets 120fps instead of hardcoded 60fps) - Enable Chromium smooth scrolling for mouse wheel momentum/easing - Enable kinetic scroll flag on GTK EventControllerScroll - Always enable AudioHandler (unblocks video decode pipeline) - Add autoplay-policy flag for muted video autoplay (Reddit feeds) - Inject video diagnostic JS (pierces Shadow DOM, traces MSE codecs) TODO: remove once video playback is stable
- commit
- 73a16999217db83617c01044b2875fedc2d3f33a
- author
- brice · · 2026-03-23 18:57 UTC
- committer
- brice · · 2026-03-28 04:38 UTC
- parent
- e564832
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +153 -11
M internal/infrastructure/cef/app.go +15 -6
| @@ -28,20 +28,29 @@ const maxCmdLineLogLen = 200 | |||
| 28 | 28 | ||
| 29 | 29 | func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) { | |
| 30 | 30 | log := logging.FromContext(a.engine.ctx) | |
| 31 | - | cmdline := "" | |
| 32 | 31 | if commandLine != nil { | |
| 33 | - | cmdline = commandLine.GetCommandLineString() | |
| 32 | + | // Enable Chromium's built-in smooth scrolling animation — without this, | |
| 33 | + | // mouse wheel scroll jumps in discrete steps with no momentum/easing. | |
| 34 | + | commandLine.AppendSwitch("enable-smooth-scrolling") | |
| 35 | + | ||
| 36 | + | // Allow video autoplay without requiring user gesture — sites like | |
| 37 | + | // Reddit autoplay muted videos in the feed; without this policy | |
| 38 | + | // Chromium blocks them, showing an infinite spinner. | |
| 39 | + | commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required") | |
| 40 | + | ||
| 41 | + | ||
| 42 | + | cmdline := commandLine.GetCommandLineString() | |
| 34 | 43 | if len(cmdline) > maxCmdLineLogLen { | |
| 35 | 44 | runes := []rune(cmdline) | |
| 36 | 45 | if len(runes) > maxCmdLineLogLen { | |
| 37 | 46 | cmdline = string(runes[:maxCmdLineLogLen]) + "…" | |
| 38 | 47 | } | |
| 39 | 48 | } | |
| 49 | + | log.Debug(). | |
| 50 | + | Str("process_type", processType). | |
| 51 | + | Str("command_line", cmdline). | |
| 52 | + | Msg("cef: OnBeforeCommandLineProcessing") | |
| 40 | 53 | } | |
| 41 | - | log.Debug(). | |
| 42 | - | Str("process_type", processType). | |
| 43 | - | Str("command_line", cmdline). | |
| 44 | - | Msg("cef: OnBeforeCommandLineProcessing") | |
| 45 | 54 | } | |
| 46 | 55 | func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) { | |
| 47 | 56 | options := int32(purecef.SchemeOptionsSchemeOptionStandard | | |
| @@ -28,20 +28,29 @@ const maxCmdLineLogLen = 200 | |||||
| 28 | 28 | ||||
| 29 | func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) { | 29 | func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) { | ||
| 30 | log := logging.FromContext(a.engine.ctx) | 30 | log := logging.FromContext(a.engine.ctx) | ||
| 31 | - | cmdline := "" | |||
| 32 | if commandLine != nil { | 31 | if commandLine != nil { | ||
| 33 | - | cmdline = commandLine.GetCommandLineString() | 32 | + | // Enable Chromium's built-in smooth scrolling animation — without this, |
| 33 | + | // mouse wheel scroll jumps in discrete steps with no momentum/easing. | |||
| 34 | + | commandLine.AppendSwitch("enable-smooth-scrolling") | |||
| 35 | + | ||||
| 36 | + | // Allow video autoplay without requiring user gesture — sites like | |||
| 37 | + | // Reddit autoplay muted videos in the feed; without this policy | |||
| 38 | + | // Chromium blocks them, showing an infinite spinner. | |||
| 39 | + | commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required") | |||
| 40 | + | ||||
| 41 | + | ||||
| 42 | + | cmdline := commandLine.GetCommandLineString() | |||
| 34 | if len(cmdline) > maxCmdLineLogLen { | 43 | if len(cmdline) > maxCmdLineLogLen { | ||
| 35 | runes := []rune(cmdline) | 44 | runes := []rune(cmdline) | ||
| 36 | if len(runes) > maxCmdLineLogLen { | 45 | if len(runes) > maxCmdLineLogLen { | ||
| 37 | cmdline = string(runes[:maxCmdLineLogLen]) + "…" | 46 | cmdline = string(runes[:maxCmdLineLogLen]) + "…" | ||
| 38 | } | 47 | } | ||
| 39 | } | 48 | } | ||
| 49 | + | log.Debug(). | |||
| 50 | + | Str("process_type", processType). | |||
| 51 | + | Str("command_line", cmdline). | |||
| 52 | + | Msg("cef: OnBeforeCommandLineProcessing") | |||
| 40 | } | 53 | } | ||
| 41 | - | log.Debug(). | |||
| 42 | - | Str("process_type", processType). | |||
| 43 | - | Str("command_line", cmdline). | |||
| 44 | - | Msg("cef: OnBeforeCommandLineProcessing") | |||
| 45 | } | 54 | } | ||
| 46 | func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) { | 55 | func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) { | ||
| 47 | options := int32(purecef.SchemeOptionsSchemeOptionStandard | | 56 | options := int32(purecef.SchemeOptionsSchemeOptionStandard | | ||
M internal/infrastructure/cef/content_injector.go +98 -0
| @@ -66,6 +66,100 @@ const scrollbarAutoHideJS = `(function(){ | |||
| 66 | 66 | }); | |
| 67 | 67 | })();` | |
| 68 | 68 | ||
| 69 | + | // videoDiagnosticJS monitors all <video> elements on the page and logs their | |
| 70 | + | // state changes to the console. This helps diagnose playback issues in CEF OSR. | |
| 71 | + | // TODO: remove once video playback is stable. | |
| 72 | + | const videoDiagnosticJS = `(function(){ | |
| 73 | + | var tag = '[VIDEO-DIAG]'; | |
| 74 | + | var events = ['loadstart','loadedmetadata','loadeddata','canplay','canplaythrough', | |
| 75 | + | 'play','playing','pause','waiting','stalled','error','abort','emptied','suspend']; | |
| 76 | + | ||
| 77 | + | function monitor(v, label) { | |
| 78 | + | if (v._dumberDiag) return; | |
| 79 | + | v._dumberDiag = true; | |
| 80 | + | console.warn(tag, label, 'src:', (v.src||'').substring(0,80), | |
| 81 | + | 'currentSrc:', (v.currentSrc||'').substring(0,80), | |
| 82 | + | 'readyState:', v.readyState, 'networkState:', v.networkState, | |
| 83 | + | 'preload:', v.preload, 'autoplay:', v.autoplay); | |
| 84 | + | events.forEach(function(evt) { | |
| 85 | + | v.addEventListener(evt, function() { | |
| 86 | + | var err = v.error ? ('code='+v.error.code+' msg='+v.error.message) : 'none'; | |
| 87 | + | console.warn(tag, evt, label, 'ready:', v.readyState, 'net:', v.networkState, | |
| 88 | + | 'paused:', v.paused, 'error:', err, 'src:', (v.currentSrc||v.src||'').substring(0,80)); | |
| 89 | + | }); | |
| 90 | + | }); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Recursively scan a root (document or shadowRoot) for video elements. | |
| 94 | + | function scanRoot(root, label) { | |
| 95 | + | root.querySelectorAll('video').forEach(function(v, i) { monitor(v, label+'#'+i); }); | |
| 96 | + | // Pierce shadow DOMs. | |
| 97 | + | root.querySelectorAll('*').forEach(function(el) { | |
| 98 | + | if (el.shadowRoot) { | |
| 99 | + | scanRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase()); | |
| 100 | + | // Observe inside shadow root too. | |
| 101 | + | observeRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase()); | |
| 102 | + | } | |
| 103 | + | }); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | function observeRoot(root, label) { | |
| 107 | + | new MutationObserver(function(muts) { | |
| 108 | + | muts.forEach(function(m) { | |
| 109 | + | m.addedNodes.forEach(function(n) { | |
| 110 | + | if (n.nodeName === 'VIDEO') monitor(n, label+'-dyn'); | |
| 111 | + | if (n.querySelectorAll) { | |
| 112 | + | n.querySelectorAll('video').forEach(function(v) { monitor(v, label+'-nested'); }); | |
| 113 | + | } | |
| 114 | + | // New element might have shadow root. | |
| 115 | + | if (n.shadowRoot) { | |
| 116 | + | scanRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase()); | |
| 117 | + | observeRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase()); | |
| 118 | + | } | |
| 119 | + | }); | |
| 120 | + | }); | |
| 121 | + | }).observe(root, {childList:true, subtree:true}); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Patch attachShadow to automatically observe new shadow roots. | |
| 125 | + | var origAttach = Element.prototype.attachShadow; | |
| 126 | + | Element.prototype.attachShadow = function(opts) { | |
| 127 | + | var sr = origAttach.call(this, opts); | |
| 128 | + | console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase()); | |
| 129 | + | observeRoot(sr, 'shadow>'+this.tagName.toLowerCase()); | |
| 130 | + | return sr; | |
| 131 | + | }; | |
| 132 | + | ||
| 133 | + | // Also intercept MediaSource to trace usage. | |
| 134 | + | if (window.MediaSource) { | |
| 135 | + | var origAddSB = MediaSource.prototype.addSourceBuffer; | |
| 136 | + | MediaSource.prototype.addSourceBuffer = function(mime) { | |
| 137 | + | console.warn(tag, 'MediaSource.addSourceBuffer:', mime, | |
| 138 | + | 'supported:', MediaSource.isTypeSupported(mime)); | |
| 139 | + | try { return origAddSB.call(this, mime); } | |
| 140 | + | catch(e) { console.error(tag, 'addSourceBuffer FAILED:', e.message, 'mime:', mime); throw e; } | |
| 141 | + | }; | |
| 142 | + | var origURL = URL.createObjectURL; | |
| 143 | + | URL.createObjectURL = function(obj) { | |
| 144 | + | var url = origURL.call(this, obj); | |
| 145 | + | if (obj instanceof MediaSource) { | |
| 146 | + | console.warn(tag, 'URL.createObjectURL(MediaSource):', url.substring(0,80)); | |
| 147 | + | } | |
| 148 | + | return url; | |
| 149 | + | }; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Initial scan. | |
| 153 | + | scanRoot(document, 'doc'); | |
| 154 | + | ||
| 155 | + | // Observe document for new elements. | |
| 156 | + | observeRoot(document.documentElement, 'doc'); | |
| 157 | + | ||
| 158 | + | console.warn(tag, 'diagnostic active, MSE:', !!window.MediaSource, | |
| 159 | + | 'h264:', MediaSource ? MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') : 'n/a', | |
| 160 | + | 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a'); | |
| 161 | + | })();` | |
| 162 | + | ||
| 69 | 163 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 70 | 164 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 71 | 165 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| @@ -181,6 +275,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 181 | 275 | // All pages get custom scrollbar styling with auto-hide. | |
| 182 | 276 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | |
| 183 | 277 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | |
| 278 | + | ||
| 279 | + | // Video playback diagnostic — logs video element state changes. | |
| 280 | + | // TODO: remove once video playback is stable. | |
| 281 | + | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | |
| 184 | 282 | } | |
| 185 | 283 | ||
| 186 | 284 | // injectCSS injects a CSS string as a <style> element via JavaScript. | |
| @@ -66,6 +66,100 @@ const scrollbarAutoHideJS = `(function(){ | |||||
| 66 | }); | 66 | }); | ||
| 67 | })();` | 67 | })();` | ||
| 68 | 68 | ||||
| 69 | + | // videoDiagnosticJS monitors all <video> elements on the page and logs their | |||
| 70 | + | // state changes to the console. This helps diagnose playback issues in CEF OSR. | |||
| 71 | + | // TODO: remove once video playback is stable. | |||
| 72 | + | const videoDiagnosticJS = `(function(){ | |||
| 73 | + | var tag = '[VIDEO-DIAG]'; | |||
| 74 | + | var events = ['loadstart','loadedmetadata','loadeddata','canplay','canplaythrough', | |||
| 75 | + | 'play','playing','pause','waiting','stalled','error','abort','emptied','suspend']; | |||
| 76 | + | ||||
| 77 | + | function monitor(v, label) { | |||
| 78 | + | if (v._dumberDiag) return; | |||
| 79 | + | v._dumberDiag = true; | |||
| 80 | + | console.warn(tag, label, 'src:', (v.src||'').substring(0,80), | |||
| 81 | + | 'currentSrc:', (v.currentSrc||'').substring(0,80), | |||
| 82 | + | 'readyState:', v.readyState, 'networkState:', v.networkState, | |||
| 83 | + | 'preload:', v.preload, 'autoplay:', v.autoplay); | |||
| 84 | + | events.forEach(function(evt) { | |||
| 85 | + | v.addEventListener(evt, function() { | |||
| 86 | + | var err = v.error ? ('code='+v.error.code+' msg='+v.error.message) : 'none'; | |||
| 87 | + | console.warn(tag, evt, label, 'ready:', v.readyState, 'net:', v.networkState, | |||
| 88 | + | 'paused:', v.paused, 'error:', err, 'src:', (v.currentSrc||v.src||'').substring(0,80)); | |||
| 89 | + | }); | |||
| 90 | + | }); | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Recursively scan a root (document or shadowRoot) for video elements. | |||
| 94 | + | function scanRoot(root, label) { | |||
| 95 | + | root.querySelectorAll('video').forEach(function(v, i) { monitor(v, label+'#'+i); }); | |||
| 96 | + | // Pierce shadow DOMs. | |||
| 97 | + | root.querySelectorAll('*').forEach(function(el) { | |||
| 98 | + | if (el.shadowRoot) { | |||
| 99 | + | scanRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase()); | |||
| 100 | + | // Observe inside shadow root too. | |||
| 101 | + | observeRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase()); | |||
| 102 | + | } | |||
| 103 | + | }); | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | function observeRoot(root, label) { | |||
| 107 | + | new MutationObserver(function(muts) { | |||
| 108 | + | muts.forEach(function(m) { | |||
| 109 | + | m.addedNodes.forEach(function(n) { | |||
| 110 | + | if (n.nodeName === 'VIDEO') monitor(n, label+'-dyn'); | |||
| 111 | + | if (n.querySelectorAll) { | |||
| 112 | + | n.querySelectorAll('video').forEach(function(v) { monitor(v, label+'-nested'); }); | |||
| 113 | + | } | |||
| 114 | + | // New element might have shadow root. | |||
| 115 | + | if (n.shadowRoot) { | |||
| 116 | + | scanRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase()); | |||
| 117 | + | observeRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase()); | |||
| 118 | + | } | |||
| 119 | + | }); | |||
| 120 | + | }); | |||
| 121 | + | }).observe(root, {childList:true, subtree:true}); | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Patch attachShadow to automatically observe new shadow roots. | |||
| 125 | + | var origAttach = Element.prototype.attachShadow; | |||
| 126 | + | Element.prototype.attachShadow = function(opts) { | |||
| 127 | + | var sr = origAttach.call(this, opts); | |||
| 128 | + | console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase()); | |||
| 129 | + | observeRoot(sr, 'shadow>'+this.tagName.toLowerCase()); | |||
| 130 | + | return sr; | |||
| 131 | + | }; | |||
| 132 | + | ||||
| 133 | + | // Also intercept MediaSource to trace usage. | |||
| 134 | + | if (window.MediaSource) { | |||
| 135 | + | var origAddSB = MediaSource.prototype.addSourceBuffer; | |||
| 136 | + | MediaSource.prototype.addSourceBuffer = function(mime) { | |||
| 137 | + | console.warn(tag, 'MediaSource.addSourceBuffer:', mime, | |||
| 138 | + | 'supported:', MediaSource.isTypeSupported(mime)); | |||
| 139 | + | try { return origAddSB.call(this, mime); } | |||
| 140 | + | catch(e) { console.error(tag, 'addSourceBuffer FAILED:', e.message, 'mime:', mime); throw e; } | |||
| 141 | + | }; | |||
| 142 | + | var origURL = URL.createObjectURL; | |||
| 143 | + | URL.createObjectURL = function(obj) { | |||
| 144 | + | var url = origURL.call(this, obj); | |||
| 145 | + | if (obj instanceof MediaSource) { | |||
| 146 | + | console.warn(tag, 'URL.createObjectURL(MediaSource):', url.substring(0,80)); | |||
| 147 | + | } | |||
| 148 | + | return url; | |||
| 149 | + | }; | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // Initial scan. | |||
| 153 | + | scanRoot(document, 'doc'); | |||
| 154 | + | ||||
| 155 | + | // Observe document for new elements. | |||
| 156 | + | observeRoot(document.documentElement, 'doc'); | |||
| 157 | + | ||||
| 158 | + | console.warn(tag, 'diagnostic active, MSE:', !!window.MediaSource, | |||
| 159 | + | 'h264:', MediaSource ? MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') : 'n/a', | |||
| 160 | + | 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a'); | |||
| 161 | + | })();` | |||
| 162 | + | ||||
| 69 | // contentInjector implements port.ContentInjector for the CEF engine. | 163 | // contentInjector implements port.ContentInjector for the CEF engine. | ||
| 70 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | 164 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | ||
| 71 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | 165 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | ||
| @@ -181,6 +275,10 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||||
| 181 | // All pages get custom scrollbar styling with auto-hide. | 275 | // All pages get custom scrollbar styling with auto-hide. | ||
| 182 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | 276 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | ||
| 183 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | 277 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | ||
| 278 | + | ||||
| 279 | + | // Video playback diagnostic — logs video element state changes. | |||
| 280 | + | // TODO: remove once video playback is stable. | |||
| 281 | + | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | |||
| 184 | } | 282 | } | ||
| 185 | 283 | ||||
| 186 | // injectCSS injects a CSS string as a <style> element via JavaScript. | 284 | // injectCSS injects a CSS string as a <style> element via JavaScript. | ||
M internal/infrastructure/cef/engine_init.go +34 -0
| @@ -127,6 +127,14 @@ func wireEngine( | |||
| 127 | 127 | ||
| 128 | 128 | scale := detectHiDPIScale(logger) | |
| 129 | 129 | ||
| 130 | + | // If frame rate is the static default (60), try to match the monitor's | |
| 131 | + | // actual refresh rate for smoother scrolling on high-refresh displays. | |
| 132 | + | if windowlessFrameRate == 60 { | |
| 133 | + | if hz := detectMonitorRefreshRate(logger); hz > 0 { | |
| 134 | + | windowlessFrameRate = hz | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 130 | 138 | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 131 | 139 | scale: scale, | |
| 132 | 140 | windowlessFrameRate: windowlessFrameRate, | |
| @@ -184,6 +192,32 @@ func detectHiDPIScale(logger *zerolog.Logger) int32 { | |||
| 184 | 192 | return 1 | |
| 185 | 193 | } | |
| 186 | 194 | ||
| 195 | + | // detectMonitorRefreshRate queries the primary GDK monitor for its refresh rate. | |
| 196 | + | // Returns the rate in Hz (e.g., 120 for a 120Hz display), or 0 if detection fails. | |
| 197 | + | func detectMonitorRefreshRate(logger *zerolog.Logger) int32 { | |
| 198 | + | display := gdk.DisplayGetDefault() | |
| 199 | + | if display == nil { | |
| 200 | + | return 0 | |
| 201 | + | } | |
| 202 | + | monitors := display.GetMonitors() | |
| 203 | + | if monitors == nil { | |
| 204 | + | return 0 | |
| 205 | + | } | |
| 206 | + | obj := monitors.GetObject(0) | |
| 207 | + | if obj == nil { | |
| 208 | + | return 0 | |
| 209 | + | } | |
| 210 | + | mon := &gdk.Monitor{} | |
| 211 | + | mon.SetGoPointer(obj.GoPointer()) | |
| 212 | + | milliHz := mon.GetRefreshRate() | |
| 213 | + | if milliHz <= 0 { | |
| 214 | + | return 0 | |
| 215 | + | } | |
| 216 | + | hz := int32(milliHz / 1000) | |
| 217 | + | logger.Info().Int32("refresh_rate_hz", hz).Msg("cef: detected monitor refresh rate") | |
| 218 | + | return hz | |
| 219 | + | } | |
| 220 | + | ||
| 187 | 221 | // appendIfMissing appends flag to args if it's not already present. | |
| 188 | 222 | func appendIfMissing(args []string, flag string) []string { | |
| 189 | 223 | for _, a := range args { | |
| @@ -127,6 +127,14 @@ func wireEngine( | |||||
| 127 | 127 | ||||
| 128 | scale := detectHiDPIScale(logger) | 128 | scale := detectHiDPIScale(logger) | ||
| 129 | 129 | ||||
| 130 | + | // If frame rate is the static default (60), try to match the monitor's | |||
| 131 | + | // actual refresh rate for smoother scrolling on high-refresh displays. | |||
| 132 | + | if windowlessFrameRate == 60 { | |||
| 133 | + | if hz := detectMonitorRefreshRate(logger); hz > 0 { | |||
| 134 | + | windowlessFrameRate = hz | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 130 | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | 138 | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | ||
| 131 | scale: scale, | 139 | scale: scale, | ||
| 132 | windowlessFrameRate: windowlessFrameRate, | 140 | windowlessFrameRate: windowlessFrameRate, | ||
| @@ -184,6 +192,32 @@ func detectHiDPIScale(logger *zerolog.Logger) int32 { | |||||
| 184 | return 1 | 192 | return 1 | ||
| 185 | } | 193 | } | ||
| 186 | 194 | ||||
| 195 | + | // detectMonitorRefreshRate queries the primary GDK monitor for its refresh rate. | |||
| 196 | + | // Returns the rate in Hz (e.g., 120 for a 120Hz display), or 0 if detection fails. | |||
| 197 | + | func detectMonitorRefreshRate(logger *zerolog.Logger) int32 { | |||
| 198 | + | display := gdk.DisplayGetDefault() | |||
| 199 | + | if display == nil { | |||
| 200 | + | return 0 | |||
| 201 | + | } | |||
| 202 | + | monitors := display.GetMonitors() | |||
| 203 | + | if monitors == nil { | |||
| 204 | + | return 0 | |||
| 205 | + | } | |||
| 206 | + | obj := monitors.GetObject(0) | |||
| 207 | + | if obj == nil { | |||
| 208 | + | return 0 | |||
| 209 | + | } | |||
| 210 | + | mon := &gdk.Monitor{} | |||
| 211 | + | mon.SetGoPointer(obj.GoPointer()) | |||
| 212 | + | milliHz := mon.GetRefreshRate() | |||
| 213 | + | if milliHz <= 0 { | |||
| 214 | + | return 0 | |||
| 215 | + | } | |||
| 216 | + | hz := int32(milliHz / 1000) | |||
| 217 | + | logger.Info().Int32("refresh_rate_hz", hz).Msg("cef: detected monitor refresh rate") | |||
| 218 | + | return hz | |||
| 219 | + | } | |||
| 220 | + | ||||
| 187 | // appendIfMissing appends flag to args if it's not already present. | 221 | // appendIfMissing appends flag to args if it's not already present. | ||
| 188 | func appendIfMissing(args []string, flag string) []string { | 222 | func appendIfMissing(args []string, flag string) []string { | ||
| 189 | for _, a := range args { | 223 | for _, a := range args { | ||
M internal/infrastructure/cef/input_bridge.go +1 -1
| @@ -107,7 +107,7 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) { | |||
| 107 | 107 | glArea.AddController(&click.EventController) | |
| 108 | 108 | ||
| 109 | 109 | // Scroll | |
| 110 | - | scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue) | |
| 110 | + | scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue | gtk.EventControllerScrollKineticValue) | |
| 111 | 111 | scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool { | |
| 112 | 112 | ib.onScroll(dx, dy) | |
| 113 | 113 | return true | |
| @@ -107,7 +107,7 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) { | |||||
| 107 | glArea.AddController(&click.EventController) | 107 | glArea.AddController(&click.EventController) | ||
| 108 | 108 | ||||
| 109 | // Scroll | 109 | // Scroll | ||
| 110 | - | scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue) | 110 | + | scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue | gtk.EventControllerScrollKineticValue) |
| 111 | scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool { | 111 | scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool { | ||
| 112 | ib.onScroll(dx, dy) | 112 | ib.onScroll(dx, dy) | ||
| 113 | return true | 113 | return true | ||
M internal/infrastructure/cef/webview_handlers.go +5 -4
| @@ -34,10 +34,11 @@ var ( | |||
| 34 | 34 | // =========================================================================== | |
| 35 | 35 | ||
| 36 | 36 | func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | |
| 37 | - | if h.enableAudioHandler { | |
| 38 | - | return h | |
| 39 | - | } | |
| 40 | - | return nil | |
| 37 | + | // Always return the audio handler — without it, CEF has no audio output | |
| 38 | + | // path and refuses to start media decoding, breaking all video playback. | |
| 39 | + | // The handler accepts audio but discards packets (no-op) until a real | |
| 40 | + | // audio backend (PipeWire/PulseAudio) is wired up. | |
| 41 | + | return h | |
| 41 | 42 | } | |
| 42 | 43 | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | |
| 43 | 44 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { | |
| @@ -34,10 +34,11 @@ var ( | |||||
| 34 | // =========================================================================== | 34 | // =========================================================================== | ||
| 35 | 35 | ||||
| 36 | func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | 36 | func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | ||
| 37 | - | if h.enableAudioHandler { | 37 | + | // Always return the audio handler — without it, CEF has no audio output |
| 38 | - | return h | 38 | + | // path and refuses to start media decoding, breaking all video playback. |
| 39 | - | } | 39 | + | // The handler accepts audio but discards packets (no-op) until a real |
| 40 | - | return nil | 40 | + | // audio backend (PipeWire/PulseAudio) is wired up. |
| 41 | + | return h | |||
| 41 | } | 42 | } | ||
| 42 | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | 43 | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | ||
| 43 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { | 44 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { | ||