detached cf74e06

feat(codec): Introduce codec preferences for media playback

commit
cf74e068ed16cffa5133236880771123aa85e6e2
author
bamen · · 2025-09-12 09:36 UTC
parent
2b27f37
signature
unsigned
29 files · +1406 -220
M CHANGELOG.md +9 -0
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Smart AV1 codec negotiation system for YouTube high-resolution streaming**:
9+ - **Resolution-aware MediaCapabilities API reporting**: Intelligent codec capability reporting based on video resolution, enabling YouTube to select optimal codecs for different quality levels
10+ - **YouTube format manifest manipulation**: Real-time format list reordering to prioritize AV1 for ≤1080p content while allowing VP9 fallback for higher resolutions
11+ - **Graduated codec preference system**: Smart localStorage preference (`2048`) instead of forced AV1 (`8192`), giving YouTube flexibility for optimal codec selection
12+ - **4K HDR 60fps AV1 support**: Successfully enables YouTube delivery of premium quality 4K HDR content with AV1 codec, HDR10 (PQ) color space, and BT.2020 wide color gamut
13+ - **Configurable AV1 resolution limits**: New `av1_max_resolution` config option with environment variable support (`DUMBER_AV1_MAX_RES`)
14+ - **Automatic codec fallback logic**: Seamless VP9 fallback for resolutions where AV1 performance may be suboptimal
15+ - **High bitrate streaming support**: Enables premium quality streaming (650+ Mbps) with proper buffer management
16+ - Fixes previous limitation where forcing AV1 would cap YouTube playback at 1080p maximum
817 - Page refresh functionality with standard keyboard shortcuts:
918 - **Ctrl+R** / **Cmd+R**: Reload current page
1019 - **Ctrl+Shift+R** / **Cmd+Shift+R**: Hard reload (bypass cache)
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Smart AV1 codec negotiation system for YouTube high-resolution streaming**:
9+ - **Resolution-aware MediaCapabilities API reporting**: Intelligent codec capability reporting based on video resolution, enabling YouTube to select optimal codecs for different quality levels
10+ - **YouTube format manifest manipulation**: Real-time format list reordering to prioritize AV1 for ≤1080p content while allowing VP9 fallback for higher resolutions
11+ - **Graduated codec preference system**: Smart localStorage preference (`2048`) instead of forced AV1 (`8192`), giving YouTube flexibility for optimal codec selection
12+ - **4K HDR 60fps AV1 support**: Successfully enables YouTube delivery of premium quality 4K HDR content with AV1 codec, HDR10 (PQ) color space, and BT.2020 wide color gamut
13+ - **Configurable AV1 resolution limits**: New `av1_max_resolution` config option with environment variable support (`DUMBER_AV1_MAX_RES`)
14+ - **Automatic codec fallback logic**: Seamless VP9 fallback for resolutions where AV1 performance may be suboptimal
15+ - **High bitrate streaming support**: Enables premium quality streaming (650+ Mbps) with proper buffer management
16+ - Fixes previous limitation where forcing AV1 would cap YouTube playback at 1080p maximum
8 - Page refresh functionality with standard keyboard shortcuts:17 - Page refresh functionality with standard keyboard shortcuts:
9 - **Ctrl+R** / **Cmd+R**: Reload current page18 - **Ctrl+R** / **Cmd+R**: Reload current page
10 - **Ctrl+Shift+R** / **Cmd+Shift+R**: Hard reload (bypass cache)19 - **Ctrl+Shift+R** / **Cmd+Shift+R**: Hard reload (bypass cache)
M internal/cache/binary_format.go +2 -2
@@ -221,8 +221,8 @@ func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) in
221221 header := BinaryHeader{
222222 Magic: CacheMagic,
223223 Version: CacheVersion,
224- EntryCount: uint32(len(c.entries)), //nolint:gosec // G115: bounds checked above
225- IndexOffset: uint64(indexOffset), //nolint:gosec // G115: bounds checked above
224+ EntryCount: uint32(len(c.entries)), //nolint:gosec // G115: bounds checked above
225+ IndexOffset: uint64(indexOffset), //nolint:gosec // G115: bounds checked above
226226 LastModified: c.lastModified,
227227 }
228228
@@ -221,8 +221,8 @@ func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) in
221 header := BinaryHeader{221 header := BinaryHeader{
222 Magic: CacheMagic,222 Magic: CacheMagic,
223 Version: CacheVersion,223 Version: CacheVersion,
224- EntryCount: uint32(len(c.entries)), //nolint:gosec // G115: bounds checked above224+ EntryCount: uint32(len(c.entries)), //nolint:gosec // G115: bounds checked above
225- IndexOffset: uint64(indexOffset), //nolint:gosec // G115: bounds checked above225+ IndexOffset: uint64(indexOffset), //nolint:gosec // G115: bounds checked above
226 LastModified: c.lastModified,226 LastModified: c.lastModified,
227 }227 }
228 228
M internal/cache/favicon.go +46 -46
@@ -5,9 +5,9 @@ import (
55 "fmt"
66 "image"
77 "image/color"
8- "image/png"
9- _ "image/jpeg" // Register JPEG decoder
108 _ "image/gif" // Register GIF decoder
9+ _ "image/jpeg" // Register JPEG decoder
10+ "image/png"
1111 "io"
1212 "log"
1313 "net/http"
@@ -107,9 +107,9 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
107107 return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode)
108108 }
109109
110- // Limit response body size
110+ // Limit response body size
111111 limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max
112-
112+
113113 // Download to temporary file first
114114 tempDownloadFile := cachedPath + ".download"
115115 downloadFile, err := os.Create(tempDownloadFile)
@@ -140,18 +140,18 @@ func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) e
140140 log.Printf("[favicon] ICO file detected for %s, using go-ico decoder", faviconURL)
141141 return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
142142 }
143-
143+
144144 // Try standard Go image decoder for other formats (PNG, JPEG, GIF)
145145 if img, err := fc.tryStandardImageDecode(inputPath); err == nil {
146146 log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL)
147-
147+
148148 // Apply dark theme improvements
149149 img = invertIfTooDark(img)
150-
150+
151151 // Save as PNG
152152 return fc.savePNG(img, outputPath)
153153 }
154-
154+
155155 // Standard decode failed, fallback to go-ico
156156 log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL)
157157 return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
@@ -164,7 +164,7 @@ func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, er
164164 return nil, err
165165 }
166166 defer file.Close()
167-
167+
168168 img, _, err := image.Decode(file)
169169 return img, err
170170 }
@@ -177,20 +177,20 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
177177 return fmt.Errorf("failed to create temp file: %w", err)
178178 }
179179 defer file.Close()
180-
180+
181181 if err := png.Encode(file, img); err != nil {
182182 os.Remove(tempFile)
183183 return fmt.Errorf("failed to encode PNG: %w", err)
184184 }
185-
185+
186186 file.Close()
187-
187+
188188 // Atomic move
189189 if err := os.Rename(tempFile, outputPath); err != nil {
190190 os.Remove(tempFile)
191191 return fmt.Errorf("failed to move PNG to cache: %w", err)
192192 }
193-
193+
194194 return nil
195195 }
196196
@@ -202,18 +202,18 @@ func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL st
202202 return fmt.Errorf("failed to open ICO file: %w", err)
203203 }
204204 defer file.Close()
205-
205+
206206 // Decode ICO file
207207 img, err := ico.Decode(file)
208208 if err != nil {
209209 return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err)
210210 }
211-
211+
212212 log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL)
213-
213+
214214 // Apply dark theme improvements
215215 img = invertIfTooDark(img)
216-
216+
217217 // Save as PNG
218218 return fc.savePNG(img, outputPath)
219219 }
@@ -226,7 +226,7 @@ func (fc *FaviconCache) CleanOld() error {
226226 }
227227
228228 cutoff := time.Now().Add(-30 * 24 * time.Hour)
229-
229+
230230 for _, entry := range entries {
231231 if entry.IsDir() {
232232 continue
@@ -249,35 +249,35 @@ func (fc *FaviconCache) CleanOld() error {
249249 // Only inverts truly monochrome dark icons, preserves colorful icons
250250 func invertIfTooDark(img image.Image) image.Image {
251251 bounds := img.Bounds()
252-
252+
253253 // Sample pixels to calculate statistics
254254 var totalR, totalG, totalB uint32
255255 var pixelCount uint32
256256 var colorVariance uint32
257257 var transparentPixels uint32
258-
258+
259259 // Sample every 4th pixel for performance
260260 for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 {
261261 for x := bounds.Min.X; x < bounds.Max.X; x += 4 {
262262 r, g, b, a := img.At(x, y).RGBA()
263-
263+
264264 if a == 0 {
265265 transparentPixels++
266266 continue
267267 }
268-
268+
269269 if a > 0 { // Only count non-transparent pixels
270270 totalR += r
271271 totalG += g
272272 totalB += b
273273 pixelCount++
274-
274+
275275 // Calculate color variance to detect colorful vs monochrome icons
276276 // High variance = colorful (don't invert), low variance = monochrome
277277 rNorm := r >> 8 // Convert to 0-255 range
278278 gNorm := g >> 8
279279 bNorm := b >> 8
280-
280+
281281 // Simple color variance: difference between max and min color channels
282282 maxColor := rNorm
283283 if gNorm > maxColor {
@@ -286,7 +286,7 @@ func invertIfTooDark(img image.Image) image.Image {
286286 if bNorm > maxColor {
287287 maxColor = bNorm
288288 }
289-
289+
290290 minColor := rNorm
291291 if gNorm < minColor {
292292 minColor = gNorm
@@ -294,47 +294,47 @@ func invertIfTooDark(img image.Image) image.Image {
294294 if bNorm < minColor {
295295 minColor = bNorm
296296 }
297-
297+
298298 colorVariance += maxColor - minColor
299299 }
300300 }
301301 }
302-
302+
303303 if pixelCount == 0 {
304304 return img // All transparent
305305 }
306-
306+
307307 // Calculate average brightness (0-65535 range)
308308 avgR := totalR / pixelCount
309309 avgG := totalG / pixelCount
310310 avgB := totalB / pixelCount
311-
311+
312312 // Calculate perceived brightness using standard luminance formula
313313 brightness := (299*avgR + 587*avgG + 114*avgB) / 1000
314-
314+
315315 // Calculate average color variance (0-255 range)
316316 avgColorVariance := colorVariance / pixelCount
317-
317+
318318 // Calculate transparency ratio
319319 totalPixels := pixelCount + transparentPixels
320320 transparencyRatio := float64(transparentPixels) / float64(totalPixels)
321-
321+
322322 // Only invert if:
323323 // 1. Icon is very dark (brightness < 20000, low brightness)
324- // 2. AND icon is mostly monochrome (low color variance < 20)
324+ // 2. AND icon is mostly monochrome (low color variance < 20)
325325 // 3. AND icon is not mostly transparent (< 80% transparent)
326326 // This makes dark monochrome icons visible on dark themes while preserving colorful icons
327-
327+
328328 shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8
329-
330- log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t",
329+
330+ log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t",
331331 brightness, avgColorVariance, transparencyRatio, shouldInvert)
332-
332+
333333 if shouldInvert {
334334 log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility")
335335 return invertImage(img)
336336 }
337-
337+
338338 return img
339339 }
340340
@@ -342,28 +342,28 @@ func invertIfTooDark(img image.Image) image.Image {
342342 func invertImage(img image.Image) image.Image {
343343 bounds := img.Bounds()
344344 inverted := image.NewRGBA(bounds)
345-
345+
346346 for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
347347 for x := bounds.Min.X; x < bounds.Max.X; x++ {
348348 r, g, b, a := img.At(x, y).RGBA()
349-
349+
350350 // Skip fully transparent pixels to avoid noise
351351 if a == 0 {
352352 inverted.Set(x, y, color.RGBA{A: 0})
353353 continue
354354 }
355-
355+
356356 // Convert to 8-bit for easier processing
357357 r8 := uint8(r >> 8)
358358 g8 := uint8(g >> 8)
359359 b8 := uint8(b >> 8)
360360 a8 := uint8(a >> 8)
361-
361+
362362 // Invert RGB but preserve alpha
363363 newR := 255 - r8
364364 newG := 255 - g8
365365 newB := 255 - b8
366-
366+
367367 // For semi-transparent pixels, blend the inversion more gently
368368 // This reduces edge noise by making transitions smoother
369369 if a8 < 255 {
@@ -373,7 +373,7 @@ func invertImage(img image.Image) image.Image {
373373 newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor))
374374 newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor))
375375 }
376-
376+
377377 inverted.Set(x, y, color.RGBA{
378378 R: newR,
379379 G: newG,
@@ -382,6 +382,6 @@ func invertImage(img image.Image) image.Image {
382382 })
383383 }
384384 }
385-
385+
386386 return inverted
387-}⏎̸
387+}
@@ -5,9 +5,9 @@ import (
5 "fmt"5 "fmt"
6 "image"6 "image"
7 "image/color"7 "image/color"
8- "image/png"
9- _ "image/jpeg" // Register JPEG decoder
10 _ "image/gif" // Register GIF decoder8 _ "image/gif" // Register GIF decoder
9+ _ "image/jpeg" // Register JPEG decoder
10+ "image/png"
11 "io"11 "io"
12 "log"12 "log"
13 "net/http"13 "net/http"
@@ -107,9 +107,9 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
107 return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode)107 return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode)
108 }108 }
109 109
110- // Limit response body size 110+ // Limit response body size
111 limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max111 limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max
112- 112+
113 // Download to temporary file first113 // Download to temporary file first
114 tempDownloadFile := cachedPath + ".download"114 tempDownloadFile := cachedPath + ".download"
115 downloadFile, err := os.Create(tempDownloadFile)115 downloadFile, err := os.Create(tempDownloadFile)
@@ -140,18 +140,18 @@ func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) e
140 log.Printf("[favicon] ICO file detected for %s, using go-ico decoder", faviconURL)140 log.Printf("[favicon] ICO file detected for %s, using go-ico decoder", faviconURL)
141 return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)141 return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
142 }142 }
143- 143+
144 // Try standard Go image decoder for other formats (PNG, JPEG, GIF)144 // Try standard Go image decoder for other formats (PNG, JPEG, GIF)
145 if img, err := fc.tryStandardImageDecode(inputPath); err == nil {145 if img, err := fc.tryStandardImageDecode(inputPath); err == nil {
146 log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL)146 log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL)
147- 147+
148 // Apply dark theme improvements148 // Apply dark theme improvements
149 img = invertIfTooDark(img)149 img = invertIfTooDark(img)
150- 150+
151 // Save as PNG151 // Save as PNG
152 return fc.savePNG(img, outputPath)152 return fc.savePNG(img, outputPath)
153 }153 }
154- 154+
155 // Standard decode failed, fallback to go-ico155 // Standard decode failed, fallback to go-ico
156 log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL)156 log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL)
157 return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)157 return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
@@ -164,7 +164,7 @@ func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, er
164 return nil, err164 return nil, err
165 }165 }
166 defer file.Close()166 defer file.Close()
167- 167+
168 img, _, err := image.Decode(file)168 img, _, err := image.Decode(file)
169 return img, err169 return img, err
170 }170 }
@@ -177,20 +177,20 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
177 return fmt.Errorf("failed to create temp file: %w", err)177 return fmt.Errorf("failed to create temp file: %w", err)
178 }178 }
179 defer file.Close()179 defer file.Close()
180- 180+
181 if err := png.Encode(file, img); err != nil {181 if err := png.Encode(file, img); err != nil {
182 os.Remove(tempFile)182 os.Remove(tempFile)
183 return fmt.Errorf("failed to encode PNG: %w", err)183 return fmt.Errorf("failed to encode PNG: %w", err)
184 }184 }
185- 185+
186 file.Close()186 file.Close()
187- 187+
188 // Atomic move188 // Atomic move
189 if err := os.Rename(tempFile, outputPath); err != nil {189 if err := os.Rename(tempFile, outputPath); err != nil {
190 os.Remove(tempFile)190 os.Remove(tempFile)
191 return fmt.Errorf("failed to move PNG to cache: %w", err)191 return fmt.Errorf("failed to move PNG to cache: %w", err)
192 }192 }
193- 193+
194 return nil194 return nil
195 }195 }
196 196
@@ -202,18 +202,18 @@ func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL st
202 return fmt.Errorf("failed to open ICO file: %w", err)202 return fmt.Errorf("failed to open ICO file: %w", err)
203 }203 }
204 defer file.Close()204 defer file.Close()
205- 205+
206 // Decode ICO file206 // Decode ICO file
207 img, err := ico.Decode(file)207 img, err := ico.Decode(file)
208 if err != nil {208 if err != nil {
209 return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err)209 return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err)
210 }210 }
211- 211+
212 log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL)212 log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL)
213- 213+
214 // Apply dark theme improvements214 // Apply dark theme improvements
215 img = invertIfTooDark(img)215 img = invertIfTooDark(img)
216- 216+
217 // Save as PNG217 // Save as PNG
218 return fc.savePNG(img, outputPath)218 return fc.savePNG(img, outputPath)
219 }219 }
@@ -226,7 +226,7 @@ func (fc *FaviconCache) CleanOld() error {
226 }226 }
227 227
228 cutoff := time.Now().Add(-30 * 24 * time.Hour)228 cutoff := time.Now().Add(-30 * 24 * time.Hour)
229- 229+
230 for _, entry := range entries {230 for _, entry := range entries {
231 if entry.IsDir() {231 if entry.IsDir() {
232 continue232 continue
@@ -249,35 +249,35 @@ func (fc *FaviconCache) CleanOld() error {
249 // Only inverts truly monochrome dark icons, preserves colorful icons249 // Only inverts truly monochrome dark icons, preserves colorful icons
250 func invertIfTooDark(img image.Image) image.Image {250 func invertIfTooDark(img image.Image) image.Image {
251 bounds := img.Bounds()251 bounds := img.Bounds()
252- 252+
253 // Sample pixels to calculate statistics253 // Sample pixels to calculate statistics
254 var totalR, totalG, totalB uint32254 var totalR, totalG, totalB uint32
255 var pixelCount uint32255 var pixelCount uint32
256 var colorVariance uint32256 var colorVariance uint32
257 var transparentPixels uint32257 var transparentPixels uint32
258- 258+
259 // Sample every 4th pixel for performance259 // Sample every 4th pixel for performance
260 for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 {260 for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 {
261 for x := bounds.Min.X; x < bounds.Max.X; x += 4 {261 for x := bounds.Min.X; x < bounds.Max.X; x += 4 {
262 r, g, b, a := img.At(x, y).RGBA()262 r, g, b, a := img.At(x, y).RGBA()
263- 263+
264 if a == 0 {264 if a == 0 {
265 transparentPixels++265 transparentPixels++
266 continue266 continue
267 }267 }
268- 268+
269 if a > 0 { // Only count non-transparent pixels269 if a > 0 { // Only count non-transparent pixels
270 totalR += r270 totalR += r
271 totalG += g271 totalG += g
272 totalB += b272 totalB += b
273 pixelCount++273 pixelCount++
274- 274+
275 // Calculate color variance to detect colorful vs monochrome icons275 // Calculate color variance to detect colorful vs monochrome icons
276 // High variance = colorful (don't invert), low variance = monochrome276 // High variance = colorful (don't invert), low variance = monochrome
277 rNorm := r >> 8 // Convert to 0-255 range277 rNorm := r >> 8 // Convert to 0-255 range
278 gNorm := g >> 8278 gNorm := g >> 8
279 bNorm := b >> 8279 bNorm := b >> 8
280- 280+
281 // Simple color variance: difference between max and min color channels281 // Simple color variance: difference between max and min color channels
282 maxColor := rNorm282 maxColor := rNorm
283 if gNorm > maxColor {283 if gNorm > maxColor {
@@ -286,7 +286,7 @@ func invertIfTooDark(img image.Image) image.Image {
286 if bNorm > maxColor {286 if bNorm > maxColor {
287 maxColor = bNorm287 maxColor = bNorm
288 }288 }
289- 289+
290 minColor := rNorm290 minColor := rNorm
291 if gNorm < minColor {291 if gNorm < minColor {
292 minColor = gNorm292 minColor = gNorm
@@ -294,47 +294,47 @@ func invertIfTooDark(img image.Image) image.Image {
294 if bNorm < minColor {294 if bNorm < minColor {
295 minColor = bNorm295 minColor = bNorm
296 }296 }
297- 297+
298 colorVariance += maxColor - minColor298 colorVariance += maxColor - minColor
299 }299 }
300 }300 }
301 }301 }
302- 302+
303 if pixelCount == 0 {303 if pixelCount == 0 {
304 return img // All transparent304 return img // All transparent
305 }305 }
306- 306+
307 // Calculate average brightness (0-65535 range)307 // Calculate average brightness (0-65535 range)
308 avgR := totalR / pixelCount308 avgR := totalR / pixelCount
309 avgG := totalG / pixelCount309 avgG := totalG / pixelCount
310 avgB := totalB / pixelCount310 avgB := totalB / pixelCount
311- 311+
312 // Calculate perceived brightness using standard luminance formula312 // Calculate perceived brightness using standard luminance formula
313 brightness := (299*avgR + 587*avgG + 114*avgB) / 1000313 brightness := (299*avgR + 587*avgG + 114*avgB) / 1000
314- 314+
315 // Calculate average color variance (0-255 range)315 // Calculate average color variance (0-255 range)
316 avgColorVariance := colorVariance / pixelCount316 avgColorVariance := colorVariance / pixelCount
317- 317+
318 // Calculate transparency ratio318 // Calculate transparency ratio
319 totalPixels := pixelCount + transparentPixels319 totalPixels := pixelCount + transparentPixels
320 transparencyRatio := float64(transparentPixels) / float64(totalPixels)320 transparencyRatio := float64(transparentPixels) / float64(totalPixels)
321- 321+
322 // Only invert if:322 // Only invert if:
323 // 1. Icon is very dark (brightness < 20000, low brightness)323 // 1. Icon is very dark (brightness < 20000, low brightness)
324- // 2. AND icon is mostly monochrome (low color variance < 20) 324+ // 2. AND icon is mostly monochrome (low color variance < 20)
325 // 3. AND icon is not mostly transparent (< 80% transparent)325 // 3. AND icon is not mostly transparent (< 80% transparent)
326 // This makes dark monochrome icons visible on dark themes while preserving colorful icons326 // This makes dark monochrome icons visible on dark themes while preserving colorful icons
327- 327+
328 shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8328 shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8
329- 329+
330- log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t", 330+ log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t",
331 brightness, avgColorVariance, transparencyRatio, shouldInvert)331 brightness, avgColorVariance, transparencyRatio, shouldInvert)
332- 332+
333 if shouldInvert {333 if shouldInvert {
334 log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility")334 log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility")
335 return invertImage(img)335 return invertImage(img)
336 }336 }
337- 337+
338 return img338 return img
339 }339 }
340 340
@@ -342,28 +342,28 @@ func invertIfTooDark(img image.Image) image.Image {
342 func invertImage(img image.Image) image.Image {342 func invertImage(img image.Image) image.Image {
343 bounds := img.Bounds()343 bounds := img.Bounds()
344 inverted := image.NewRGBA(bounds)344 inverted := image.NewRGBA(bounds)
345- 345+
346 for y := bounds.Min.Y; y < bounds.Max.Y; y++ {346 for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
347 for x := bounds.Min.X; x < bounds.Max.X; x++ {347 for x := bounds.Min.X; x < bounds.Max.X; x++ {
348 r, g, b, a := img.At(x, y).RGBA()348 r, g, b, a := img.At(x, y).RGBA()
349- 349+
350 // Skip fully transparent pixels to avoid noise350 // Skip fully transparent pixels to avoid noise
351 if a == 0 {351 if a == 0 {
352 inverted.Set(x, y, color.RGBA{A: 0})352 inverted.Set(x, y, color.RGBA{A: 0})
353 continue353 continue
354 }354 }
355- 355+
356 // Convert to 8-bit for easier processing356 // Convert to 8-bit for easier processing
357 r8 := uint8(r >> 8)357 r8 := uint8(r >> 8)
358 g8 := uint8(g >> 8)358 g8 := uint8(g >> 8)
359 b8 := uint8(b >> 8)359 b8 := uint8(b >> 8)
360 a8 := uint8(a >> 8)360 a8 := uint8(a >> 8)
361- 361+
362 // Invert RGB but preserve alpha362 // Invert RGB but preserve alpha
363 newR := 255 - r8363 newR := 255 - r8
364 newG := 255 - g8364 newG := 255 - g8
365 newB := 255 - b8365 newB := 255 - b8
366- 366+
367 // For semi-transparent pixels, blend the inversion more gently367 // For semi-transparent pixels, blend the inversion more gently
368 // This reduces edge noise by making transitions smoother368 // This reduces edge noise by making transitions smoother
369 if a8 < 255 {369 if a8 < 255 {
@@ -373,7 +373,7 @@ func invertImage(img image.Image) image.Image {
373 newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor))373 newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor))
374 newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor))374 newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor))
375 }375 }
376- 376+
377 inverted.Set(x, y, color.RGBA{377 inverted.Set(x, y, color.RGBA{
378 R: newR,378 R: newR,
379 G: newG,379 G: newG,
@@ -382,6 +382,6 @@ func invertImage(img image.Image) image.Image {
382 })382 })
383 }383 }
384 }384 }
385- 385+
386 return inverted386 return inverted
387-}⏎̸387+}
M internal/cache/fuzzy_cache.go +5 -5
@@ -15,11 +15,11 @@ import (
1515
1616 // Cache update constants
1717 const (
18- updateWaitIntervalMs = 10 // Milliseconds to wait during concurrent cache build
19- dbHashTimeoutSec = 5 // Seconds timeout for database hash calculation
20- recentHistoryLimit = 20 // Number of recent entries to check for changes
21- exactMatchBonus = 0.95 // Score bonus for exact matches
22- dirPerm = 0755 // Directory permissions
18+ updateWaitIntervalMs = 10 // Milliseconds to wait during concurrent cache build
19+ dbHashTimeoutSec = 5 // Seconds timeout for database hash calculation
20+ recentHistoryLimit = 20 // Number of recent entries to check for changes
21+ exactMatchBonus = 0.95 // Score bonus for exact matches
22+ dirPerm = 0755 // Directory permissions
2323 )
2424
2525 // CacheManager handles the lifecycle of the fuzzy cache with smart invalidation.
@@ -15,11 +15,11 @@ import (
15 15
16 // Cache update constants16 // Cache update constants
17 const (17 const (
18- updateWaitIntervalMs = 10 // Milliseconds to wait during concurrent cache build18+ updateWaitIntervalMs = 10 // Milliseconds to wait during concurrent cache build
19- dbHashTimeoutSec = 5 // Seconds timeout for database hash calculation19+ dbHashTimeoutSec = 5 // Seconds timeout for database hash calculation
20- recentHistoryLimit = 20 // Number of recent entries to check for changes20+ recentHistoryLimit = 20 // Number of recent entries to check for changes
21- exactMatchBonus = 0.95 // Score bonus for exact matches21+ exactMatchBonus = 0.95 // Score bonus for exact matches
22- dirPerm = 0755 // Directory permissions22+ dirPerm = 0755 // Directory permissions
23 )23 )
24 24
25 // CacheManager handles the lifecycle of the fuzzy cache with smart invalidation.25 // CacheManager handles the lifecycle of the fuzzy cache with smart invalidation.
M internal/cache/fuzzy_cache_test.go +23 -23
@@ -487,11 +487,11 @@ func TestCacheInvalidation(t *testing.T) {
487487
488488 // First call returns initial history
489489 firstHistory := createTestHistory()[:3] // Only 3 entries
490- secondHistory := createTestHistory() // All 5 entries
491-
490+ secondHistory := createTestHistory() // All 5 entries
491+
492492 // Set up multiple expectations for the different calls
493493 gomock.InOrder(
494- mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(firstHistory, nil).Times(2), // Build + hash
494+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(firstHistory, nil).Times(2), // Build + hash
495495 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(secondHistory, nil).AnyTimes(), // Subsequent calls
496496 )
497497
@@ -542,7 +542,7 @@ func TestCacheInvalidation(t *testing.T) {
542542 func create40TestEntries() []db.History {
543543 now := time.Now()
544544 entries := make([]db.History, 40)
545-
545+
546546 urls := []string{
547547 "https://github.com", "https://stackoverflow.com", "https://golang.org", "https://news.ycombinator.com",
548548 "https://youtube.com", "https://google.com", "https://reddit.com", "https://twitter.com",
@@ -555,7 +555,7 @@ func create40TestEntries() []db.History {
555555 "https://instagram.com", "https://tiktok.com", "https://netflix.com", "https://spotify.com",
556556 "https://apple.com", "https://microsoft.com", "https://amazon.com", "https://wikipedia.org",
557557 }
558-
558+
559559 titles := []string{
560560 "GitHub", "Stack Overflow", "Go Programming Language", "Hacker News",
561561 "YouTube", "Google", "Reddit", "Twitter",
@@ -568,7 +568,7 @@ func create40TestEntries() []db.History {
568568 "Instagram", "TikTok", "Netflix", "Spotify",
569569 "Apple", "Microsoft", "Amazon", "Wikipedia",
570570 }
571-
571+
572572 for i := 0; i < 40; i++ {
573573 entries[i] = db.History{
574574 ID: int64(i + 1),
@@ -578,7 +578,7 @@ func create40TestEntries() []db.History {
578578 LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
579579 }
580580 }
581-
581+
582582 return entries
583583 }
584584
@@ -586,13 +586,13 @@ func create40TestEntries() []db.History {
586586 func BenchmarkGetTopEntries(b *testing.B) {
587587 ctrl := gomock.NewController(b)
588588 defer ctrl.Finish()
589-
589+
590590 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
591-
591+
592592 // Setup 40 test entries
593593 testHistory := create40TestEntries()
594594 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
595-
595+
596596 // Create temporary directory for cache file
597597 tempDir, err := os.MkdirTemp("", "cache_benchmark")
598598 if err != nil {
@@ -603,23 +603,23 @@ func BenchmarkGetTopEntries(b *testing.B) {
603603 b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
604604 }
605605 }()
606-
606+
607607 config := DefaultCacheConfig()
608608 config.CacheFile = filepath.Join(tempDir, "benchmark_cache.bin")
609609 config.MaxResults = 50
610-
610+
611611 manager := NewCacheManager(mockQuerier, config)
612612 ctx := context.Background()
613-
613+
614614 // Pre-build cache once (this isn't part of the benchmark)
615615 _, err = manager.GetCache(ctx)
616616 if err != nil {
617617 b.Fatalf("Failed to pre-build cache: %v", err)
618618 }
619-
619+
620620 b.ResetTimer() // Start timing from here
621621 b.ReportAllocs()
622-
622+
623623 // This is the critical path that needs to be under 2ms
624624 for i := 0; i < b.N; i++ {
625625 result, err := manager.GetTopEntries(ctx)
@@ -636,13 +636,13 @@ func BenchmarkGetTopEntries(b *testing.B) {
636636 func BenchmarkGetTopEntriesParallel(b *testing.B) {
637637 ctrl := gomock.NewController(b)
638638 defer ctrl.Finish()
639-
639+
640640 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
641-
641+
642642 // Setup 40 test entries
643643 testHistory := create40TestEntries()
644644 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
645-
645+
646646 // Create temporary directory for cache file
647647 tempDir, err := os.MkdirTemp("", "cache_benchmark_parallel")
648648 if err != nil {
@@ -653,23 +653,23 @@ func BenchmarkGetTopEntriesParallel(b *testing.B) {
653653 b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
654654 }
655655 }()
656-
656+
657657 config := DefaultCacheConfig()
658658 config.CacheFile = filepath.Join(tempDir, "benchmark_parallel_cache.bin")
659659 config.MaxResults = 50
660-
660+
661661 manager := NewCacheManager(mockQuerier, config)
662662 ctx := context.Background()
663-
663+
664664 // Pre-build cache once (this isn't part of the benchmark)
665665 _, err = manager.GetCache(ctx)
666666 if err != nil {
667667 b.Fatalf("Failed to pre-build cache: %v", err)
668668 }
669-
669+
670670 b.ResetTimer()
671671 b.ReportAllocs()
672-
672+
673673 b.RunParallel(func(pb *testing.PB) {
674674 for pb.Next() {
675675 result, err := manager.GetTopEntries(ctx)
@@ -487,11 +487,11 @@ func TestCacheInvalidation(t *testing.T) {
487 487
488 // First call returns initial history488 // First call returns initial history
489 firstHistory := createTestHistory()[:3] // Only 3 entries489 firstHistory := createTestHistory()[:3] // Only 3 entries
490- secondHistory := createTestHistory() // All 5 entries490+ secondHistory := createTestHistory() // All 5 entries
491- 491+
492 // Set up multiple expectations for the different calls492 // Set up multiple expectations for the different calls
493 gomock.InOrder(493 gomock.InOrder(
494- mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(firstHistory, nil).Times(2), // Build + hash494+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(firstHistory, nil).Times(2), // Build + hash
495 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(secondHistory, nil).AnyTimes(), // Subsequent calls495 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(secondHistory, nil).AnyTimes(), // Subsequent calls
496 )496 )
497 497
@@ -542,7 +542,7 @@ func TestCacheInvalidation(t *testing.T) {
542 func create40TestEntries() []db.History {542 func create40TestEntries() []db.History {
543 now := time.Now()543 now := time.Now()
544 entries := make([]db.History, 40)544 entries := make([]db.History, 40)
545- 545+
546 urls := []string{546 urls := []string{
547 "https://github.com", "https://stackoverflow.com", "https://golang.org", "https://news.ycombinator.com",547 "https://github.com", "https://stackoverflow.com", "https://golang.org", "https://news.ycombinator.com",
548 "https://youtube.com", "https://google.com", "https://reddit.com", "https://twitter.com",548 "https://youtube.com", "https://google.com", "https://reddit.com", "https://twitter.com",
@@ -555,7 +555,7 @@ func create40TestEntries() []db.History {
555 "https://instagram.com", "https://tiktok.com", "https://netflix.com", "https://spotify.com",555 "https://instagram.com", "https://tiktok.com", "https://netflix.com", "https://spotify.com",
556 "https://apple.com", "https://microsoft.com", "https://amazon.com", "https://wikipedia.org",556 "https://apple.com", "https://microsoft.com", "https://amazon.com", "https://wikipedia.org",
557 }557 }
558- 558+
559 titles := []string{559 titles := []string{
560 "GitHub", "Stack Overflow", "Go Programming Language", "Hacker News",560 "GitHub", "Stack Overflow", "Go Programming Language", "Hacker News",
561 "YouTube", "Google", "Reddit", "Twitter",561 "YouTube", "Google", "Reddit", "Twitter",
@@ -568,7 +568,7 @@ func create40TestEntries() []db.History {
568 "Instagram", "TikTok", "Netflix", "Spotify",568 "Instagram", "TikTok", "Netflix", "Spotify",
569 "Apple", "Microsoft", "Amazon", "Wikipedia",569 "Apple", "Microsoft", "Amazon", "Wikipedia",
570 }570 }
571- 571+
572 for i := 0; i < 40; i++ {572 for i := 0; i < 40; i++ {
573 entries[i] = db.History{573 entries[i] = db.History{
574 ID: int64(i + 1),574 ID: int64(i + 1),
@@ -578,7 +578,7 @@ func create40TestEntries() []db.History {
578 LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},578 LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
579 }579 }
580 }580 }
581- 581+
582 return entries582 return entries
583 }583 }
584 584
@@ -586,13 +586,13 @@ func create40TestEntries() []db.History {
586 func BenchmarkGetTopEntries(b *testing.B) {586 func BenchmarkGetTopEntries(b *testing.B) {
587 ctrl := gomock.NewController(b)587 ctrl := gomock.NewController(b)
588 defer ctrl.Finish()588 defer ctrl.Finish()
589- 589+
590 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)590 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
591- 591+
592 // Setup 40 test entries592 // Setup 40 test entries
593 testHistory := create40TestEntries()593 testHistory := create40TestEntries()
594 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()594 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
595- 595+
596 // Create temporary directory for cache file596 // Create temporary directory for cache file
597 tempDir, err := os.MkdirTemp("", "cache_benchmark")597 tempDir, err := os.MkdirTemp("", "cache_benchmark")
598 if err != nil {598 if err != nil {
@@ -603,23 +603,23 @@ func BenchmarkGetTopEntries(b *testing.B) {
603 b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)603 b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
604 }604 }
605 }()605 }()
606- 606+
607 config := DefaultCacheConfig()607 config := DefaultCacheConfig()
608 config.CacheFile = filepath.Join(tempDir, "benchmark_cache.bin")608 config.CacheFile = filepath.Join(tempDir, "benchmark_cache.bin")
609 config.MaxResults = 50609 config.MaxResults = 50
610- 610+
611 manager := NewCacheManager(mockQuerier, config)611 manager := NewCacheManager(mockQuerier, config)
612 ctx := context.Background()612 ctx := context.Background()
613- 613+
614 // Pre-build cache once (this isn't part of the benchmark)614 // Pre-build cache once (this isn't part of the benchmark)
615 _, err = manager.GetCache(ctx)615 _, err = manager.GetCache(ctx)
616 if err != nil {616 if err != nil {
617 b.Fatalf("Failed to pre-build cache: %v", err)617 b.Fatalf("Failed to pre-build cache: %v", err)
618 }618 }
619- 619+
620 b.ResetTimer() // Start timing from here620 b.ResetTimer() // Start timing from here
621 b.ReportAllocs()621 b.ReportAllocs()
622- 622+
623 // This is the critical path that needs to be under 2ms623 // This is the critical path that needs to be under 2ms
624 for i := 0; i < b.N; i++ {624 for i := 0; i < b.N; i++ {
625 result, err := manager.GetTopEntries(ctx)625 result, err := manager.GetTopEntries(ctx)
@@ -636,13 +636,13 @@ func BenchmarkGetTopEntries(b *testing.B) {
636 func BenchmarkGetTopEntriesParallel(b *testing.B) {636 func BenchmarkGetTopEntriesParallel(b *testing.B) {
637 ctrl := gomock.NewController(b)637 ctrl := gomock.NewController(b)
638 defer ctrl.Finish()638 defer ctrl.Finish()
639- 639+
640 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)640 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
641- 641+
642 // Setup 40 test entries642 // Setup 40 test entries
643 testHistory := create40TestEntries()643 testHistory := create40TestEntries()
644 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()644 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
645- 645+
646 // Create temporary directory for cache file646 // Create temporary directory for cache file
647 tempDir, err := os.MkdirTemp("", "cache_benchmark_parallel")647 tempDir, err := os.MkdirTemp("", "cache_benchmark_parallel")
648 if err != nil {648 if err != nil {
@@ -653,23 +653,23 @@ func BenchmarkGetTopEntriesParallel(b *testing.B) {
653 b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)653 b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
654 }654 }
655 }()655 }()
656- 656+
657 config := DefaultCacheConfig()657 config := DefaultCacheConfig()
658 config.CacheFile = filepath.Join(tempDir, "benchmark_parallel_cache.bin")658 config.CacheFile = filepath.Join(tempDir, "benchmark_parallel_cache.bin")
659 config.MaxResults = 50659 config.MaxResults = 50
660- 660+
661 manager := NewCacheManager(mockQuerier, config)661 manager := NewCacheManager(mockQuerier, config)
662 ctx := context.Background()662 ctx := context.Background()
663- 663+
664 // Pre-build cache once (this isn't part of the benchmark)664 // Pre-build cache once (this isn't part of the benchmark)
665 _, err = manager.GetCache(ctx)665 _, err = manager.GetCache(ctx)
666 if err != nil {666 if err != nil {
667 b.Fatalf("Failed to pre-build cache: %v", err)667 b.Fatalf("Failed to pre-build cache: %v", err)
668 }668 }
669- 669+
670 b.ResetTimer()670 b.ResetTimer()
671 b.ReportAllocs()671 b.ReportAllocs()
672- 672+
673 b.RunParallel(func(pb *testing.PB) {673 b.RunParallel(func(pb *testing.PB) {
674 for pb.Next() {674 for pb.Next() {
675 result, err := manager.GetTopEntries(ctx)675 result, err := manager.GetTopEntries(ctx)
M internal/cache/fuzzy_search.go +3 -3
@@ -10,9 +10,9 @@ import (
1010
1111 // Fuzzy search constants
1212 const (
13- prefixMatchBonus = 0.9 // Score bonus for prefix matches
14- prefixLength = 4 // Maximum prefix length to check
15- recencyDecayDays = 30.0 // Days over which recency score decays
13+ prefixMatchBonus = 0.9 // Score bonus for prefix matches
14+ prefixLength = 4 // Maximum prefix length to check
15+ recencyDecayDays = 30.0 // Days over which recency score decays
1616 maxVisitNormal = 1000.0 // Maximum visits for normalization
1717 )
1818
@@ -10,9 +10,9 @@ import (
10 10
11 // Fuzzy search constants11 // Fuzzy search constants
12 const (12 const (
13- prefixMatchBonus = 0.9 // Score bonus for prefix matches13+ prefixMatchBonus = 0.9 // Score bonus for prefix matches
14- prefixLength = 4 // Maximum prefix length to check14+ prefixLength = 4 // Maximum prefix length to check
15- recencyDecayDays = 30.0 // Days over which recency score decays15+ recencyDecayDays = 30.0 // Days over which recency score decays
16 maxVisitNormal = 1000.0 // Maximum visits for normalization16 maxVisitNormal = 1000.0 // Maximum visits for normalization
17 )17 )
18 18
M internal/cli/browse.go +1 -3
@@ -88,7 +88,6 @@ func browse(cli *CLI, input string) error {
8888 return openURLWithConfig(finalURL, cli.Config)
8989 }
9090
91-
9291 // recordVisit adds or updates a URL visit in the history
9392 func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
9493 var sqlTitle sql.NullString
@@ -119,7 +118,7 @@ func openURLWithConfig(url string, cfg *config.Config) error {
119118 // 1. Config file defaults
120119 // 2. Environment variables (override config)
121120 // 3. Command line flags (override both)
122- //
121+ //
123122 // We don't convert config to env vars here because that would break precedence.
124123 // The cfg parameter ensures we use the same config source in both processes.
125124
@@ -167,4 +166,3 @@ func updateFavicon(ctx context.Context, cli *CLI, pageURL string) {
167166 faviconCache.CacheAsync(faviconURL)
168167 }
169168 }
170-
@@ -88,7 +88,6 @@ func browse(cli *CLI, input string) error {
88 return openURLWithConfig(finalURL, cli.Config)88 return openURLWithConfig(finalURL, cli.Config)
89 }89 }
90 90
91-
92 // recordVisit adds or updates a URL visit in the history91 // recordVisit adds or updates a URL visit in the history
93 func recordVisit(ctx context.Context, cli *CLI, url, title string) error {92 func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
94 var sqlTitle sql.NullString93 var sqlTitle sql.NullString
@@ -119,7 +118,7 @@ func openURLWithConfig(url string, cfg *config.Config) error {
119 // 1. Config file defaults118 // 1. Config file defaults
120 // 2. Environment variables (override config)119 // 2. Environment variables (override config)
121 // 3. Command line flags (override both)120 // 3. Command line flags (override both)
122- // 121+ //
123 // We don't convert config to env vars here because that would break precedence.122 // We don't convert config to env vars here because that would break precedence.
124 // The cfg parameter ensures we use the same config source in both processes.123 // The cfg parameter ensures we use the same config source in both processes.
125 124
@@ -167,4 +166,3 @@ func updateFavicon(ctx context.Context, cli *CLI, pageURL string) {
167 faviconCache.CacheAsync(faviconURL)166 faviconCache.CacheAsync(faviconURL)
168 }167 }
169 }168 }
170-
M internal/cli/dmenu.go +10 -12
@@ -85,7 +85,7 @@ func generateOptions(cli *CLI) error {
8585 // Add history entries from cache (already sorted by relevance!)
8686 for _, match := range result.Matches {
8787 entry := match.Entry
88-
88+
8989 // Parse URL for domain extraction
9090 parsedURL, err := url.Parse(entry.URL)
9191 var domain string
@@ -94,7 +94,7 @@ func generateOptions(cli *CLI) error {
9494 } else {
9595 domain = parsedURL.Host
9696 }
97-
97+
9898 // Determine title
9999 title := entry.Title
100100 if title == "" || title == entry.URL {
@@ -105,7 +105,7 @@ func generateOptions(cli *CLI) error {
105105 title = fmt.Sprintf("[%s]", domain)
106106 }
107107 }
108-
108+
109109 // Check if it's a Google search result and format accordingly
110110 isGoogleSearch := domain == "www.google.com" && strings.Contains(entry.URL, "/search?q=")
111111 if isGoogleSearch {
@@ -115,17 +115,17 @@ func generateOptions(cli *CLI) error {
115115 }
116116 }
117117 }
118-
118+
119119 // Format: "Title | domain.com | full-url"
120120 // Using pipe separator for rofi/dmenu compatibility
121- display := fmt.Sprintf("%s | %s | %s",
121+ display := fmt.Sprintf("%s | %s | %s",
122122 truncateString(title, 50),
123123 domain,
124124 truncateString(entry.URL, 70))
125125
126126 // Get favicon URL from cached entry
127127 faviconURL := match.Entry.FaviconURL
128-
128+
129129 options = append(options, DmenuOption{
130130 Display: display,
131131 Value: entry.URL,
@@ -263,7 +263,6 @@ func handleSelection(cli *CLI) error {
263263 return fmt.Errorf("empty selection")
264264 }
265265
266-
267266 // Parse the selection to get the actual URL from history entry
268267 input := parseSelection(selection)
269268
@@ -273,7 +272,7 @@ func handleSelection(cli *CLI) error {
273272
274273 // Browse the selected/entered URL
275274 err := browse(cli, input)
276-
275+
277276 // After successful browse, invalidate cache so next dmenu shows updated order
278277 if err == nil {
279278 ctx := context.Background()
@@ -281,11 +280,10 @@ func handleSelection(cli *CLI) error {
281280 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
282281 cacheManager.InvalidateAndRefresh(ctx)
283282 }
284-
283+
285284 return err
286285 }
287286
288-
289287 // DmenuOption represents a selectable option in dmenu mode
290288 type DmenuOption struct {
291289 Display string
@@ -352,7 +350,7 @@ func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {
352350 if option.Type != historyType {
353351 return ""
354352 }
355-
353+
356354 // Only use cached favicons - no system theme fallbacks for consistent sizing
357355 if option.FaviconURL != "" && faviconCache != nil {
358356 cachedPath := faviconCache.GetCachedPath(option.FaviconURL)
@@ -362,7 +360,7 @@ func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {
362360 // Start async download for next time
363361 faviconCache.CacheAsync(option.FaviconURL)
364362 }
365-
363+
366364 // No favicon available - return empty string to show no icon
367365 return ""
368366 }
@@ -85,7 +85,7 @@ func generateOptions(cli *CLI) error {
85 // Add history entries from cache (already sorted by relevance!)85 // Add history entries from cache (already sorted by relevance!)
86 for _, match := range result.Matches {86 for _, match := range result.Matches {
87 entry := match.Entry87 entry := match.Entry
88- 88+
89 // Parse URL for domain extraction89 // Parse URL for domain extraction
90 parsedURL, err := url.Parse(entry.URL)90 parsedURL, err := url.Parse(entry.URL)
91 var domain string91 var domain string
@@ -94,7 +94,7 @@ func generateOptions(cli *CLI) error {
94 } else {94 } else {
95 domain = parsedURL.Host95 domain = parsedURL.Host
96 }96 }
97- 97+
98 // Determine title98 // Determine title
99 title := entry.Title99 title := entry.Title
100 if title == "" || title == entry.URL {100 if title == "" || title == entry.URL {
@@ -105,7 +105,7 @@ func generateOptions(cli *CLI) error {
105 title = fmt.Sprintf("[%s]", domain)105 title = fmt.Sprintf("[%s]", domain)
106 }106 }
107 }107 }
108- 108+
109 // Check if it's a Google search result and format accordingly109 // Check if it's a Google search result and format accordingly
110 isGoogleSearch := domain == "www.google.com" && strings.Contains(entry.URL, "/search?q=")110 isGoogleSearch := domain == "www.google.com" && strings.Contains(entry.URL, "/search?q=")
111 if isGoogleSearch {111 if isGoogleSearch {
@@ -115,17 +115,17 @@ func generateOptions(cli *CLI) error {
115 }115 }
116 }116 }
117 }117 }
118- 118+
119 // Format: "Title | domain.com | full-url"119 // Format: "Title | domain.com | full-url"
120 // Using pipe separator for rofi/dmenu compatibility120 // Using pipe separator for rofi/dmenu compatibility
121- display := fmt.Sprintf("%s | %s | %s", 121+ display := fmt.Sprintf("%s | %s | %s",
122 truncateString(title, 50),122 truncateString(title, 50),
123 domain,123 domain,
124 truncateString(entry.URL, 70))124 truncateString(entry.URL, 70))
125 125
126 // Get favicon URL from cached entry126 // Get favicon URL from cached entry
127 faviconURL := match.Entry.FaviconURL127 faviconURL := match.Entry.FaviconURL
128- 128+
129 options = append(options, DmenuOption{129 options = append(options, DmenuOption{
130 Display: display,130 Display: display,
131 Value: entry.URL,131 Value: entry.URL,
@@ -263,7 +263,6 @@ func handleSelection(cli *CLI) error {
263 return fmt.Errorf("empty selection")263 return fmt.Errorf("empty selection")
264 }264 }
265 265
266-
267 // Parse the selection to get the actual URL from history entry266 // Parse the selection to get the actual URL from history entry
268 input := parseSelection(selection)267 input := parseSelection(selection)
269 268
@@ -273,7 +272,7 @@ func handleSelection(cli *CLI) error {
273 272
274 // Browse the selected/entered URL273 // Browse the selected/entered URL
275 err := browse(cli, input)274 err := browse(cli, input)
276- 275+
277 // After successful browse, invalidate cache so next dmenu shows updated order276 // After successful browse, invalidate cache so next dmenu shows updated order
278 if err == nil {277 if err == nil {
279 ctx := context.Background()278 ctx := context.Background()
@@ -281,11 +280,10 @@ func handleSelection(cli *CLI) error {
281 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)280 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
282 cacheManager.InvalidateAndRefresh(ctx)281 cacheManager.InvalidateAndRefresh(ctx)
283 }282 }
284- 283+
285 return err284 return err
286 }285 }
287 286
288-
289 // DmenuOption represents a selectable option in dmenu mode287 // DmenuOption represents a selectable option in dmenu mode
290 type DmenuOption struct {288 type DmenuOption struct {
291 Display string289 Display string
@@ -352,7 +350,7 @@ func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {
352 if option.Type != historyType {350 if option.Type != historyType {
353 return ""351 return ""
354 }352 }
355- 353+
356 // Only use cached favicons - no system theme fallbacks for consistent sizing354 // Only use cached favicons - no system theme fallbacks for consistent sizing
357 if option.FaviconURL != "" && faviconCache != nil {355 if option.FaviconURL != "" && faviconCache != nil {
358 cachedPath := faviconCache.GetCachedPath(option.FaviconURL)356 cachedPath := faviconCache.GetCachedPath(option.FaviconURL)
@@ -362,7 +360,7 @@ func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {
362 // Start async download for next time360 // Start async download for next time
363 faviconCache.CacheAsync(option.FaviconURL)361 faviconCache.CacheAsync(option.FaviconURL)
364 }362 }
365- 363+
366 // No favicon available - return empty string to show no icon364 // No favicon available - return empty string to show no icon
367 return ""365 return ""
368 }366 }
M internal/cli/logs.go +8 -8
@@ -180,7 +180,7 @@ func listLogFiles(logDir string) error {
180180 if file.IsDir() {
181181 continue
182182 }
183-
183+
184184 name := file.Name()
185185 if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") {
186186 info, err := file.Info()
@@ -204,7 +204,7 @@ func listLogFiles(logDir string) error {
204204 // Display files
205205 fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE")
206206 fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----")
207-
207+
208208 for _, file := range logFiles {
209209 size := formatSize(file.Size())
210210 modTime := file.ModTime().Format("2006-01-02 15:04:05")
@@ -253,9 +253,9 @@ func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
253253
254254 if info.ModTime().Before(cutoffTime) {
255255 filePath := filepath.Join(logDir, name)
256-
256+
257257 if dryRun {
258- fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n",
258+ fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n",
259259 name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05"))
260260 } else {
261261 err := os.Remove(filePath)
@@ -265,17 +265,17 @@ func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
265265 fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024)
266266 }
267267 }
268-
268+
269269 deletedCount++
270270 deletedSize += info.Size()
271271 }
272272 }
273273
274274 if dryRun {
275- fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n",
275+ fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n",
276276 deletedCount, float64(deletedSize)/1024)
277277 } else {
278- fmt.Printf("\nCleaned %d files (%.2f KB total)\n",
278+ fmt.Printf("\nCleaned %d files (%.2f KB total)\n",
279279 deletedCount, float64(deletedSize)/1024)
280280 }
281281
@@ -296,4 +296,4 @@ func formatSize(size int64) string {
296296 }
297297
298298 return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
299-}⏎̸
299+}
@@ -180,7 +180,7 @@ func listLogFiles(logDir string) error {
180 if file.IsDir() {180 if file.IsDir() {
181 continue181 continue
182 }182 }
183- 183+
184 name := file.Name()184 name := file.Name()
185 if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") {185 if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") {
186 info, err := file.Info()186 info, err := file.Info()
@@ -204,7 +204,7 @@ func listLogFiles(logDir string) error {
204 // Display files204 // Display files
205 fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE")205 fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE")
206 fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----")206 fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----")
207- 207+
208 for _, file := range logFiles {208 for _, file := range logFiles {
209 size := formatSize(file.Size())209 size := formatSize(file.Size())
210 modTime := file.ModTime().Format("2006-01-02 15:04:05")210 modTime := file.ModTime().Format("2006-01-02 15:04:05")
@@ -253,9 +253,9 @@ func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
253 253
254 if info.ModTime().Before(cutoffTime) {254 if info.ModTime().Before(cutoffTime) {
255 filePath := filepath.Join(logDir, name)255 filePath := filepath.Join(logDir, name)
256- 256+
257 if dryRun {257 if dryRun {
258- fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n", 258+ fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n",
259 name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05"))259 name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05"))
260 } else {260 } else {
261 err := os.Remove(filePath)261 err := os.Remove(filePath)
@@ -265,17 +265,17 @@ func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
265 fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024)265 fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024)
266 }266 }
267 }267 }
268- 268+
269 deletedCount++269 deletedCount++
270 deletedSize += info.Size()270 deletedSize += info.Size()
271 }271 }
272 }272 }
273 273
274 if dryRun {274 if dryRun {
275- fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n", 275+ fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n",
276 deletedCount, float64(deletedSize)/1024)276 deletedCount, float64(deletedSize)/1024)
277 } else {277 } else {
278- fmt.Printf("\nCleaned %d files (%.2f KB total)\n", 278+ fmt.Printf("\nCleaned %d files (%.2f KB total)\n",
279 deletedCount, float64(deletedSize)/1024)279 deletedCount, float64(deletedSize)/1024)
280 }280 }
281 281
@@ -296,4 +296,4 @@ func formatSize(size int64) string {
296 }296 }
297 297
298 return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])298 return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
299-}⏎̸299+}
M internal/cli/purge.go +1 -1
@@ -276,4 +276,4 @@ func deletePath(path string) error {
276276 return os.RemoveAll(path)
277277 }
278278 return os.Remove(path)
279-}⏎̸
279+}
@@ -276,4 +276,4 @@ func deletePath(path string) error {
276 return os.RemoveAll(path)276 return os.RemoveAll(path)
277 }277 }
278 return os.Remove(path)278 return os.Remove(path)
279-}⏎̸279+}
M internal/cli/root.go +0 -1
@@ -192,7 +192,6 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
192192 return rootCmd
193193 }
194194
195-
196195 // initializeDatabase runs embedded migrations and ensures database is up to date
197196 func initializeDatabase(db *sql.DB, cfg *config.Config) error {
198197 // Run embedded migrations - this will create all tables and apply any new migrations
@@ -192,7 +192,6 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
192 return rootCmd192 return rootCmd
193 }193 }
194 194
195-
196 // initializeDatabase runs embedded migrations and ensures database is up to date195 // initializeDatabase runs embedded migrations and ensures database is up to date
197 func initializeDatabase(db *sql.DB, cfg *config.Config) error {196 func initializeDatabase(db *sql.DB, cfg *config.Config) error {
198 // Run embedded migrations - this will create all tables and apply any new migrations197 // Run embedded migrations - this will create all tables and apply any new migrations
M internal/config/config.go +127 -15
@@ -24,13 +24,14 @@ const (
2424
2525 // Config represents the complete configuration for dumber.
2626 type Config struct {
27- Database DatabaseConfig `mapstructure:"database" yaml:"database"`
28- History HistoryConfig `mapstructure:"history" yaml:"history"`
29- SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
30- Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
31- Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
32- Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
33- VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
27+ Database DatabaseConfig `mapstructure:"database" yaml:"database"`
28+ History HistoryConfig `mapstructure:"history" yaml:"history"`
29+ SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
30+ Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
31+ Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
32+ Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
33+ VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
34+ CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"`
3435 // RenderingMode controls GPU/CPU rendering selection for WebKit
3536 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
3637 }
@@ -79,13 +80,13 @@ type DmenuConfig struct {
7980
8081 // LoggingConfig holds logging configuration.
8182 type LoggingConfig struct {
82- Level string `mapstructure:"level" yaml:"level"`
83- Format string `mapstructure:"format" yaml:"format"`
84- Filename string `mapstructure:"filename" yaml:"filename"`
85- MaxSize int `mapstructure:"max_size" yaml:"max_size"`
86- MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"`
87- MaxAge int `mapstructure:"max_age" yaml:"max_age"`
88- Compress bool `mapstructure:"compress" yaml:"compress"`
83+ Level string `mapstructure:"level" yaml:"level"`
84+ Format string `mapstructure:"format" yaml:"format"`
85+ Filename string `mapstructure:"filename" yaml:"filename"`
86+ MaxSize int `mapstructure:"max_size" yaml:"max_size"`
87+ MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"`
88+ MaxAge int `mapstructure:"max_age" yaml:"max_age"`
89+ Compress bool `mapstructure:"compress" yaml:"compress"`
8990
9091 // File output configuration
9192 LogDir string `mapstructure:"log_dir" yaml:"log_dir"`
@@ -120,6 +121,33 @@ type VideoAccelerationConfig struct {
120121 LegacyVAAPI bool `mapstructure:"legacy_vaapi" yaml:"legacy_vaapi"`
121122 }
122123
124+// CodecConfig holds video codec preferences and handling
125+type CodecConfig struct {
126+ // Codec preference order (e.g., "av1,h264,vp8")
127+ PreferredCodecs string `mapstructure:"preferred_codecs" yaml:"preferred_codecs"`
128+
129+ // Force specific codec for platforms
130+ ForceAV1 bool `mapstructure:"force_av1" yaml:"force_av1"`
131+
132+ // Block problematic codecs
133+ BlockVP9 bool `mapstructure:"block_vp9" yaml:"block_vp9"`
134+ BlockVP8 bool `mapstructure:"block_vp8" yaml:"block_vp8"`
135+
136+ // Hardware acceleration per codec
137+ AV1HardwareOnly bool `mapstructure:"av1_hardware_only" yaml:"av1_hardware_only"`
138+ DisableVP9Hardware bool `mapstructure:"disable_vp9_hardware" yaml:"disable_vp9_hardware"`
139+
140+ // Buffer configuration for smooth playback
141+ VideoBufferSizeMB int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb"`
142+ QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec"`
143+
144+ // Custom User-Agent for codec negotiation
145+ CustomUserAgent string `mapstructure:"custom_user_agent" yaml:"custom_user_agent"`
146+
147+ // Maximum resolution for AV1 codec (720p, 1080p, 1440p, 4k, unlimited)
148+ AV1MaxResolution string `mapstructure:"av1_max_resolution" yaml:"av1_max_resolution"`
149+}
150+
123151 // Manager handles configuration loading, watching, and reloading.
124152 type Manager struct {
125153 config *Config
@@ -194,7 +222,7 @@ func NewManager() (*Manager, error) {
194222 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",
195223 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",
196224 "video_acceleration.vaapi_driver_name": "LIBVA_DRIVER_NAME",
197- "video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS",
225+ "video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS",
198226 "video_acceleration.legacy_vaapi": "WEBKIT_GST_ENABLE_LEGACY_VAAPI",
199227 }
200228
@@ -204,6 +232,26 @@ func NewManager() (*Manager, error) {
204232 }
205233 }
206234
235+ // Codec preferences environment variable bindings
236+ codecEnvBindings := map[string]string{
237+ "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",
238+ "codec_preferences.force_av1": "DUMBER_FORCE_AV1",
239+ "codec_preferences.block_vp9": "DUMBER_BLOCK_VP9",
240+ "codec_preferences.block_vp8": "DUMBER_BLOCK_VP8",
241+ "codec_preferences.av1_hardware_only": "DUMBER_AV1_HW_ONLY",
242+ "codec_preferences.disable_vp9_hardware": "DUMBER_DISABLE_VP9_HW",
243+ "codec_preferences.video_buffer_size_mb": "DUMBER_VIDEO_BUFFER_MB",
244+ "codec_preferences.queue_buffer_time_sec": "DUMBER_QUEUE_BUFFER_SEC",
245+ "codec_preferences.custom_user_agent": "DUMBER_CUSTOM_UA",
246+ "codec_preferences.av1_max_resolution": "DUMBER_AV1_MAX_RES",
247+ }
248+
249+ for key, env := range codecEnvBindings {
250+ if err := v.BindEnv(key, env); err != nil {
251+ return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
252+ }
253+ }
254+
207255 return &Manager{
208256 viper: v,
209257 callbacks: make([]func(*Config), 0),
@@ -271,6 +319,9 @@ func (m *Manager) Load() error {
271319 }
272320 }
273321
322+ // Validate and configure codec preferences
323+ config = m.validateAndConfigureCodecPreferences(config)
324+
274325 m.config = config
275326 return nil
276327 }
@@ -366,6 +417,9 @@ func (m *Manager) reload() error {
366417 }
367418 }
368419
420+ // Validate and configure codec preferences
421+ config = m.validateAndConfigureCodecPreferences(config)
422+
369423 m.config = config
370424 return nil
371425 }
@@ -428,6 +482,18 @@ func (m *Manager) setDefaults() {
428482 m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers)
429483 m.viper.SetDefault("video_acceleration.legacy_vaapi", defaults.VideoAcceleration.LegacyVAAPI)
430484
485+ // Codec preferences defaults
486+ m.viper.SetDefault("codec_preferences.preferred_codecs", defaults.CodecPreferences.PreferredCodecs)
487+ m.viper.SetDefault("codec_preferences.force_av1", defaults.CodecPreferences.ForceAV1)
488+ m.viper.SetDefault("codec_preferences.block_vp9", defaults.CodecPreferences.BlockVP9)
489+ m.viper.SetDefault("codec_preferences.block_vp8", defaults.CodecPreferences.BlockVP8)
490+ m.viper.SetDefault("codec_preferences.av1_hardware_only", defaults.CodecPreferences.AV1HardwareOnly)
491+ m.viper.SetDefault("codec_preferences.disable_vp9_hardware", defaults.CodecPreferences.DisableVP9Hardware)
492+ m.viper.SetDefault("codec_preferences.video_buffer_size_mb", defaults.CodecPreferences.VideoBufferSizeMB)
493+ m.viper.SetDefault("codec_preferences.queue_buffer_time_sec", defaults.CodecPreferences.QueueBufferTimeSec)
494+ m.viper.SetDefault("codec_preferences.custom_user_agent", defaults.CodecPreferences.CustomUserAgent)
495+ m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)
496+
431497 // Rendering defaults
432498 m.viper.SetDefault("rendering_mode", string(RenderingModeAuto))
433499 }
@@ -508,3 +574,49 @@ func OnConfigChange(callback func(*Config)) {
508574 }
509575 globalManager.OnConfigChange(callback)
510576 }
577+
578+// validateAndConfigureCodecPreferences validates and auto-configures codec preferences based on GPU capabilities
579+func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config {
580+ // Validate codec preferences
581+ if config.CodecPreferences.PreferredCodecs == "" {
582+ config.CodecPreferences.PreferredCodecs = "av1,h264,vp8"
583+ fmt.Printf("Config: Set default codec preferences: %s\n", config.CodecPreferences.PreferredCodecs)
584+ }
585+
586+ // Auto-configure based on GPU capabilities
587+ if config.VideoAcceleration.AutoDetectGPU {
588+ gpuInfo := gpu.DetectGPU()
589+
590+ // Check AV1 hardware support
591+ if config.CodecPreferences.AV1HardwareOnly && !gpuInfo.SupportsAV1Hardware() {
592+ // Disable AV1 hardware-only if GPU doesn't support it
593+ config.CodecPreferences.AV1HardwareOnly = false
594+ fmt.Printf("Config: GPU doesn't support AV1 hardware, enabling software fallback\n")
595+ }
596+
597+ // Get detailed AV1 capabilities
598+ av1Caps := gpuInfo.GetAV1HardwareCapabilities()
599+ if av1Caps["decode"] {
600+ fmt.Printf("Config: GPU supports AV1 hardware decode\n")
601+
602+ // If GPU supports AV1, prefer it over VP9
603+ if !config.CodecPreferences.ForceAV1 &&
604+ !strings.HasPrefix(config.CodecPreferences.PreferredCodecs, "av1") {
605+ config.CodecPreferences.PreferredCodecs = "av1," + config.CodecPreferences.PreferredCodecs
606+ fmt.Printf("Config: Auto-enabled AV1 preference due to hardware support\n")
607+ }
608+ } else {
609+ // If no AV1 support, ensure VP9 hardware is disabled to prevent issues
610+ if !config.CodecPreferences.DisableVP9Hardware {
611+ config.CodecPreferences.DisableVP9Hardware = true
612+ fmt.Printf("Config: Auto-disabled VP9 hardware acceleration (no AV1 fallback)\n")
613+ }
614+ }
615+
616+ // Log GPU-specific codec capabilities
617+ fmt.Printf("Config: %s GPU codec capabilities - AV1: decode=%t, encode=%t, 10bit=%t\n",
618+ gpuInfo.Vendor, av1Caps["decode"], av1Caps["encode"], av1Caps["10bit"])
619+ }
620+
621+ return config
622+}
@@ -24,13 +24,14 @@ const (
24 24
25 // Config represents the complete configuration for dumber.25 // Config represents the complete configuration for dumber.
26 type Config struct {26 type Config struct {
27- Database DatabaseConfig `mapstructure:"database" yaml:"database"`27+ Database DatabaseConfig `mapstructure:"database" yaml:"database"`
28- History HistoryConfig `mapstructure:"history" yaml:"history"`28+ History HistoryConfig `mapstructure:"history" yaml:"history"`
29- SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`29+ SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
30- Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`30+ Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
31- Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`31+ Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
32- Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`32+ Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
33- VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`33+ VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
34+ CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"`
34 // RenderingMode controls GPU/CPU rendering selection for WebKit35 // RenderingMode controls GPU/CPU rendering selection for WebKit
35 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`36 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
36 }37 }
@@ -79,13 +80,13 @@ type DmenuConfig struct {
79 80
80 // LoggingConfig holds logging configuration.81 // LoggingConfig holds logging configuration.
81 type LoggingConfig struct {82 type LoggingConfig struct {
82- Level string `mapstructure:"level" yaml:"level"`83+ Level string `mapstructure:"level" yaml:"level"`
83- Format string `mapstructure:"format" yaml:"format"`84+ Format string `mapstructure:"format" yaml:"format"`
84- Filename string `mapstructure:"filename" yaml:"filename"`85+ Filename string `mapstructure:"filename" yaml:"filename"`
85- MaxSize int `mapstructure:"max_size" yaml:"max_size"`86+ MaxSize int `mapstructure:"max_size" yaml:"max_size"`
86- MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"`87+ MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"`
87- MaxAge int `mapstructure:"max_age" yaml:"max_age"`88+ MaxAge int `mapstructure:"max_age" yaml:"max_age"`
88- Compress bool `mapstructure:"compress" yaml:"compress"`89+ Compress bool `mapstructure:"compress" yaml:"compress"`
89 90
90 // File output configuration91 // File output configuration
91 LogDir string `mapstructure:"log_dir" yaml:"log_dir"`92 LogDir string `mapstructure:"log_dir" yaml:"log_dir"`
@@ -120,6 +121,33 @@ type VideoAccelerationConfig struct {
120 LegacyVAAPI bool `mapstructure:"legacy_vaapi" yaml:"legacy_vaapi"`121 LegacyVAAPI bool `mapstructure:"legacy_vaapi" yaml:"legacy_vaapi"`
121 }122 }
122 123
124+// CodecConfig holds video codec preferences and handling
125+type CodecConfig struct {
126+ // Codec preference order (e.g., "av1,h264,vp8")
127+ PreferredCodecs string `mapstructure:"preferred_codecs" yaml:"preferred_codecs"`
128+
129+ // Force specific codec for platforms
130+ ForceAV1 bool `mapstructure:"force_av1" yaml:"force_av1"`
131+
132+ // Block problematic codecs
133+ BlockVP9 bool `mapstructure:"block_vp9" yaml:"block_vp9"`
134+ BlockVP8 bool `mapstructure:"block_vp8" yaml:"block_vp8"`
135+
136+ // Hardware acceleration per codec
137+ AV1HardwareOnly bool `mapstructure:"av1_hardware_only" yaml:"av1_hardware_only"`
138+ DisableVP9Hardware bool `mapstructure:"disable_vp9_hardware" yaml:"disable_vp9_hardware"`
139+
140+ // Buffer configuration for smooth playback
141+ VideoBufferSizeMB int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb"`
142+ QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec"`
143+
144+ // Custom User-Agent for codec negotiation
145+ CustomUserAgent string `mapstructure:"custom_user_agent" yaml:"custom_user_agent"`
146+
147+ // Maximum resolution for AV1 codec (720p, 1080p, 1440p, 4k, unlimited)
148+ AV1MaxResolution string `mapstructure:"av1_max_resolution" yaml:"av1_max_resolution"`
149+}
150+
123 // Manager handles configuration loading, watching, and reloading.151 // Manager handles configuration loading, watching, and reloading.
124 type Manager struct {152 type Manager struct {
125 config *Config153 config *Config
@@ -194,7 +222,7 @@ func NewManager() (*Manager, error) {
194 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",222 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",
195 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",223 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",
196 "video_acceleration.vaapi_driver_name": "LIBVA_DRIVER_NAME",224 "video_acceleration.vaapi_driver_name": "LIBVA_DRIVER_NAME",
197- "video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS", 225+ "video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS",
198 "video_acceleration.legacy_vaapi": "WEBKIT_GST_ENABLE_LEGACY_VAAPI",226 "video_acceleration.legacy_vaapi": "WEBKIT_GST_ENABLE_LEGACY_VAAPI",
199 }227 }
200 228
@@ -204,6 +232,26 @@ func NewManager() (*Manager, error) {
204 }232 }
205 }233 }
206 234
235+ // Codec preferences environment variable bindings
236+ codecEnvBindings := map[string]string{
237+ "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",
238+ "codec_preferences.force_av1": "DUMBER_FORCE_AV1",
239+ "codec_preferences.block_vp9": "DUMBER_BLOCK_VP9",
240+ "codec_preferences.block_vp8": "DUMBER_BLOCK_VP8",
241+ "codec_preferences.av1_hardware_only": "DUMBER_AV1_HW_ONLY",
242+ "codec_preferences.disable_vp9_hardware": "DUMBER_DISABLE_VP9_HW",
243+ "codec_preferences.video_buffer_size_mb": "DUMBER_VIDEO_BUFFER_MB",
244+ "codec_preferences.queue_buffer_time_sec": "DUMBER_QUEUE_BUFFER_SEC",
245+ "codec_preferences.custom_user_agent": "DUMBER_CUSTOM_UA",
246+ "codec_preferences.av1_max_resolution": "DUMBER_AV1_MAX_RES",
247+ }
248+
249+ for key, env := range codecEnvBindings {
250+ if err := v.BindEnv(key, env); err != nil {
251+ return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
252+ }
253+ }
254+
207 return &Manager{255 return &Manager{
208 viper: v,256 viper: v,
209 callbacks: make([]func(*Config), 0),257 callbacks: make([]func(*Config), 0),
@@ -271,6 +319,9 @@ func (m *Manager) Load() error {
271 }319 }
272 }320 }
273 321
322+ // Validate and configure codec preferences
323+ config = m.validateAndConfigureCodecPreferences(config)
324+
274 m.config = config325 m.config = config
275 return nil326 return nil
276 }327 }
@@ -366,6 +417,9 @@ func (m *Manager) reload() error {
366 }417 }
367 }418 }
368 419
420+ // Validate and configure codec preferences
421+ config = m.validateAndConfigureCodecPreferences(config)
422+
369 m.config = config423 m.config = config
370 return nil424 return nil
371 }425 }
@@ -428,6 +482,18 @@ func (m *Manager) setDefaults() {
428 m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers)482 m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers)
429 m.viper.SetDefault("video_acceleration.legacy_vaapi", defaults.VideoAcceleration.LegacyVAAPI)483 m.viper.SetDefault("video_acceleration.legacy_vaapi", defaults.VideoAcceleration.LegacyVAAPI)
430 484
485+ // Codec preferences defaults
486+ m.viper.SetDefault("codec_preferences.preferred_codecs", defaults.CodecPreferences.PreferredCodecs)
487+ m.viper.SetDefault("codec_preferences.force_av1", defaults.CodecPreferences.ForceAV1)
488+ m.viper.SetDefault("codec_preferences.block_vp9", defaults.CodecPreferences.BlockVP9)
489+ m.viper.SetDefault("codec_preferences.block_vp8", defaults.CodecPreferences.BlockVP8)
490+ m.viper.SetDefault("codec_preferences.av1_hardware_only", defaults.CodecPreferences.AV1HardwareOnly)
491+ m.viper.SetDefault("codec_preferences.disable_vp9_hardware", defaults.CodecPreferences.DisableVP9Hardware)
492+ m.viper.SetDefault("codec_preferences.video_buffer_size_mb", defaults.CodecPreferences.VideoBufferSizeMB)
493+ m.viper.SetDefault("codec_preferences.queue_buffer_time_sec", defaults.CodecPreferences.QueueBufferTimeSec)
494+ m.viper.SetDefault("codec_preferences.custom_user_agent", defaults.CodecPreferences.CustomUserAgent)
495+ m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)
496+
431 // Rendering defaults497 // Rendering defaults
432 m.viper.SetDefault("rendering_mode", string(RenderingModeAuto))498 m.viper.SetDefault("rendering_mode", string(RenderingModeAuto))
433 }499 }
@@ -508,3 +574,49 @@ func OnConfigChange(callback func(*Config)) {
508 }574 }
509 globalManager.OnConfigChange(callback)575 globalManager.OnConfigChange(callback)
510 }576 }
577+
578+// validateAndConfigureCodecPreferences validates and auto-configures codec preferences based on GPU capabilities
579+func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config {
580+ // Validate codec preferences
581+ if config.CodecPreferences.PreferredCodecs == "" {
582+ config.CodecPreferences.PreferredCodecs = "av1,h264,vp8"
583+ fmt.Printf("Config: Set default codec preferences: %s\n", config.CodecPreferences.PreferredCodecs)
584+ }
585+
586+ // Auto-configure based on GPU capabilities
587+ if config.VideoAcceleration.AutoDetectGPU {
588+ gpuInfo := gpu.DetectGPU()
589+
590+ // Check AV1 hardware support
591+ if config.CodecPreferences.AV1HardwareOnly && !gpuInfo.SupportsAV1Hardware() {
592+ // Disable AV1 hardware-only if GPU doesn't support it
593+ config.CodecPreferences.AV1HardwareOnly = false
594+ fmt.Printf("Config: GPU doesn't support AV1 hardware, enabling software fallback\n")
595+ }
596+
597+ // Get detailed AV1 capabilities
598+ av1Caps := gpuInfo.GetAV1HardwareCapabilities()
599+ if av1Caps["decode"] {
600+ fmt.Printf("Config: GPU supports AV1 hardware decode\n")
601+
602+ // If GPU supports AV1, prefer it over VP9
603+ if !config.CodecPreferences.ForceAV1 &&
604+ !strings.HasPrefix(config.CodecPreferences.PreferredCodecs, "av1") {
605+ config.CodecPreferences.PreferredCodecs = "av1," + config.CodecPreferences.PreferredCodecs
606+ fmt.Printf("Config: Auto-enabled AV1 preference due to hardware support\n")
607+ }
608+ } else {
609+ // If no AV1 support, ensure VP9 hardware is disabled to prevent issues
610+ if !config.CodecPreferences.DisableVP9Hardware {
611+ config.CodecPreferences.DisableVP9Hardware = true
612+ fmt.Printf("Config: Auto-disabled VP9 hardware acceleration (no AV1 fallback)\n")
613+ }
614+ }
615+
616+ // Log GPU-specific codec capabilities
617+ fmt.Printf("Config: %s GPU codec capabilities - AV1: decode=%t, encode=%t, 10bit=%t\n",
618+ gpuInfo.Vendor, av1Caps["decode"], av1Caps["encode"], av1Caps["10bit"])
619+ }
620+
621+ return config
622+}
M internal/config/defaults.go +28 -16
@@ -8,10 +8,10 @@ import (
88 // Default configuration constants
99 const (
1010 // Database defaults
11- defaultMaxIdleTimeMin = 5 // minutes
12- defaultQueryTimeoutSec = 30 // seconds
11+ defaultMaxIdleTimeMin = 5 // minutes
12+ defaultQueryTimeoutSec = 30 // seconds
1313
14- // History defaults
14+ // History defaults
1515 defaultMaxHistoryEntries = 10000 // entries
1616 defaultRetentionDays = 365 // 1 year
1717
@@ -78,20 +78,20 @@ func DefaultConfig() *Config {
7878 SortByVisitCount: true,
7979 },
8080 Logging: LoggingConfig{
81- Level: "info",
82- Format: "text", // text or json
83- Filename: "", // empty means stdout
84- MaxSize: defaultMaxLogSizeMB, // MB
85- MaxBackups: defaultMaxBackups,
86- MaxAge: defaultMaxLogAgeDays, // days
87- Compress: true,
88- LogDir: getDefaultLogDir(),
89- EnableFileLog: true,
90- CaptureStdout: false,
91- CaptureStderr: false,
81+ Level: "info",
82+ Format: "text", // text or json
83+ Filename: "", // empty means stdout
84+ MaxSize: defaultMaxLogSizeMB, // MB
85+ MaxBackups: defaultMaxBackups,
86+ MaxAge: defaultMaxLogAgeDays, // days
87+ Compress: true,
88+ LogDir: getDefaultLogDir(),
89+ EnableFileLog: true,
90+ CaptureStdout: false,
91+ CaptureStderr: false,
9292 CaptureCOutput: false, // Disabled by default to avoid performance impact
93- DebugFile: "debug.log",
94- VerboseWebKit: false,
93+ DebugFile: "debug.log",
94+ VerboseWebKit: false,
9595 },
9696 Appearance: AppearanceConfig{
9797 SansFont: "Fira Sans",
@@ -106,6 +106,18 @@ func DefaultConfig() *Config {
106106 EnableAllDrivers: true,
107107 LegacyVAAPI: false,
108108 },
109+ CodecPreferences: CodecConfig{
110+ PreferredCodecs: "av1,h264", // AV1 first, but allow VP9 fallback for higher resolutions
111+ ForceAV1: false, // Use smart AV1 negotiation instead of forcing
112+ BlockVP9: false, // Allow VP9 for higher resolutions
113+ BlockVP8: true, // Still block VP8 as it's outdated
114+ AV1HardwareOnly: false, // Allow software AV1 fallback
115+ DisableVP9Hardware: false, // Allow VP9 hardware for high res content
116+ VideoBufferSizeMB: 16, // Larger buffer for AV1 streams
117+ QueueBufferTimeSec: 10, // More buffering time for smooth playback
118+ CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", // Chrome UA with AV1 support
119+ AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res
120+ },
109121 RenderingMode: RenderingModeAuto,
110122 }
111123 }
@@ -8,10 +8,10 @@ import (
8 // Default configuration constants8 // Default configuration constants
9 const (9 const (
10 // Database defaults10 // Database defaults
11- defaultMaxIdleTimeMin = 5 // minutes11+ defaultMaxIdleTimeMin = 5 // minutes
12- defaultQueryTimeoutSec = 30 // seconds12+ defaultQueryTimeoutSec = 30 // seconds
13 13
14- // History defaults 14+ // History defaults
15 defaultMaxHistoryEntries = 10000 // entries15 defaultMaxHistoryEntries = 10000 // entries
16 defaultRetentionDays = 365 // 1 year16 defaultRetentionDays = 365 // 1 year
17 17
@@ -78,20 +78,20 @@ func DefaultConfig() *Config {
78 SortByVisitCount: true,78 SortByVisitCount: true,
79 },79 },
80 Logging: LoggingConfig{80 Logging: LoggingConfig{
81- Level: "info",81+ Level: "info",
82- Format: "text", // text or json82+ Format: "text", // text or json
83- Filename: "", // empty means stdout83+ Filename: "", // empty means stdout
84- MaxSize: defaultMaxLogSizeMB, // MB84+ MaxSize: defaultMaxLogSizeMB, // MB
85- MaxBackups: defaultMaxBackups,85+ MaxBackups: defaultMaxBackups,
86- MaxAge: defaultMaxLogAgeDays, // days86+ MaxAge: defaultMaxLogAgeDays, // days
87- Compress: true,87+ Compress: true,
88- LogDir: getDefaultLogDir(),88+ LogDir: getDefaultLogDir(),
89- EnableFileLog: true,89+ EnableFileLog: true,
90- CaptureStdout: false,90+ CaptureStdout: false,
91- CaptureStderr: false,91+ CaptureStderr: false,
92 CaptureCOutput: false, // Disabled by default to avoid performance impact92 CaptureCOutput: false, // Disabled by default to avoid performance impact
93- DebugFile: "debug.log",93+ DebugFile: "debug.log",
94- VerboseWebKit: false,94+ VerboseWebKit: false,
95 },95 },
96 Appearance: AppearanceConfig{96 Appearance: AppearanceConfig{
97 SansFont: "Fira Sans",97 SansFont: "Fira Sans",
@@ -106,6 +106,18 @@ func DefaultConfig() *Config {
106 EnableAllDrivers: true,106 EnableAllDrivers: true,
107 LegacyVAAPI: false,107 LegacyVAAPI: false,
108 },108 },
109+ CodecPreferences: CodecConfig{
110+ PreferredCodecs: "av1,h264", // AV1 first, but allow VP9 fallback for higher resolutions
111+ ForceAV1: false, // Use smart AV1 negotiation instead of forcing
112+ BlockVP9: false, // Allow VP9 for higher resolutions
113+ BlockVP8: true, // Still block VP8 as it's outdated
114+ AV1HardwareOnly: false, // Allow software AV1 fallback
115+ DisableVP9Hardware: false, // Allow VP9 hardware for high res content
116+ VideoBufferSizeMB: 16, // Larger buffer for AV1 streams
117+ QueueBufferTimeSec: 10, // More buffering time for smooth playback
118+ CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", // Chrome UA with AV1 support
119+ AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res
120+ },
109 RenderingMode: RenderingModeAuto,121 RenderingMode: RenderingModeAuto,
110 }122 }
111 }123 }
M internal/config/xdg.go +0 -1
@@ -91,7 +91,6 @@ func GetLogDir() (string, error) {
9191 return filepath.Join(stateDir, "logs"), nil
9292 }
9393
94-
9594 // GetConfigFile returns the path to the main configuration file.
9695 func GetConfigFile() (string, error) {
9796 configDir, err := GetConfigDir()
@@ -91,7 +91,6 @@ func GetLogDir() (string, error) {
91 return filepath.Join(stateDir, "logs"), nil91 return filepath.Join(stateDir, "logs"), nil
92 }92 }
93 93
94-
95 // GetConfigFile returns the path to the main configuration file.94 // GetConfigFile returns the path to the main configuration file.
96 func GetConfigFile() (string, error) {95 func GetConfigFile() (string, error) {
97 configDir, err := GetConfigDir()96 configDir, err := GetConfigDir()
M internal/logging/capture.go +1 -1
@@ -128,4 +128,4 @@ func (c *OutputCapture) pipeToLogger(r io.Reader, prefix string) {
128128 }
129129 }
130130 }
131-}⏎̸
131+}
@@ -128,4 +128,4 @@ func (c *OutputCapture) pipeToLogger(r io.Reader, prefix string) {
128 }128 }
129 }129 }
130 }130 }
131-}⏎̸131+}
M internal/logging/logger.go +3 -3
@@ -103,7 +103,7 @@ func Init(logDir string, level string, format string, enableFileLog bool, maxSiz
103103 }
104104
105105 outputs := []io.Writer{}
106-
106+
107107 // Always include stdout
108108 outputs = append(outputs, os.Stdout)
109109
@@ -142,7 +142,7 @@ func (l *Logger) Write(p []byte) (n int, err error) {
142142 if message == "" {
143143 return len(p), nil
144144 }
145-
145+
146146 l.writeLog(INFO, message, "")
147147 return len(p), nil
148148 }
@@ -225,4 +225,4 @@ func CaptureWebKitLog(message string) {
225225 // LogLevelInfo returns the INFO log level constant for external packages
226226 func LogLevelInfo() LogLevel {
227227 return INFO
228-}⏎̸
228+}
@@ -103,7 +103,7 @@ func Init(logDir string, level string, format string, enableFileLog bool, maxSiz
103 }103 }
104 104
105 outputs := []io.Writer{}105 outputs := []io.Writer{}
106- 106+
107 // Always include stdout107 // Always include stdout
108 outputs = append(outputs, os.Stdout)108 outputs = append(outputs, os.Stdout)
109 109
@@ -142,7 +142,7 @@ func (l *Logger) Write(p []byte) (n int, err error) {
142 if message == "" {142 if message == "" {
143 return len(p), nil143 return len(p), nil
144 }144 }
145- 145+
146 l.writeLog(INFO, message, "")146 l.writeLog(INFO, message, "")
147 return len(p), nil147 return len(p), nil
148 }148 }
@@ -225,4 +225,4 @@ func CaptureWebKitLog(message string) {
225 // LogLevelInfo returns the INFO log level constant for external packages225 // LogLevelInfo returns the INFO log level constant for external packages
226 func LogLevelInfo() LogLevel {226 func LogLevelInfo() LogLevel {
227 return INFO227 return INFO
228-}⏎̸228+}
M internal/logging/rotator.go +4 -4
@@ -43,7 +43,7 @@ func NewLogRotator(baseDir string, maxSizeMB, maxBackups, maxAgeDays int, compre
4343
4444 func (r *LogRotator) openCurrentFile() error {
4545 logPath := filepath.Join(r.baseDir, r.baseName)
46-
46+
4747 // Get current file size if it exists
4848 if info, err := os.Stat(logPath); err == nil {
4949 r.currentSize = info.Size()
@@ -92,7 +92,7 @@ func (r *LogRotator) rotate() error {
9292 // Generate backup filename with timestamp
9393 timestamp := time.Now().Format("2006-01-02-15-04-05")
9494 backupName := fmt.Sprintf("%s.%s", r.baseName, timestamp)
95-
95+
9696 currentPath := filepath.Join(r.baseDir, r.baseName)
9797 backupPath := filepath.Join(r.baseDir, backupName)
9898
@@ -191,9 +191,9 @@ func (r *LogRotator) cleanup() {
191191 func (r *LogRotator) Close() error {
192192 r.mu.Lock()
193193 defer r.mu.Unlock()
194-
194+
195195 if r.currentFile != nil {
196196 return r.currentFile.Close()
197197 }
198198 return nil
199-}⏎̸
199+}
@@ -43,7 +43,7 @@ func NewLogRotator(baseDir string, maxSizeMB, maxBackups, maxAgeDays int, compre
43 43
44 func (r *LogRotator) openCurrentFile() error {44 func (r *LogRotator) openCurrentFile() error {
45 logPath := filepath.Join(r.baseDir, r.baseName)45 logPath := filepath.Join(r.baseDir, r.baseName)
46- 46+
47 // Get current file size if it exists47 // Get current file size if it exists
48 if info, err := os.Stat(logPath); err == nil {48 if info, err := os.Stat(logPath); err == nil {
49 r.currentSize = info.Size()49 r.currentSize = info.Size()
@@ -92,7 +92,7 @@ func (r *LogRotator) rotate() error {
92 // Generate backup filename with timestamp92 // Generate backup filename with timestamp
93 timestamp := time.Now().Format("2006-01-02-15-04-05")93 timestamp := time.Now().Format("2006-01-02-15-04-05")
94 backupName := fmt.Sprintf("%s.%s", r.baseName, timestamp)94 backupName := fmt.Sprintf("%s.%s", r.baseName, timestamp)
95- 95+
96 currentPath := filepath.Join(r.baseDir, r.baseName)96 currentPath := filepath.Join(r.baseDir, r.baseName)
97 backupPath := filepath.Join(r.baseDir, backupName)97 backupPath := filepath.Join(r.baseDir, backupName)
98 98
@@ -191,9 +191,9 @@ func (r *LogRotator) cleanup() {
191 func (r *LogRotator) Close() error {191 func (r *LogRotator) Close() error {
192 r.mu.Lock()192 r.mu.Lock()
193 defer r.mu.Unlock()193 defer r.mu.Unlock()
194- 194+
195 if r.currentFile != nil {195 if r.currentFile != nil {
196 return r.currentFile.Close()196 return r.currentFile.Close()
197 }197 }
198 return nil198 return nil
199-}⏎̸199+}
M internal/migrations/migrations.go +1 -1
@@ -187,4 +187,4 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
187187 }
188188
189189 return versions, nil
190-}⏎̸
190+}
@@ -187,4 +187,4 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
187 }187 }
188 188
189 return versions, nil189 return versions, nil
190-}⏎̸190+}
M internal/parser/parser.go +1 -1
@@ -127,7 +127,7 @@ func (p *Parser) parseInputUsingCLILogic(input string) (string, error) {
127127 if commonTLDs.MatchString(input) {
128128 return "https://" + input, nil
129129 }
130-
130+
131131 // If it has a dot but uncommon TLD, still try as URL
132132 return "https://" + input, nil
133133 }
@@ -127,7 +127,7 @@ func (p *Parser) parseInputUsingCLILogic(input string) (string, error) {
127 if commonTLDs.MatchString(input) {127 if commonTLDs.MatchString(input) {
128 return "https://" + input, nil128 return "https://" + input, nil
129 }129 }
130- 130+
131 // If it has a dot but uncommon TLD, still try as URL131 // If it has a dot but uncommon TLD, still try as URL
132 return "https://" + input, nil132 return "https://" + input, nil
133 }133 }
M main.go +156 -17
@@ -42,11 +42,11 @@ const (
4242
4343 // Parsing and utility constants
4444 const (
45- colonSplitParts = 2 // Number of parts when splitting on colon
46- dirPerm = 0o755 // Directory permissions
45+ colonSplitParts = 2 // Number of parts when splitting on colon
46+ dirPerm = 0o755 // Directory permissions
4747 // JavaScript hash constants (auto-generated, should not be changed)
48- hashGetRecentHistory = 3708519028 // BrowserService.GetRecentHistory(limit)
49- hashGetSearchShortcuts = 4078533762 // BrowserService.GetSearchShortcuts()
48+ hashGetRecentHistory = 3708519028 // BrowserService.GetRecentHistory(limit)
49+ hashGetSearchShortcuts = 4078533762 // BrowserService.GetSearchShortcuts()
5050 )
5151
5252 func main() {
@@ -165,20 +165,20 @@ func runBrowser() {
165165 // Demote VAAPI-related elements so they won't be auto-picked
166166 if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {
167167 if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0"); err != nil {
168- log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
169- }
168+ log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
169+ }
170170 }
171171 // Provide a safe audio sink to avoid autoaudiosink failures
172172 if os.Getenv("GST_AUDIO_SINK") == "" {
173173 if err := os.Setenv("GST_AUDIO_SINK", "fakesink"); err != nil {
174- log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err)
175- }
174+ log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err)
175+ }
176176 }
177177 // Optionally disable media stream features that can engage complex pipelines
178178 if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
179179 if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil {
180- log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err)
181- }
180+ log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err)
181+ }
182182 }
183183 log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)")
184184 }
@@ -191,7 +191,10 @@ func runBrowser() {
191191 os.Exit(1)
192192 }
193193 cfg := config.Get()
194-
194+
195+ // Apply codec preferences configuration
196+ applyCodecConfiguration(cfg.CodecPreferences)
197+
195198 // Initialize logging system early
196199 if err := logging.Init(
197200 cfg.Logging.LogDir,
@@ -260,10 +263,10 @@ func runBrowser() {
260263 // Track current URL for zoom persistence
261264 // Initialize to the initial homepage so shortcuts work immediately
262265 var currentURL string = "dumb://homepage"
263-
266+
264267 // Track last zoom domain to avoid showing toaster on every navigation within same domain
265268 var lastZoomDomain string
266-
269+
267270 // Flag to prevent showing toast when programmatically setting zoom (not user-initiated)
268271 var programmaticZoomChange bool
269272
@@ -451,6 +454,12 @@ func runBrowser() {
451454 LegacyVAAPI: cfg.VideoAcceleration.LegacyVAAPI,
452455 },
453456 Memory: readMemoryConfigFromEnv(),
457+ CodecPreferences: webkit.CodecPreferencesConfig{
458+ PreferredCodecs: strings.Split(cfg.CodecPreferences.PreferredCodecs, ","),
459+ BlockedCodecs: buildBlockedCodecsList(cfg.CodecPreferences),
460+ ForceAV1: cfg.CodecPreferences.ForceAV1,
461+ CustomUserAgent: cfg.CodecPreferences.CustomUserAgent,
462+ },
454463 })
455464 if err != nil {
456465 log.Printf("Warning: failed to create WebView: %v", err)
@@ -478,13 +487,13 @@ func runBrowser() {
478487 }
479488 ctx := context.Background()
480489 currentDomain := services.ZoomKeyForLog(u)
481-
490+
482491 if z, err := browserService.GetZoomLevel(ctx, u); err == nil {
483492 programmaticZoomChange = true
484493 _ = view.SetZoom(z)
485494 programmaticZoomChange = false
486495 log.Printf("[zoom] loaded %.2f for %s", z, currentDomain)
487-
496+
488497 // Only show toast when entering a new domain (not on first load)
489498 if currentDomain != lastZoomDomain && lastZoomDomain != "" {
490499 js := fmt.Sprintf(`try {
@@ -514,7 +523,7 @@ func runBrowser() {
514523 }
515524 key := services.ZoomKeyForLog(url)
516525 log.Printf("[zoom] saved %.2f for %s", level, key)
517-
526+
518527 // Only show toast for user-initiated zoom changes, not programmatic ones
519528 if !programmaticZoomChange {
520529 js := fmt.Sprintf(`try {
@@ -723,7 +732,7 @@ func runBrowser() {
723732 _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)
724733 return
725734 }
726-
735+
727736 if err := clipboard.CopyToClipboard(currentURL); err != nil {
728737 log.Printf("Failed to copy URL to clipboard: %v", err)
729738 _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)
@@ -926,6 +935,136 @@ func detectAndSetKeyboardLocale() {
926935 log.Printf("[locale] keyboard locale detected: %s", locale)
927936 }
928937
938+// applyCodecConfiguration applies codec preferences to environment variables and GStreamer settings
939+func applyCodecConfiguration(codecPrefs config.CodecConfig) {
940+ log.Printf("[codec] Applying codec preferences: preferred=%s, force_av1=%t, block_vp9=%t",
941+ codecPrefs.PreferredCodecs, codecPrefs.ForceAV1, codecPrefs.BlockVP9)
942+
943+ // Build comprehensive GStreamer plugin rankings
944+ var rankSettings []string
945+
946+ // Get existing GST_PLUGIN_FEATURE_RANK or start fresh
947+ existingRank := os.Getenv("GST_PLUGIN_FEATURE_RANK")
948+ if existingRank != "" {
949+ rankSettings = strings.Split(existingRank, ",")
950+ }
951+
952+ // AV1 decoder promotions (high priority)
953+ if codecPrefs.ForceAV1 || strings.Contains(codecPrefs.PreferredCodecs, "av1") {
954+ av1Ranks := []string{
955+ "vaav1dec:768", // VA-API AV1 decoder (highest priority)
956+ "nvav1dec:512", // NVDEC AV1 decoder
957+ "av1dec:384", // Software AV1 decoder (fallback)
958+ "avdec_av1:256", // libav AV1 decoder
959+ }
960+ rankSettings = append(rankSettings, av1Ranks...)
961+ log.Printf("[codec] Promoted AV1 decoders for maximum priority")
962+ }
963+
964+ // H.264 decoder management
965+ if strings.Contains(codecPrefs.PreferredCodecs, "h264") {
966+ h264Ranks := []string{
967+ "vah264dec:512", // VA-API H.264 decoder
968+ "nvh264dec:384", // NVDEC H.264 decoder
969+ "avdec_h264:256", // Software H.264 decoder
970+ }
971+ rankSettings = append(rankSettings, h264Ranks...)
972+ }
973+
974+ // VP9 decoder demotion/blocking
975+ if codecPrefs.DisableVP9Hardware || codecPrefs.BlockVP9 {
976+ vp9Demotions := []string{
977+ "vaapivp9dec:0", // Block VA-API VP9
978+ "nvvp9dec:0", // Block NVDEC VP9
979+ }
980+ if codecPrefs.BlockVP9 {
981+ vp9Demotions = append(vp9Demotions, "vp9dec:0", "avdec_vp9:0") // Block all VP9
982+ } else {
983+ // Allow software VP9 with low priority
984+ vp9Demotions = append(vp9Demotions, "vp9dec:64", "avdec_vp9:64")
985+ }
986+ rankSettings = append(rankSettings, vp9Demotions...)
987+ log.Printf("[codec] Demoted/blocked VP9 decoders")
988+ }
989+
990+ // VP8 decoder blocking
991+ if codecPrefs.BlockVP8 {
992+ vp8Demotions := []string{
993+ "vaapivp8dec:0",
994+ "vp8dec:0",
995+ "avdec_vp8:0",
996+ }
997+ rankSettings = append(rankSettings, vp8Demotions...)
998+ log.Printf("[codec] Blocked VP8 decoders")
999+ }
1000+
1001+ // Apply the complete ranking system
1002+ if len(rankSettings) > 0 {
1003+ finalRank := strings.Join(rankSettings, ",")
1004+ if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", finalRank); err != nil {
1005+ log.Printf("[codec] Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
1006+ } else {
1007+ log.Printf("[codec] Set comprehensive GST_PLUGIN_FEATURE_RANK: %s", finalRank)
1008+ }
1009+ }
1010+
1011+ // Set video buffer sizes if specified
1012+ if codecPrefs.VideoBufferSizeMB > 0 {
1013+ bufferSize := strconv.Itoa(codecPrefs.VideoBufferSizeMB * 1024 * 1024)
1014+
1015+ if err := os.Setenv("GST_BUFFER_SIZE", bufferSize); err != nil {
1016+ log.Printf("[codec] Warning: failed to set GST_BUFFER_SIZE: %v", err)
1017+ }
1018+
1019+ if err := os.Setenv("GST_QUEUE2_MAX_SIZE_BYTES", bufferSize); err != nil {
1020+ log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_BYTES: %v", err)
1021+ }
1022+
1023+ log.Printf("[codec] Set video buffer size to %dMB", codecPrefs.VideoBufferSizeMB)
1024+ }
1025+
1026+ // Set queue buffer time if specified
1027+ if codecPrefs.QueueBufferTimeSec > 0 {
1028+ // Convert seconds to nanoseconds for GStreamer
1029+ bufferTime := strconv.Itoa(codecPrefs.QueueBufferTimeSec * 1000000000)
1030+
1031+ if err := os.Setenv("GST_QUEUE2_MAX_SIZE_TIME", bufferTime); err != nil {
1032+ log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_TIME: %v", err)
1033+ } else {
1034+ log.Printf("[codec] Set queue buffer time to %ds", codecPrefs.QueueBufferTimeSec)
1035+ }
1036+ }
1037+
1038+ // Set additional codec-specific environment variables
1039+ if codecPrefs.ForceAV1 {
1040+ // Enable AV1 hardware decoding if available
1041+ if err := os.Setenv("GST_AV1_DECODER_ENABLE_HW", "1"); err != nil {
1042+ log.Printf("[codec] Warning: failed to set GST_AV1_DECODER_ENABLE_HW: %v", err)
1043+ }
1044+ }
1045+
1046+ // Disable video post-processing to avoid VA-API issues
1047+ if err := os.Setenv("GST_VAAPI_DISABLE_VPP", "1"); err != nil {
1048+ log.Printf("[codec] Warning: failed to set GST_VAAPI_DISABLE_VPP: %v", err)
1049+ }
1050+
1051+ log.Printf("[codec] Codec configuration applied successfully")
1052+}
1053+
1054+// buildBlockedCodecsList converts config codec blocking preferences to a string slice
1055+func buildBlockedCodecsList(codecPrefs config.CodecConfig) []string {
1056+ var blocked []string
1057+
1058+ if codecPrefs.BlockVP9 {
1059+ blocked = append(blocked, "vp9")
1060+ }
1061+ if codecPrefs.BlockVP8 {
1062+ blocked = append(blocked, "vp8")
1063+ }
1064+
1065+ return blocked
1066+}
1067+
9291068 // getHomepageFaviconSVG returns the SVG used as a favicon for the homepage
9301069 // when WebKit requests dumb://homepage/favicon.ico.
9311070 // getHomepageFaviconSVG was inlined previously; now favicon.svg is emitted at build time.
@@ -42,11 +42,11 @@ const (
42 42
43 // Parsing and utility constants43 // Parsing and utility constants
44 const (44 const (
45- colonSplitParts = 2 // Number of parts when splitting on colon45+ colonSplitParts = 2 // Number of parts when splitting on colon
46- dirPerm = 0o755 // Directory permissions46+ dirPerm = 0o755 // Directory permissions
47 // JavaScript hash constants (auto-generated, should not be changed)47 // JavaScript hash constants (auto-generated, should not be changed)
48- hashGetRecentHistory = 3708519028 // BrowserService.GetRecentHistory(limit)48+ hashGetRecentHistory = 3708519028 // BrowserService.GetRecentHistory(limit)
49- hashGetSearchShortcuts = 4078533762 // BrowserService.GetSearchShortcuts()49+ hashGetSearchShortcuts = 4078533762 // BrowserService.GetSearchShortcuts()
50 )50 )
51 51
52 func main() {52 func main() {
@@ -165,20 +165,20 @@ func runBrowser() {
165 // Demote VAAPI-related elements so they won't be auto-picked165 // Demote VAAPI-related elements so they won't be auto-picked
166 if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {166 if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {
167 if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0"); err != nil {167 if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0"); err != nil {
168- log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)168+ log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
169- }169+ }
170 }170 }
171 // Provide a safe audio sink to avoid autoaudiosink failures171 // Provide a safe audio sink to avoid autoaudiosink failures
172 if os.Getenv("GST_AUDIO_SINK") == "" {172 if os.Getenv("GST_AUDIO_SINK") == "" {
173 if err := os.Setenv("GST_AUDIO_SINK", "fakesink"); err != nil {173 if err := os.Setenv("GST_AUDIO_SINK", "fakesink"); err != nil {
174- log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err)174+ log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err)
175- }175+ }
176 }176 }
177 // Optionally disable media stream features that can engage complex pipelines177 // Optionally disable media stream features that can engage complex pipelines
178 if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {178 if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
179 if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil {179 if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil {
180- log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err)180+ log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err)
181- }181+ }
182 }182 }
183 log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)")183 log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)")
184 }184 }
@@ -191,7 +191,10 @@ func runBrowser() {
191 os.Exit(1)191 os.Exit(1)
192 }192 }
193 cfg := config.Get()193 cfg := config.Get()
194- 194+
195+ // Apply codec preferences configuration
196+ applyCodecConfiguration(cfg.CodecPreferences)
197+
195 // Initialize logging system early198 // Initialize logging system early
196 if err := logging.Init(199 if err := logging.Init(
197 cfg.Logging.LogDir,200 cfg.Logging.LogDir,
@@ -260,10 +263,10 @@ func runBrowser() {
260 // Track current URL for zoom persistence263 // Track current URL for zoom persistence
261 // Initialize to the initial homepage so shortcuts work immediately264 // Initialize to the initial homepage so shortcuts work immediately
262 var currentURL string = "dumb://homepage"265 var currentURL string = "dumb://homepage"
263- 266+
264 // Track last zoom domain to avoid showing toaster on every navigation within same domain267 // Track last zoom domain to avoid showing toaster on every navigation within same domain
265 var lastZoomDomain string268 var lastZoomDomain string
266- 269+
267 // Flag to prevent showing toast when programmatically setting zoom (not user-initiated)270 // Flag to prevent showing toast when programmatically setting zoom (not user-initiated)
268 var programmaticZoomChange bool271 var programmaticZoomChange bool
269 272
@@ -451,6 +454,12 @@ func runBrowser() {
451 LegacyVAAPI: cfg.VideoAcceleration.LegacyVAAPI,454 LegacyVAAPI: cfg.VideoAcceleration.LegacyVAAPI,
452 },455 },
453 Memory: readMemoryConfigFromEnv(),456 Memory: readMemoryConfigFromEnv(),
457+ CodecPreferences: webkit.CodecPreferencesConfig{
458+ PreferredCodecs: strings.Split(cfg.CodecPreferences.PreferredCodecs, ","),
459+ BlockedCodecs: buildBlockedCodecsList(cfg.CodecPreferences),
460+ ForceAV1: cfg.CodecPreferences.ForceAV1,
461+ CustomUserAgent: cfg.CodecPreferences.CustomUserAgent,
462+ },
454 })463 })
455 if err != nil {464 if err != nil {
456 log.Printf("Warning: failed to create WebView: %v", err)465 log.Printf("Warning: failed to create WebView: %v", err)
@@ -478,13 +487,13 @@ func runBrowser() {
478 }487 }
479 ctx := context.Background()488 ctx := context.Background()
480 currentDomain := services.ZoomKeyForLog(u)489 currentDomain := services.ZoomKeyForLog(u)
481- 490+
482 if z, err := browserService.GetZoomLevel(ctx, u); err == nil {491 if z, err := browserService.GetZoomLevel(ctx, u); err == nil {
483 programmaticZoomChange = true492 programmaticZoomChange = true
484 _ = view.SetZoom(z)493 _ = view.SetZoom(z)
485 programmaticZoomChange = false494 programmaticZoomChange = false
486 log.Printf("[zoom] loaded %.2f for %s", z, currentDomain)495 log.Printf("[zoom] loaded %.2f for %s", z, currentDomain)
487- 496+
488 // Only show toast when entering a new domain (not on first load)497 // Only show toast when entering a new domain (not on first load)
489 if currentDomain != lastZoomDomain && lastZoomDomain != "" {498 if currentDomain != lastZoomDomain && lastZoomDomain != "" {
490 js := fmt.Sprintf(`try { 499 js := fmt.Sprintf(`try {
@@ -514,7 +523,7 @@ func runBrowser() {
514 }523 }
515 key := services.ZoomKeyForLog(url)524 key := services.ZoomKeyForLog(url)
516 log.Printf("[zoom] saved %.2f for %s", level, key)525 log.Printf("[zoom] saved %.2f for %s", level, key)
517- 526+
518 // Only show toast for user-initiated zoom changes, not programmatic ones527 // Only show toast for user-initiated zoom changes, not programmatic ones
519 if !programmaticZoomChange {528 if !programmaticZoomChange {
520 js := fmt.Sprintf(`try { 529 js := fmt.Sprintf(`try {
@@ -723,7 +732,7 @@ func runBrowser() {
723 _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)732 _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)
724 return733 return
725 }734 }
726- 735+
727 if err := clipboard.CopyToClipboard(currentURL); err != nil {736 if err := clipboard.CopyToClipboard(currentURL); err != nil {
728 log.Printf("Failed to copy URL to clipboard: %v", err)737 log.Printf("Failed to copy URL to clipboard: %v", err)
729 _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)738 _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)
@@ -926,6 +935,136 @@ func detectAndSetKeyboardLocale() {
926 log.Printf("[locale] keyboard locale detected: %s", locale)935 log.Printf("[locale] keyboard locale detected: %s", locale)
927 }936 }
928 937
938+// applyCodecConfiguration applies codec preferences to environment variables and GStreamer settings
939+func applyCodecConfiguration(codecPrefs config.CodecConfig) {
940+ log.Printf("[codec] Applying codec preferences: preferred=%s, force_av1=%t, block_vp9=%t",
941+ codecPrefs.PreferredCodecs, codecPrefs.ForceAV1, codecPrefs.BlockVP9)
942+
943+ // Build comprehensive GStreamer plugin rankings
944+ var rankSettings []string
945+
946+ // Get existing GST_PLUGIN_FEATURE_RANK or start fresh
947+ existingRank := os.Getenv("GST_PLUGIN_FEATURE_RANK")
948+ if existingRank != "" {
949+ rankSettings = strings.Split(existingRank, ",")
950+ }
951+
952+ // AV1 decoder promotions (high priority)
953+ if codecPrefs.ForceAV1 || strings.Contains(codecPrefs.PreferredCodecs, "av1") {
954+ av1Ranks := []string{
955+ "vaav1dec:768", // VA-API AV1 decoder (highest priority)
956+ "nvav1dec:512", // NVDEC AV1 decoder
957+ "av1dec:384", // Software AV1 decoder (fallback)
958+ "avdec_av1:256", // libav AV1 decoder
959+ }
960+ rankSettings = append(rankSettings, av1Ranks...)
961+ log.Printf("[codec] Promoted AV1 decoders for maximum priority")
962+ }
963+
964+ // H.264 decoder management
965+ if strings.Contains(codecPrefs.PreferredCodecs, "h264") {
966+ h264Ranks := []string{
967+ "vah264dec:512", // VA-API H.264 decoder
968+ "nvh264dec:384", // NVDEC H.264 decoder
969+ "avdec_h264:256", // Software H.264 decoder
970+ }
971+ rankSettings = append(rankSettings, h264Ranks...)
972+ }
973+
974+ // VP9 decoder demotion/blocking
975+ if codecPrefs.DisableVP9Hardware || codecPrefs.BlockVP9 {
976+ vp9Demotions := []string{
977+ "vaapivp9dec:0", // Block VA-API VP9
978+ "nvvp9dec:0", // Block NVDEC VP9
979+ }
980+ if codecPrefs.BlockVP9 {
981+ vp9Demotions = append(vp9Demotions, "vp9dec:0", "avdec_vp9:0") // Block all VP9
982+ } else {
983+ // Allow software VP9 with low priority
984+ vp9Demotions = append(vp9Demotions, "vp9dec:64", "avdec_vp9:64")
985+ }
986+ rankSettings = append(rankSettings, vp9Demotions...)
987+ log.Printf("[codec] Demoted/blocked VP9 decoders")
988+ }
989+
990+ // VP8 decoder blocking
991+ if codecPrefs.BlockVP8 {
992+ vp8Demotions := []string{
993+ "vaapivp8dec:0",
994+ "vp8dec:0",
995+ "avdec_vp8:0",
996+ }
997+ rankSettings = append(rankSettings, vp8Demotions...)
998+ log.Printf("[codec] Blocked VP8 decoders")
999+ }
1000+
1001+ // Apply the complete ranking system
1002+ if len(rankSettings) > 0 {
1003+ finalRank := strings.Join(rankSettings, ",")
1004+ if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", finalRank); err != nil {
1005+ log.Printf("[codec] Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
1006+ } else {
1007+ log.Printf("[codec] Set comprehensive GST_PLUGIN_FEATURE_RANK: %s", finalRank)
1008+ }
1009+ }
1010+
1011+ // Set video buffer sizes if specified
1012+ if codecPrefs.VideoBufferSizeMB > 0 {
1013+ bufferSize := strconv.Itoa(codecPrefs.VideoBufferSizeMB * 1024 * 1024)
1014+
1015+ if err := os.Setenv("GST_BUFFER_SIZE", bufferSize); err != nil {
1016+ log.Printf("[codec] Warning: failed to set GST_BUFFER_SIZE: %v", err)
1017+ }
1018+
1019+ if err := os.Setenv("GST_QUEUE2_MAX_SIZE_BYTES", bufferSize); err != nil {
1020+ log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_BYTES: %v", err)
1021+ }
1022+
1023+ log.Printf("[codec] Set video buffer size to %dMB", codecPrefs.VideoBufferSizeMB)
1024+ }
1025+
1026+ // Set queue buffer time if specified
1027+ if codecPrefs.QueueBufferTimeSec > 0 {
1028+ // Convert seconds to nanoseconds for GStreamer
1029+ bufferTime := strconv.Itoa(codecPrefs.QueueBufferTimeSec * 1000000000)
1030+
1031+ if err := os.Setenv("GST_QUEUE2_MAX_SIZE_TIME", bufferTime); err != nil {
1032+ log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_TIME: %v", err)
1033+ } else {
1034+ log.Printf("[codec] Set queue buffer time to %ds", codecPrefs.QueueBufferTimeSec)
1035+ }
1036+ }
1037+
1038+ // Set additional codec-specific environment variables
1039+ if codecPrefs.ForceAV1 {
1040+ // Enable AV1 hardware decoding if available
1041+ if err := os.Setenv("GST_AV1_DECODER_ENABLE_HW", "1"); err != nil {
1042+ log.Printf("[codec] Warning: failed to set GST_AV1_DECODER_ENABLE_HW: %v", err)
1043+ }
1044+ }
1045+
1046+ // Disable video post-processing to avoid VA-API issues
1047+ if err := os.Setenv("GST_VAAPI_DISABLE_VPP", "1"); err != nil {
1048+ log.Printf("[codec] Warning: failed to set GST_VAAPI_DISABLE_VPP: %v", err)
1049+ }
1050+
1051+ log.Printf("[codec] Codec configuration applied successfully")
1052+}
1053+
1054+// buildBlockedCodecsList converts config codec blocking preferences to a string slice
1055+func buildBlockedCodecsList(codecPrefs config.CodecConfig) []string {
1056+ var blocked []string
1057+
1058+ if codecPrefs.BlockVP9 {
1059+ blocked = append(blocked, "vp9")
1060+ }
1061+ if codecPrefs.BlockVP8 {
1062+ blocked = append(blocked, "vp8")
1063+ }
1064+
1065+ return blocked
1066+}
1067+
929 // getHomepageFaviconSVG returns the SVG used as a favicon for the homepage1068 // getHomepageFaviconSVG returns the SVG used as a favicon for the homepage
930 // when WebKit requests dumb://homepage/favicon.ico.1069 // when WebKit requests dumb://homepage/favicon.ico.
931 // getHomepageFaviconSVG was inlined previously; now favicon.svg is emitted at build time.1070 // getHomepageFaviconSVG was inlined previously; now favicon.svg is emitted at build time.
M pkg/clipboard/clipboard.go +5 -5
@@ -31,7 +31,7 @@ func CopyToClipboard(text string) error {
3131 func tryWlCopy(text string) error {
3232 cmd := exec.Command("wl-copy")
3333 cmd.Stdin = nil
34-
34+
3535 // Set up stdin pipe to pass the text
3636 stdin, err := cmd.StdinPipe()
3737 if err != nil {
@@ -70,7 +70,7 @@ func tryWlCopy(text string) error {
7070 // tryXclip attempts to copy text using xclip (X11 clipboard)
7171 func tryXclip(text string) error {
7272 cmd := exec.Command("xclip", "-selection", "clipboard")
73-
73+
7474 // Set up stdin pipe to pass the text
7575 stdin, err := cmd.StdinPipe()
7676 if err != nil {
@@ -112,11 +112,11 @@ func IsAvailable() bool {
112112 if _, err := exec.LookPath("wl-copy"); err == nil {
113113 return true
114114 }
115-
115+
116116 // Check if xclip is available
117117 if _, err := exec.LookPath("xclip"); err == nil {
118118 return true
119119 }
120-
120+
121121 return false
122-}⏎̸
122+}
@@ -31,7 +31,7 @@ func CopyToClipboard(text string) error {
31 func tryWlCopy(text string) error {31 func tryWlCopy(text string) error {
32 cmd := exec.Command("wl-copy")32 cmd := exec.Command("wl-copy")
33 cmd.Stdin = nil33 cmd.Stdin = nil
34- 34+
35 // Set up stdin pipe to pass the text35 // Set up stdin pipe to pass the text
36 stdin, err := cmd.StdinPipe()36 stdin, err := cmd.StdinPipe()
37 if err != nil {37 if err != nil {
@@ -70,7 +70,7 @@ func tryWlCopy(text string) error {
70 // tryXclip attempts to copy text using xclip (X11 clipboard)70 // tryXclip attempts to copy text using xclip (X11 clipboard)
71 func tryXclip(text string) error {71 func tryXclip(text string) error {
72 cmd := exec.Command("xclip", "-selection", "clipboard")72 cmd := exec.Command("xclip", "-selection", "clipboard")
73- 73+
74 // Set up stdin pipe to pass the text74 // Set up stdin pipe to pass the text
75 stdin, err := cmd.StdinPipe()75 stdin, err := cmd.StdinPipe()
76 if err != nil {76 if err != nil {
@@ -112,11 +112,11 @@ func IsAvailable() bool {
112 if _, err := exec.LookPath("wl-copy"); err == nil {112 if _, err := exec.LookPath("wl-copy"); err == nil {
113 return true113 return true
114 }114 }
115- 115+
116 // Check if xclip is available116 // Check if xclip is available
117 if _, err := exec.LookPath("xclip"); err == nil {117 if _, err := exec.LookPath("xclip"); err == nil {
118 return true118 return true
119 }119 }
120- 120+
121 return false121 return false
122-}⏎̸122+}
M pkg/clipboard/clipboard_test.go +9 -9
@@ -12,7 +12,7 @@ func TestCopyToClipboard(t *testing.T) {
1212 }
1313
1414 testText := "https://example.com/test-url"
15-
15+
1616 err := CopyToClipboard(testText)
1717 if err != nil {
1818 t.Errorf("CopyToClipboard failed: %v", err)
@@ -28,21 +28,21 @@ func TestCopyToClipboard_EmptyString(t *testing.T) {
2828
2929 func TestIsAvailable(t *testing.T) {
3030 available := IsAvailable()
31-
31+
3232 // Check if the result matches the actual availability
3333 wlCopyExists := false
3434 xclipExists := false
35-
35+
3636 if _, err := exec.LookPath("wl-copy"); err == nil {
3737 wlCopyExists = true
3838 }
39-
39+
4040 if _, err := exec.LookPath("xclip"); err == nil {
4141 xclipExists = true
4242 }
43-
43+
4444 expected := wlCopyExists || xclipExists
45-
45+
4646 if available != expected {
4747 t.Errorf("IsAvailable() = %v, expected %v", available, expected)
4848 }
@@ -52,7 +52,7 @@ func TestTryWlCopy(t *testing.T) {
5252 if _, err := exec.LookPath("wl-copy"); err != nil {
5353 t.Skip("wl-copy not available, skipping test")
5454 }
55-
55+
5656 err := tryWlCopy("test content")
5757 if err != nil {
5858 t.Errorf("tryWlCopy failed: %v", err)
@@ -63,9 +63,9 @@ func TestTryXclip(t *testing.T) {
6363 if _, err := exec.LookPath("xclip"); err != nil {
6464 t.Skip("xclip not available, skipping test")
6565 }
66-
66+
6767 err := tryXclip("test content")
6868 if err != nil {
6969 t.Errorf("tryXclip failed: %v", err)
7070 }
71-}⏎̸
71+}
@@ -12,7 +12,7 @@ func TestCopyToClipboard(t *testing.T) {
12 }12 }
13 13
14 testText := "https://example.com/test-url"14 testText := "https://example.com/test-url"
15- 15+
16 err := CopyToClipboard(testText)16 err := CopyToClipboard(testText)
17 if err != nil {17 if err != nil {
18 t.Errorf("CopyToClipboard failed: %v", err)18 t.Errorf("CopyToClipboard failed: %v", err)
@@ -28,21 +28,21 @@ func TestCopyToClipboard_EmptyString(t *testing.T) {
28 28
29 func TestIsAvailable(t *testing.T) {29 func TestIsAvailable(t *testing.T) {
30 available := IsAvailable()30 available := IsAvailable()
31- 31+
32 // Check if the result matches the actual availability32 // Check if the result matches the actual availability
33 wlCopyExists := false33 wlCopyExists := false
34 xclipExists := false34 xclipExists := false
35- 35+
36 if _, err := exec.LookPath("wl-copy"); err == nil {36 if _, err := exec.LookPath("wl-copy"); err == nil {
37 wlCopyExists = true37 wlCopyExists = true
38 }38 }
39- 39+
40 if _, err := exec.LookPath("xclip"); err == nil {40 if _, err := exec.LookPath("xclip"); err == nil {
41 xclipExists = true41 xclipExists = true
42 }42 }
43- 43+
44 expected := wlCopyExists || xclipExists44 expected := wlCopyExists || xclipExists
45- 45+
46 if available != expected {46 if available != expected {
47 t.Errorf("IsAvailable() = %v, expected %v", available, expected)47 t.Errorf("IsAvailable() = %v, expected %v", available, expected)
48 }48 }
@@ -52,7 +52,7 @@ func TestTryWlCopy(t *testing.T) {
52 if _, err := exec.LookPath("wl-copy"); err != nil {52 if _, err := exec.LookPath("wl-copy"); err != nil {
53 t.Skip("wl-copy not available, skipping test")53 t.Skip("wl-copy not available, skipping test")
54 }54 }
55- 55+
56 err := tryWlCopy("test content")56 err := tryWlCopy("test content")
57 if err != nil {57 if err != nil {
58 t.Errorf("tryWlCopy failed: %v", err)58 t.Errorf("tryWlCopy failed: %v", err)
@@ -63,9 +63,9 @@ func TestTryXclip(t *testing.T) {
63 if _, err := exec.LookPath("xclip"); err != nil {63 if _, err := exec.LookPath("xclip"); err != nil {
64 t.Skip("xclip not available, skipping test")64 t.Skip("xclip not available, skipping test")
65 }65 }
66- 66+
67 err := tryXclip("test content")67 err := tryXclip("test content")
68 if err != nil {68 if err != nil {
69 t.Errorf("tryXclip failed: %v", err)69 t.Errorf("tryXclip failed: %v", err)
70 }70 }
71-}⏎̸71+}
M pkg/gpu/detect.go +261 -3
@@ -38,7 +38,7 @@ func (g GPUInfo) String() string {
3838 // DetectGPU attempts to detect the GPU vendor and driver using multiple methods
3939 func DetectGPU() GPUInfo {
4040 logging.Debug("Starting GPU detection")
41-
41+
4242 // Try methods in order of reliability
4343 methods := []func() GPUInfo{
4444 detectViaGLXInfo,
@@ -200,7 +200,7 @@ func extractDeviceName(line string) string {
200200 }
201201
202202 name := strings.TrimSpace(parts[len(parts)-1])
203-
203+
204204 // Remove revision info in parentheses
205205 if idx := strings.Index(name, " (rev "); idx != -1 {
206206 name = name[:idx]
@@ -230,4 +230,262 @@ func (g GPUInfo) GetVAAPIDriverName() string {
230230 // SupportsVAAPI returns true if the GPU vendor supports VA-API
231231 func (g GPUInfo) SupportsVAAPI() bool {
232232 return g.Vendor == GPUVendorAMD || g.Vendor == GPUVendorIntel || g.Vendor == GPUVendorNVIDIA
233-}⏎̸
233+}
234+
235+// SupportsAV1Hardware returns true if GPU has hardware AV1 decoding support
236+func (g GPUInfo) SupportsAV1Hardware() bool {
237+ switch g.Vendor {
238+ case GPUVendorAMD:
239+ return g.supportsAMDAV1()
240+ case GPUVendorNVIDIA:
241+ return g.supportsNVIDIAAV1()
242+ case GPUVendorIntel:
243+ return g.supportsIntelAV1()
244+ default:
245+ return false
246+ }
247+}
248+
249+// supportsAMDAV1 checks for AMD AV1 hardware decode support based on official AMD specifications
250+// RDNA2+ architectures support AV1, with RDNA3+ adding encode support and RDNA4 bringing full B-frame support
251+func (g GPUInfo) supportsAMDAV1() bool {
252+ name := strings.ToLower(g.Name)
253+
254+ // RDNA 2 architecture (RX 6000 series) - AV1 decode only
255+ // Source: Official AMD RDNA2 specifications - hardware-accelerated AV1 decoding
256+ rdna2Models := []string{
257+ // Desktop RX 6000 series (all RDNA2 models support AV1 decode)
258+ "rx 6400", "rx 6450", "rx 6500", "rx 6550", "rx 6600", "rx 6650",
259+ "rx 6700", "rx 6700 xt", "rx 6750", "rx 6750 xt",
260+ "rx 6800", "rx 6800 xt", "rx 6900", "rx 6900 xt", "rx 6950 xt",
261+ }
262+
263+ // RDNA 3 architecture (RX 7000 series) - AV1 encode/decode with VCN 4.0
264+ // Source: Official AMD specifications - first RDNA with dedicated media engine and AV1 encode
265+ rdna3Models := []string{
266+ "rx 7600", "rx 7600 xt", "rx 7700", "rx 7700 xt",
267+ "rx 7800", "rx 7800 xt", "rx 7900", "rx 7900 xt", "rx 7900 gre", "rx 7900 xtx",
268+ }
269+
270+ // RDNA 4 architecture (RX 9000 series) - Full AV1 support with B-frame encoding
271+ // Source: Official AMD RX 9070 series announcement - enhanced AV1 with B-frame support
272+ rdna4Models := []string{
273+ "rx 9070", "rx 9070 xt", // Officially announced models
274+ // Future RDNA4 models expected to follow similar naming
275+ "rx 9060", "rx 9080", "rx 9090",
276+ }
277+
278+ // Check against all supported models
279+ allModels := append(rdna2Models, rdna3Models...)
280+ allModels = append(allModels, rdna4Models...)
281+
282+ for _, model := range allModels {
283+ if strings.Contains(name, model) {
284+ return true
285+ }
286+ }
287+
288+ // APUs with RDNA2/3 integrated graphics
289+ // Source: AMD APU specifications with RDNA2+ iGPUs
290+ apuModels := []string{
291+ // RDNA2-based APUs
292+ "ryzen 6000", "radeon 680m", "radeon 660m",
293+ // RDNA3-based APUs
294+ "ryzen 7000", "radeon 780m", "radeon 760m",
295+ // Future RDNA3+ APUs
296+ "ryzen 8000", "ryzen 9000", "radeon 880m",
297+ }
298+
299+ for _, apu := range apuModels {
300+ if strings.Contains(name, apu) {
301+ return true
302+ }
303+ }
304+
305+ return false
306+}
307+
308+// supportsNVIDIAAV1 checks for NVIDIA AV1 hardware decode/encode support based on official NVIDIA specifications
309+func (g GPUInfo) supportsNVIDIAAV1() bool {
310+ name := strings.ToLower(g.Name)
311+
312+ // RTX 30 series (Ampere) - AV1 decode only, up to 8K HDR
313+ // Source: Official NVIDIA RTX 30 series specifications - first NVIDIA GPUs with AV1 decode
314+ rtx30Series := []string{
315+ "rtx 3060", "rtx 3060 ti", "rtx 3070", "rtx 3070 ti",
316+ "rtx 3080", "rtx 3080 ti", "rtx 3090", "rtx 3090 ti",
317+ "rtx 3050", // Budget model with AV1 decode
318+ }
319+
320+ // RTX 40 series (Ada Lovelace) - Full AV1 encode/decode with 8th gen NVENC
321+ // Source: Official NVIDIA RTX 40 series specifications - real-time AV1 encoding support
322+ rtx40Series := []string{
323+ "rtx 4060", "rtx 4060 ti", "rtx 4070", "rtx 4070 ti", "rtx 4070 super",
324+ "rtx 4080", "rtx 4080 super", "rtx 4090",
325+ "rtx 4050", // Mobile/budget variants
326+ }
327+
328+ // Mobile RTX 40 series (Ada Lovelace) - Full AV1 support
329+ mobileRtx40 := []string{
330+ "rtx 4050 mobile", "rtx 4060 mobile", "rtx 4070 mobile", "rtx 4080 mobile", "rtx 4090 mobile",
331+ }
332+
333+ // Professional Ada Lovelace cards
334+ professionalAda := []string{
335+ "rtx a4000", "rtx a4500", "rtx a5000", "rtx a6000", // Ada Lovelace professional
336+ "l4", "l40", "l40s", // Data center Ada Lovelace cards
337+ }
338+
339+ // Check all series with AV1 support (decode-only for RTX 30, encode+decode for RTX 40+)
340+ allCards := append(rtx30Series, rtx40Series...)
341+ allCards = append(allCards, mobileRtx40...)
342+ allCards = append(allCards, professionalAda...)
343+
344+ for _, card := range allCards {
345+ if strings.Contains(name, card) {
346+ return true
347+ }
348+ }
349+
350+ // Architecture-based detection
351+ if strings.Contains(name, "ampere") || strings.Contains(name, "ada lovelace") {
352+ return true
353+ }
354+
355+ return false
356+}
357+
358+// supportsIntelAV1 checks for Intel AV1 hardware decode/encode support based on official Intel specifications
359+// Intel Arc GPUs are the first to offer full hardware AV1 acceleration (encode + decode)
360+func (g GPUInfo) supportsIntelAV1() bool {
361+ name := strings.ToLower(g.Name)
362+
363+ // Intel Arc A-Series discrete GPUs (Alchemist, Xe-HPG architecture)
364+ // Source: Official Intel Arc specifications - industry-first full AV1 hardware acceleration
365+ arcAlchemist := []string{
366+ "arc a310", "arc a350", "arc a380", // Entry-level
367+ "arc a580", "arc a750", "arc a770", // Performance models
368+ }
369+
370+ // Intel Arc B-Series discrete GPUs (Battlemage, Xe2 architecture)
371+ // Source: Official Intel Arc B-Series launch (December 2024) - B580 and B570 with enhanced AV1
372+ arcBattlemage := []string{
373+ "arc b570", "arc b580", // Officially announced December 2024
374+ // Future Battlemage models expected
375+ "arc b750", "arc b770",
376+ }
377+
378+ // Intel Xe integrated graphics with AV1 support (12th gen+)
379+ // Note: Integrated graphics may have limited AV1 encode capabilities compared to discrete
380+ xeIntegrated := []string{
381+ "xe graphics", "iris xe", "uhd graphics xe",
382+ // Specific integrated GPU models
383+ "xe graphics g7", "iris xe graphics", "uhd graphics xe g4",
384+ }
385+
386+ // CPU generation indicators for AV1-capable integrated graphics
387+ // Source: Intel specifications - AV1 support starting with Alder Lake (12th gen)
388+ cpuGenerations := []string{
389+ // Architecture codenames
390+ "alder lake", "raptor lake", "meteor lake", "lunar lake", "arrow lake",
391+ // Consumer generation naming
392+ "12th gen", "13th gen", "14th gen", "15th gen",
393+ // Core series with AV1 support
394+ "core ultra", // New branding for newer processors
395+ }
396+
397+ // Check discrete Arc GPUs (full AV1 encode/decode support)
398+ allArcModels := append(arcAlchemist, arcBattlemage...)
399+ for _, gpu := range allArcModels {
400+ if strings.Contains(name, gpu) {
401+ return true
402+ }
403+ }
404+
405+ // Check integrated Xe graphics with generation validation
406+ for _, xe := range xeIntegrated {
407+ if strings.Contains(name, xe) {
408+ // Verify it's from a generation that supports AV1 (12th gen+)
409+ for _, gen := range cpuGenerations {
410+ if strings.Contains(name, gen) {
411+ return true
412+ }
413+ }
414+ // If we find Xe but can't determine generation, assume newer (likely supports AV1)
415+ return true
416+ }
417+ }
418+
419+ // Check by CPU generation alone (may have Xe integrated graphics)
420+ for _, gen := range cpuGenerations {
421+ if strings.Contains(name, gen) {
422+ return true
423+ }
424+ }
425+
426+ return false
427+}
428+
429+// GetAV1HardwareCapabilities returns detailed AV1 capabilities for the GPU
430+func (g GPUInfo) GetAV1HardwareCapabilities() map[string]bool {
431+ capabilities := map[string]bool{
432+ "decode": false,
433+ "encode": false,
434+ "10bit": false, // 10-bit AV1 support
435+ "hdr": false, // HDR support
436+ }
437+
438+ if !g.SupportsAV1Hardware() {
439+ return capabilities
440+ }
441+
442+ name := strings.ToLower(g.Name)
443+
444+ switch g.Vendor {
445+ case GPUVendorAMD:
446+ // All RDNA2+ GPUs support AV1 decode, RDNA3+ adds encode
447+ capabilities["decode"] = true
448+ capabilities["10bit"] = true
449+
450+ // RDNA3 (RX 7000) has VCN 4.0 with AV1 encode support
451+ // RDNA4 (RX 9000) adds B-frame encoding for better compression
452+ if strings.Contains(name, "rx 7") || strings.Contains(name, "rx 9") {
453+ capabilities["encode"] = true
454+ capabilities["hdr"] = true
455+ }
456+
457+ case GPUVendorNVIDIA:
458+ // RTX 30 series: AV1 decode only (8K HDR capable)
459+ if strings.Contains(name, "rtx 3") {
460+ capabilities["decode"] = true
461+ capabilities["10bit"] = true
462+ capabilities["hdr"] = true
463+ }
464+ // RTX 40 series: Full AV1 encode/decode with 8th gen NVENC
465+ if strings.Contains(name, "rtx 4") {
466+ capabilities["decode"] = true
467+ capabilities["encode"] = true
468+ capabilities["10bit"] = true
469+ capabilities["hdr"] = true
470+ }
471+
472+ case GPUVendorIntel:
473+ // Intel Arc discrete GPUs: Full AV1 encode/decode (industry first)
474+ // Xe integrated: AV1 decode, limited encode capabilities
475+ capabilities["decode"] = true
476+ capabilities["10bit"] = true
477+
478+ // Arc discrete GPUs have full AV1 encode support
479+ if strings.Contains(name, "arc a") || strings.Contains(name, "arc b") {
480+ capabilities["encode"] = true
481+ capabilities["hdr"] = true
482+ }
483+ // Xe integrated may have basic encode on newer generations
484+ if strings.Contains(name, "xe graphics") || strings.Contains(name, "iris xe") {
485+ // Conservative: assume decode-only for integrated unless proven otherwise
486+ capabilities["hdr"] = true
487+ }
488+ }
489+
490+ return capabilities
491+}
@@ -38,7 +38,7 @@ func (g GPUInfo) String() string {
38 // DetectGPU attempts to detect the GPU vendor and driver using multiple methods38 // DetectGPU attempts to detect the GPU vendor and driver using multiple methods
39 func DetectGPU() GPUInfo {39 func DetectGPU() GPUInfo {
40 logging.Debug("Starting GPU detection")40 logging.Debug("Starting GPU detection")
41- 41+
42 // Try methods in order of reliability42 // Try methods in order of reliability
43 methods := []func() GPUInfo{43 methods := []func() GPUInfo{
44 detectViaGLXInfo,44 detectViaGLXInfo,
@@ -200,7 +200,7 @@ func extractDeviceName(line string) string {
200 }200 }
201 201
202 name := strings.TrimSpace(parts[len(parts)-1])202 name := strings.TrimSpace(parts[len(parts)-1])
203- 203+
204 // Remove revision info in parentheses204 // Remove revision info in parentheses
205 if idx := strings.Index(name, " (rev "); idx != -1 {205 if idx := strings.Index(name, " (rev "); idx != -1 {
206 name = name[:idx]206 name = name[:idx]
@@ -230,4 +230,262 @@ func (g GPUInfo) GetVAAPIDriverName() string {
230 // SupportsVAAPI returns true if the GPU vendor supports VA-API230 // SupportsVAAPI returns true if the GPU vendor supports VA-API
231 func (g GPUInfo) SupportsVAAPI() bool {231 func (g GPUInfo) SupportsVAAPI() bool {
232 return g.Vendor == GPUVendorAMD || g.Vendor == GPUVendorIntel || g.Vendor == GPUVendorNVIDIA232 return g.Vendor == GPUVendorAMD || g.Vendor == GPUVendorIntel || g.Vendor == GPUVendorNVIDIA
233-}⏎̸233+}
234+
235+// SupportsAV1Hardware returns true if GPU has hardware AV1 decoding support
236+func (g GPUInfo) SupportsAV1Hardware() bool {
237+ switch g.Vendor {
238+ case GPUVendorAMD:
239+ return g.supportsAMDAV1()
240+ case GPUVendorNVIDIA:
241+ return g.supportsNVIDIAAV1()
242+ case GPUVendorIntel:
243+ return g.supportsIntelAV1()
244+ default:
245+ return false
246+ }
247+}
248+
249+// supportsAMDAV1 checks for AMD AV1 hardware decode support based on official AMD specifications
250+// RDNA2+ architectures support AV1, with RDNA3+ adding encode support and RDNA4 bringing full B-frame support
251+func (g GPUInfo) supportsAMDAV1() bool {
252+ name := strings.ToLower(g.Name)
253+
254+ // RDNA 2 architecture (RX 6000 series) - AV1 decode only
255+ // Source: Official AMD RDNA2 specifications - hardware-accelerated AV1 decoding
256+ rdna2Models := []string{
257+ // Desktop RX 6000 series (all RDNA2 models support AV1 decode)
258+ "rx 6400", "rx 6450", "rx 6500", "rx 6550", "rx 6600", "rx 6650",
259+ "rx 6700", "rx 6700 xt", "rx 6750", "rx 6750 xt",
260+ "rx 6800", "rx 6800 xt", "rx 6900", "rx 6900 xt", "rx 6950 xt",
261+ }
262+
263+ // RDNA 3 architecture (RX 7000 series) - AV1 encode/decode with VCN 4.0
264+ // Source: Official AMD specifications - first RDNA with dedicated media engine and AV1 encode
265+ rdna3Models := []string{
266+ "rx 7600", "rx 7600 xt", "rx 7700", "rx 7700 xt",
267+ "rx 7800", "rx 7800 xt", "rx 7900", "rx 7900 xt", "rx 7900 gre", "rx 7900 xtx",
268+ }
269+
270+ // RDNA 4 architecture (RX 9000 series) - Full AV1 support with B-frame encoding
271+ // Source: Official AMD RX 9070 series announcement - enhanced AV1 with B-frame support
272+ rdna4Models := []string{
273+ "rx 9070", "rx 9070 xt", // Officially announced models
274+ // Future RDNA4 models expected to follow similar naming
275+ "rx 9060", "rx 9080", "rx 9090",
276+ }
277+
278+ // Check against all supported models
279+ allModels := append(rdna2Models, rdna3Models...)
280+ allModels = append(allModels, rdna4Models...)
281+
282+ for _, model := range allModels {
283+ if strings.Contains(name, model) {
284+ return true
285+ }
286+ }
287+
288+ // APUs with RDNA2/3 integrated graphics
289+ // Source: AMD APU specifications with RDNA2+ iGPUs
290+ apuModels := []string{
291+ // RDNA2-based APUs
292+ "ryzen 6000", "radeon 680m", "radeon 660m",
293+ // RDNA3-based APUs
294+ "ryzen 7000", "radeon 780m", "radeon 760m",
295+ // Future RDNA3+ APUs
296+ "ryzen 8000", "ryzen 9000", "radeon 880m",
297+ }
298+
299+ for _, apu := range apuModels {
300+ if strings.Contains(name, apu) {
301+ return true
302+ }
303+ }
304+
305+ return false
306+}
307+
308+// supportsNVIDIAAV1 checks for NVIDIA AV1 hardware decode/encode support based on official NVIDIA specifications
309+func (g GPUInfo) supportsNVIDIAAV1() bool {
310+ name := strings.ToLower(g.Name)
311+
312+ // RTX 30 series (Ampere) - AV1 decode only, up to 8K HDR
313+ // Source: Official NVIDIA RTX 30 series specifications - first NVIDIA GPUs with AV1 decode
314+ rtx30Series := []string{
315+ "rtx 3060", "rtx 3060 ti", "rtx 3070", "rtx 3070 ti",
316+ "rtx 3080", "rtx 3080 ti", "rtx 3090", "rtx 3090 ti",
317+ "rtx 3050", // Budget model with AV1 decode
318+ }
319+
320+ // RTX 40 series (Ada Lovelace) - Full AV1 encode/decode with 8th gen NVENC
321+ // Source: Official NVIDIA RTX 40 series specifications - real-time AV1 encoding support
322+ rtx40Series := []string{
323+ "rtx 4060", "rtx 4060 ti", "rtx 4070", "rtx 4070 ti", "rtx 4070 super",
324+ "rtx 4080", "rtx 4080 super", "rtx 4090",
325+ "rtx 4050", // Mobile/budget variants
326+ }
327+
328+ // Mobile RTX 40 series (Ada Lovelace) - Full AV1 support
329+ mobileRtx40 := []string{
330+ "rtx 4050 mobile", "rtx 4060 mobile", "rtx 4070 mobile", "rtx 4080 mobile", "rtx 4090 mobile",
331+ }
332+
333+ // Professional Ada Lovelace cards
334+ professionalAda := []string{
335+ "rtx a4000", "rtx a4500", "rtx a5000", "rtx a6000", // Ada Lovelace professional
336+ "l4", "l40", "l40s", // Data center Ada Lovelace cards
337+ }
338+
339+ // Check all series with AV1 support (decode-only for RTX 30, encode+decode for RTX 40+)
340+ allCards := append(rtx30Series, rtx40Series...)
341+ allCards = append(allCards, mobileRtx40...)
342+ allCards = append(allCards, professionalAda...)
343+
344+ for _, card := range allCards {
345+ if strings.Contains(name, card) {
346+ return true
347+ }
348+ }
349+
350+ // Architecture-based detection
351+ if strings.Contains(name, "ampere") || strings.Contains(name, "ada lovelace") {
352+ return true
353+ }
354+
355+ return false
356+}
357+
358+// supportsIntelAV1 checks for Intel AV1 hardware decode/encode support based on official Intel specifications
359+// Intel Arc GPUs are the first to offer full hardware AV1 acceleration (encode + decode)
360+func (g GPUInfo) supportsIntelAV1() bool {
361+ name := strings.ToLower(g.Name)
362+
363+ // Intel Arc A-Series discrete GPUs (Alchemist, Xe-HPG architecture)
364+ // Source: Official Intel Arc specifications - industry-first full AV1 hardware acceleration
365+ arcAlchemist := []string{
366+ "arc a310", "arc a350", "arc a380", // Entry-level
367+ "arc a580", "arc a750", "arc a770", // Performance models
368+ }
369+
370+ // Intel Arc B-Series discrete GPUs (Battlemage, Xe2 architecture)
371+ // Source: Official Intel Arc B-Series launch (December 2024) - B580 and B570 with enhanced AV1
372+ arcBattlemage := []string{
373+ "arc b570", "arc b580", // Officially announced December 2024
374+ // Future Battlemage models expected
375+ "arc b750", "arc b770",
376+ }
377+
378+ // Intel Xe integrated graphics with AV1 support (12th gen+)
379+ // Note: Integrated graphics may have limited AV1 encode capabilities compared to discrete
380+ xeIntegrated := []string{
381+ "xe graphics", "iris xe", "uhd graphics xe",
382+ // Specific integrated GPU models
383+ "xe graphics g7", "iris xe graphics", "uhd graphics xe g4",
384+ }
385+
386+ // CPU generation indicators for AV1-capable integrated graphics
387+ // Source: Intel specifications - AV1 support starting with Alder Lake (12th gen)
388+ cpuGenerations := []string{
389+ // Architecture codenames
390+ "alder lake", "raptor lake", "meteor lake", "lunar lake", "arrow lake",
391+ // Consumer generation naming
392+ "12th gen", "13th gen", "14th gen", "15th gen",
393+ // Core series with AV1 support
394+ "core ultra", // New branding for newer processors
395+ }
396+
397+ // Check discrete Arc GPUs (full AV1 encode/decode support)
398+ allArcModels := append(arcAlchemist, arcBattlemage...)
399+ for _, gpu := range allArcModels {
400+ if strings.Contains(name, gpu) {
401+ return true
402+ }
403+ }
404+
405+ // Check integrated Xe graphics with generation validation
406+ for _, xe := range xeIntegrated {
407+ if strings.Contains(name, xe) {
408+ // Verify it's from a generation that supports AV1 (12th gen+)
409+ for _, gen := range cpuGenerations {
410+ if strings.Contains(name, gen) {
411+ return true
412+ }
413+ }
414+ // If we find Xe but can't determine generation, assume newer (likely supports AV1)
415+ return true
416+ }
417+ }
418+
419+ // Check by CPU generation alone (may have Xe integrated graphics)
420+ for _, gen := range cpuGenerations {
421+ if strings.Contains(name, gen) {
422+ return true
423+ }
424+ }
425+
426+ return false
427+}
428+
429+// GetAV1HardwareCapabilities returns detailed AV1 capabilities for the GPU
430+func (g GPUInfo) GetAV1HardwareCapabilities() map[string]bool {
431+ capabilities := map[string]bool{
432+ "decode": false,
433+ "encode": false,
434+ "10bit": false, // 10-bit AV1 support
435+ "hdr": false, // HDR support
436+ }
437+
438+ if !g.SupportsAV1Hardware() {
439+ return capabilities
440+ }
441+
442+ name := strings.ToLower(g.Name)
443+
444+ switch g.Vendor {
445+ case GPUVendorAMD:
446+ // All RDNA2+ GPUs support AV1 decode, RDNA3+ adds encode
447+ capabilities["decode"] = true
448+ capabilities["10bit"] = true
449+
450+ // RDNA3 (RX 7000) has VCN 4.0 with AV1 encode support
451+ // RDNA4 (RX 9000) adds B-frame encoding for better compression
452+ if strings.Contains(name, "rx 7") || strings.Contains(name, "rx 9") {
453+ capabilities["encode"] = true
454+ capabilities["hdr"] = true
455+ }
456+
457+ case GPUVendorNVIDIA:
458+ // RTX 30 series: AV1 decode only (8K HDR capable)
459+ if strings.Contains(name, "rtx 3") {
460+ capabilities["decode"] = true
461+ capabilities["10bit"] = true
462+ capabilities["hdr"] = true
463+ }
464+ // RTX 40 series: Full AV1 encode/decode with 8th gen NVENC
465+ if strings.Contains(name, "rtx 4") {
466+ capabilities["decode"] = true
467+ capabilities["encode"] = true
468+ capabilities["10bit"] = true
469+ capabilities["hdr"] = true
470+ }
471+
472+ case GPUVendorIntel:
473+ // Intel Arc discrete GPUs: Full AV1 encode/decode (industry first)
474+ // Xe integrated: AV1 decode, limited encode capabilities
475+ capabilities["decode"] = true
476+ capabilities["10bit"] = true
477+
478+ // Arc discrete GPUs have full AV1 encode support
479+ if strings.Contains(name, "arc a") || strings.Contains(name, "arc b") {
480+ capabilities["encode"] = true
481+ capabilities["hdr"] = true
482+ }
483+ // Xe integrated may have basic encode on newer generations
484+ if strings.Contains(name, "xe graphics") || strings.Contains(name, "iris xe") {
485+ // Conservative: assume decode-only for integrated unless proven otherwise
486+ capabilities["hdr"] = true
487+ }
488+ }
489+
490+ return capabilities
491+}
A pkg/webkit/codec_control.go +616 -0
@@ -0,0 +1,616 @@
1+package webkit
2+
3+import (
4+ "strings"
5+)
6+
7+// GenerateCodecControlScript creates JavaScript code to control codec preferences
8+// This script overrides MediaCapabilities API and platform-specific player configurations
9+func GenerateCodecControlScript(prefs CodecPreferencesConfig) string {
10+ // Build blocked codecs regex pattern
11+ var blockedCodecs []string
12+ for _, codec := range prefs.BlockedCodecs {
13+ switch strings.ToLower(codec) {
14+ case "vp9":
15+ blockedCodecs = append(blockedCodecs, "vp9", "vp09")
16+ case "vp8":
17+ blockedCodecs = append(blockedCodecs, "vp8", "vp08")
18+ case "h264":
19+ blockedCodecs = append(blockedCodecs, "h264", "avc1")
20+ case "h265", "hevc":
21+ blockedCodecs = append(blockedCodecs, "h265", "hevc", "hev1", "hvc1")
22+ default:
23+ blockedCodecs = append(blockedCodecs, strings.ToLower(codec))
24+ }
25+ }
26+
27+ // Build preferred codecs list
28+ var preferredCodecs []string
29+ for _, codec := range prefs.PreferredCodecs {
30+ switch strings.ToLower(codec) {
31+ case "av1":
32+ preferredCodecs = append(preferredCodecs, "av01", "av1")
33+ case "h264":
34+ preferredCodecs = append(preferredCodecs, "h264", "avc1")
35+ case "vp9":
36+ preferredCodecs = append(preferredCodecs, "vp9", "vp09")
37+ case "vp8":
38+ preferredCodecs = append(preferredCodecs, "vp8", "vp08")
39+ default:
40+ preferredCodecs = append(preferredCodecs, strings.ToLower(codec))
41+ }
42+ }
43+
44+ blockedPattern := strings.Join(blockedCodecs, "|")
45+ hasBlockedCodecs := len(blockedCodecs) > 0
46+ hasPreferredCodecs := len(preferredCodecs) > 0
47+ firstPreferredCodec := ""
48+ if len(preferredCodecs) > 0 {
49+ firstPreferredCodec = preferredCodecs[0]
50+ }
51+
52+ // Convert preferredCodecs slice to proper JavaScript array syntax
53+ preferredCodecsJS := "[" + strings.Join(func() []string {
54+ var quoted []string
55+ for _, codec := range preferredCodecs {
56+ quoted = append(quoted, `"`+codec+`"`)
57+ }
58+ return quoted
59+ }(), ", ") + "]"
60+
61+ // Build JavaScript directly without format specifiers
62+ var js strings.Builder
63+
64+ js.WriteString(`(() => {
65+ 'use strict';
66+
67+ console.log('[dumber-codec] Initializing codec control');
68+
69+ // Smart fullscreen handling that works with WebKit's native behavior
70+ const videoFullscreenStates = new WeakMap();
71+ let fullscreenTransitionInProgress = false;
72+
73+ // Minimal state tracking - only what's essential
74+ function trackVideoForFullscreen(video) {
75+ if (!video || video.tagName !== 'VIDEO') return;
76+
77+ // Only track if video has meaningful content
78+ if (video.readyState < 2 || !video.duration) return;
79+
80+ const state = {
81+ wasPlaying: !video.paused && !video.ended,
82+ currentTime: video.currentTime,
83+ playbackRate: video.playbackRate
84+ };
85+
86+ videoFullscreenStates.set(video, state);
87+ console.log('[dumber-codec] Tracking video for fullscreen - playing:', state.wasPlaying, 'time:', state.currentTime);
88+ }
89+
90+ // Gentle recovery without forcing reloads
91+ function recoverVideoAfterFullscreen(video) {
92+ const state = videoFullscreenStates.get(video);
93+ if (!state) return;
94+
95+ console.log('[dumber-codec] Recovering video after fullscreen transition');
96+
97+ // Only intervene if video is stuck in a loading state
98+ const isStuck = video.readyState < 2 && video.networkState === 2; // NETWORK_LOADING but not ready
99+ const hasLostTime = Math.abs(video.currentTime - state.currentTime) > 2;
100+
101+ if (isStuck || hasLostTime) {
102+ console.log('[dumber-codec] Video appears stuck, applying gentle recovery');
103+
104+ // Gentle nudge - just try to resume if it was playing
105+ if (state.wasPlaying && video.paused) {
106+ setTimeout(() => {
107+ if (video.readyState >= 2) {
108+ video.play().catch(e => console.log('[dumber-codec] Play failed:', e));
109+ }
110+ }, 200);
111+ }
112+ }
113+
114+ videoFullscreenStates.delete(video);
115+ }
116+
117+ // WebKit-aware fullscreen change detection
118+ function handleWebKitFullscreenTransition() {
119+ if (fullscreenTransitionInProgress) return;
120+ fullscreenTransitionInProgress = true;
121+
122+ console.log('[dumber-codec] WebKit fullscreen transition detected');
123+
124+ const videos = document.querySelectorAll('video');
125+ const isEntering = !!(document.fullscreenElement || document.webkitFullscreenElement);
126+
127+ videos.forEach(video => {
128+ if (isEntering) {
129+ trackVideoForFullscreen(video);
130+ } else {
131+ // Give WebKit time to complete its native fullscreen handling
132+ setTimeout(() => recoverVideoAfterFullscreen(video), 500);
133+ }
134+ });
135+
136+ setTimeout(() => {
137+ fullscreenTransitionInProgress = false;
138+ }, 1000);
139+ }
140+
141+ // Use passive listeners to avoid interfering with WebKit's native handling
142+ document.addEventListener('fullscreenchange', handleWebKitFullscreenTransition, { passive: true });
143+ document.addEventListener('webkitfullscreenchange', handleWebKitFullscreenTransition, { passive: true });
144+
145+ // Override canPlayType for HTMLMediaElement (primary codec detection)
146+ const originalCanPlayType = HTMLMediaElement.prototype.canPlayType;
147+ HTMLMediaElement.prototype.canPlayType = function(type) {
148+ console.log('[dumber-codec] canPlayType called with:', type);
149+
150+ // Block unwanted codecs`)
151+
152+ if hasBlockedCodecs {
153+ js.WriteString(`
154+ const blockedRegex = /` + blockedPattern + `/i;
155+ if (blockedRegex.test(type)) {
156+ console.log('[dumber-codec] Blocking codec via canPlayType:', type);
157+ return '';
158+ }`)
159+ }
160+
161+ if hasPreferredCodecs {
162+ js.WriteString(`
163+
164+ // Boost preferred codecs
165+ const preferredCodecs = ` + preferredCodecsJS + `;
166+ for (const preferred of preferredCodecs) {
167+ if (type.toLowerCase().includes(preferred)) {
168+ console.log('[dumber-codec] Boosting preferred codec via canPlayType:', type);
169+ return 'probably';
170+ }
171+ }`)
172+ }
173+
174+ js.WriteString(`
175+
176+ // Call original for other codecs
177+ return originalCanPlayType.call(this, type);
178+ };
179+
180+ // Override MediaSource.isTypeSupported for more aggressive codec blocking
181+ if (window.MediaSource && MediaSource.isTypeSupported) {
182+ const originalIsTypeSupported = MediaSource.isTypeSupported.bind(MediaSource);
183+ MediaSource.isTypeSupported = function(type) {
184+ console.log('[dumber-codec] MediaSource.isTypeSupported called with:', type);`)
185+
186+ if hasBlockedCodecs {
187+ js.WriteString(`
188+
189+ // Block unwanted codecs
190+ const blockedRegex = /` + blockedPattern + `/i;
191+ if (blockedRegex.test(type)) {
192+ console.log('[dumber-codec] Blocking codec via MediaSource.isTypeSupported:', type);
193+ return false;
194+ }`)
195+ }
196+
197+ if hasPreferredCodecs {
198+ js.WriteString(`
199+
200+ // Boost preferred codecs
201+ const preferredCodecs = ` + preferredCodecsJS + `;
202+ for (const preferred of preferredCodecs) {
203+ if (type.toLowerCase().includes(preferred)) {
204+ console.log('[dumber-codec] Boosting preferred codec via MediaSource.isTypeSupported:', type);
205+ return true;
206+ }
207+ }`)
208+ }
209+
210+ js.WriteString(`
211+
212+ // Call original for other codecs
213+ return originalIsTypeSupported(type);
214+ };
215+ }
216+
217+ // Override MediaCapabilities API
218+ if (navigator.mediaCapabilities && navigator.mediaCapabilities.decodingInfo) {
219+ const originalDecodingInfo = navigator.mediaCapabilities.decodingInfo.bind(navigator.mediaCapabilities);
220+
221+ navigator.mediaCapabilities.decodingInfo = async function(config) {
222+ const contentType = config.video?.contentType || config.type || '';
223+
224+ // Parse resolution from video config for resolution-aware decisions
225+ let width = 0, height = 0;
226+ if (config.video) {
227+ width = config.video.width || 0;
228+ height = config.video.height || 0;
229+ }
230+
231+ // Determine if this is a high resolution request
232+ const isHighRes = height >= 1440 || width >= 2560; // 1440p/1080p ultrawide+
233+ const is4K = height >= 2160 || width >= 3840;
234+ const is1080p60 = (height >= 1080 || width >= 1920) &&
235+ (config.video?.framerate >= 50 || config.video?.frameRate >= 50);
236+
237+ console.log('[dumber-codec] MediaCapabilities query - contentType:', contentType,
238+ 'resolution:', width + 'x' + height, 'highRes:', isHighRes, '4K:', is4K, '1080p60:', is1080p60);
239+
240+ // Block unwanted codecs`)
241+
242+ if hasBlockedCodecs {
243+ js.WriteString(`
244+ const blockedRegex = /` + blockedPattern + `/i;
245+ if (blockedRegex.test(contentType)) {
246+ console.log('[dumber-codec] Blocking codec:', contentType);
247+ return {
248+ supported: false,
249+ smooth: false,
250+ powerEfficient: false
251+ };
252+ }`)
253+ }
254+
255+ js.WriteString(`
256+
257+ // Resolution-aware preferred codec handling`)
258+
259+ if hasPreferredCodecs {
260+ js.WriteString(`
261+ const preferredCodecs = ` + preferredCodecsJS + `;
262+ for (const preferred of preferredCodecs) {
263+ if (contentType.toLowerCase().includes(preferred)) {
264+ console.log('[dumber-codec] Boosting preferred codec:', contentType);
265+
266+ // Resolution-aware capability reporting for AV1
267+ if (preferred === 'av01' || preferred === 'av1') {
268+ // AV1 handling based on resolution
269+ if (is4K) {
270+ // 4K AV1: supported but not smooth (encourage VP9 fallback)
271+ return {
272+ supported: true,
273+ smooth: false,
274+ powerEfficient: false
275+ };
276+ } else if (is1080p60) {
277+ // 1080p60 AV1: supported but not power efficient
278+ return {
279+ supported: true,
280+ smooth: true,
281+ powerEfficient: false
282+ };
283+ } else if (isHighRes) {
284+ // 1440p AV1: supported but not optimal
285+ return {
286+ supported: true,
287+ smooth: false,
288+ powerEfficient: true
289+ };
290+ } else {
291+ // <= 1080p30 AV1: fully supported
292+ return {
293+ supported: true,
294+ smooth: true,
295+ powerEfficient: true
296+ };
297+ }
298+ } else {
299+ // Other preferred codecs (VP9, H264) - always report as capable
300+ return {
301+ supported: true,
302+ smooth: true,
303+ powerEfficient: true
304+ };
305+ }
306+ }
307+ }`)
308+ }
309+
310+ js.WriteString(`
311+
312+ // Call original for other codecs
313+ try {
314+ return await originalDecodingInfo(config);
315+ } catch (e) {
316+ console.warn('[dumber-codec] MediaCapabilities error:', e);
317+ return {
318+ supported: false,
319+ smooth: false,
320+ powerEfficient: false
321+ };
322+ }
323+ };
324+ }
325+
326+ // YouTube-specific codec forcing
327+ if (location.hostname.includes('youtube.com')) {
328+ console.log('[dumber-codec] Applying YouTube codec preferences');
329+
330+ // Set YouTube's localStorage preferences for smart AV1 usage
331+ // 2048 = prefer AV1 for lower resolutions, fallback to VP9 for higher res
332+ // This allows YouTube to make smarter codec decisions based on our MediaCapabilities
333+ try {
334+ localStorage.setItem('yt-player-av1-pref', '2048');
335+ Object.defineProperty(localStorage, 'yt-player-av1-pref', {
336+ value: '2048',
337+ writable: false,
338+ configurable: false
339+ });
340+ console.log('[dumber-codec] YouTube: Set smart AV1 localStorage preference (2048)');
341+ } catch (e) {
342+ console.warn('[dumber-codec] YouTube: Failed to set localStorage:', e);
343+ }
344+
345+ // Override ytInitialPlayerResponse parsing
346+ Object.defineProperty(window, 'ytplayer', {
347+ configurable: true,
348+ get() {
349+ return this._ytplayer || null;
350+ },
351+ set(player) {
352+ if (player && player.config && player.config.args) {`)
353+
354+ if prefs.ForceAV1 {
355+ js.WriteString(`
356+ // Force AV1 if enabled
357+ player.config.args.preferred_codecs = 'av01';
358+ console.log('[dumber-codec] YouTube: Forced AV1 codec');`)
359+ }
360+
361+ if hasPreferredCodecs {
362+ js.WriteString(`
363+ // Set codec preference order
364+ const preferredCodecs = ` + preferredCodecsJS + `;
365+ const codecOrder = preferredCodecs.map(c => {
366+ switch(c.toLowerCase()) {
367+ case 'av1': case 'av01': return 'av01';
368+ case 'h264': case 'avc1': return 'avc1';
369+ case 'vp9': case 'vp09': return 'vp9';
370+ case 'vp8': case 'vp08': return 'vp8';
371+ default: return c;
372+ }
373+ }).join(',');
374+ player.config.args.preferred_codecs = codecOrder;
375+ console.log('[dumber-codec] YouTube: Set codec order:', codecOrder);`)
376+ }
377+
378+ js.WriteString(`
379+ }
380+ this._ytplayer = player;
381+ }
382+ });
383+
384+ // Intercept YouTube's format selection
385+ const originalFetch = window.fetch;
386+ window.fetch = function(input, init) {
387+ if (typeof input === 'string' && input.includes('/youtubei/v1/player')) {
388+ console.log('[dumber-codec] YouTube: Intercepting player request');`)
389+
390+ if hasPreferredCodecs {
391+ js.WriteString(`
392+
393+ // Modify request to prefer our codecs
394+ if (init && init.body) {
395+ try {
396+ const body = JSON.parse(init.body);
397+ if (body.videoId) {
398+ // Add codec preferences to the request
399+ body.codecPreferences = ` + preferredCodecsJS + `;
400+ init.body = JSON.stringify(body);
401+ }
402+ } catch (e) {
403+ // Non-JSON body, ignore
404+ }
405+ }`)
406+ }
407+
408+ js.WriteString(`
409+ }
410+
411+ // Process YouTube player API responses for format manipulation
412+ if (typeof input === 'string' && input.includes('/youtubei/v1/player')) {
413+ return originalFetch.call(this, input, init).then(response => {
414+ if (!response.ok) return response;
415+
416+ return response.text().then(text => {
417+ try {
418+ const data = JSON.parse(text);
419+
420+ // Manipulate streaming data if present
421+ if (data.streamingData && data.streamingData.formats) {
422+ console.log('[dumber-codec] YouTube: Processing format manifest');
423+
424+ // Sort formats to prioritize AV1 for lower resolutions, VP9 for higher
425+ data.streamingData.formats.sort((a, b) => {
426+ const aHeight = a.height || 0;
427+ const bHeight = b.height || 0;
428+ const aIsAV1 = a.mimeType && a.mimeType.includes('av01');
429+ const bIsAV1 = b.mimeType && b.mimeType.includes('av01');
430+ const aIsVP9 = a.mimeType && a.mimeType.includes('vp9');
431+ const bIsVP9 = b.mimeType && b.mimeType.includes('vp9');
432+
433+ // For resolutions <= 1080p, prioritize AV1
434+ if (Math.max(aHeight, bHeight) <= 1080) {
435+ if (aIsAV1 && !bIsAV1) return -1;
436+ if (!aIsAV1 && bIsAV1) return 1;
437+ }
438+ // For resolutions > 1080p, prioritize VP9
439+ else {
440+ if (aIsVP9 && !bIsVP9) return -1;
441+ if (!aIsVP9 && bIsVP9) return 1;
442+ }
443+
444+ return 0;
445+ });
446+
447+ console.log('[dumber-codec] YouTube: Reordered formats for optimal codec selection');
448+ }
449+
450+ return new Response(JSON.stringify(data), {
451+ status: response.status,
452+ statusText: response.statusText,
453+ headers: response.headers
454+ });
455+ } catch (e) {
456+ console.warn('[dumber-codec] YouTube: Failed to process response:', e);
457+ return new Response(text, {
458+ status: response.status,
459+ statusText: response.statusText,
460+ headers: response.headers
461+ });
462+ }
463+ });
464+ });
465+ }
466+
467+ return originalFetch.call(this, input, init);
468+ };
469+ }
470+
471+ // Twitch-specific codec control
472+ if (location.hostname.includes('twitch.tv')) {
473+ console.log('[dumber-codec] Applying Twitch codec preferences');
474+
475+ // Intercept Twitch Enhanced Broadcasting API calls
476+ const originalFetch = window.fetch;
477+ window.fetch = function(input, init) {
478+ const url = typeof input === 'string' ? input : input.url;
479+
480+ // Intercept Enhanced Broadcasting API calls
481+ if (url && url.includes('/api/channel/hls') || url.includes('/enhanced_broadcasting')) {
482+ console.log('[dumber-codec] Twitch: Intercepting Enhanced Broadcasting API call');
483+
484+ // Modify request to prefer AV1 if available
485+ if (init && init.body) {
486+ try {
487+ const body = JSON.parse(init.body);
488+ if (body.supported_codecs) {`)
489+
490+ if prefs.ForceAV1 || hasPreferredCodecs {
491+ js.WriteString(`
492+ // Reorder supported codecs to prioritize AV1
493+ const preferredOrder = ['av01', 'avc1', 'vp9', 'vp8'];
494+ body.supported_codecs.sort((a, b) => {
495+ const aIdx = preferredOrder.indexOf(a.toLowerCase()) !== -1 ?
496+ preferredOrder.indexOf(a.toLowerCase()) : 999;
497+ const bIdx = preferredOrder.indexOf(b.toLowerCase()) !== -1 ?
498+ preferredOrder.indexOf(b.toLowerCase()) : 999;
499+ return aIdx - bIdx;
500+ });
501+ console.log('[dumber-codec] Twitch: Reordered supported codecs:', body.supported_codecs);`)
502+ }
503+
504+ js.WriteString(`
505+ }
506+ init.body = JSON.stringify(body);
507+ } catch (e) {
508+ // Non-JSON body, ignore
509+ }
510+ }
511+ }
512+
513+ // Intercept HLS manifest requests for codec modification
514+ if (url && (url.includes('.m3u8') || url.includes('/playlist/'))) {
515+ return originalFetch.call(this, input, init).then(response => {
516+ if (response.ok && response.headers.get('content-type')?.includes('application/vnd.apple.mpegurl')) {
517+ return response.text().then(text => {`)
518+
519+ if hasBlockedCodecs {
520+ js.WriteString(`
521+ // Remove blocked codecs from HLS manifest
522+ const blockedCodecs = ` + preferredCodecsJS + `.join('|');
523+ const blockedRegex = new RegExp('CODECS="[^"]*(' + blockedCodecs + ')[^"]*"', 'gi');
524+ text = text.replace(blockedRegex, '');`)
525+ }
526+
527+ js.WriteString(`
528+
529+ console.log('[dumber-codec] Twitch: Modified HLS manifest');
530+ return new Response(text, {
531+ status: response.status,
532+ statusText: response.statusText,
533+ headers: response.headers
534+ });
535+ });
536+ }
537+ return response;
538+ });
539+ }
540+
541+ return originalFetch.call(this, input, init);
542+ };
543+
544+ // Override Twitch player codec selection
545+ const originalDefineProperty = Object.defineProperty;
546+ Object.defineProperty = function(obj, prop, descriptor) {
547+ // Intercept video codec settings
548+ if (prop === 'videoCodec' && descriptor.value) {`)
549+
550+ if prefs.ForceAV1 && hasPreferredCodecs {
551+ js.WriteString(`
552+ if (true) {
553+ descriptor.value = 'av01';
554+ console.log('[dumber-codec] Twitch: Forced AV1 codec');
555+ }`)
556+ } else if hasPreferredCodecs && firstPreferredCodec != "" {
557+ twitchCodec := firstPreferredCodec
558+ switch strings.ToLower(firstPreferredCodec) {
559+ case "av1", "av01":
560+ twitchCodec = "av01"
561+ case "h264", "avc1":
562+ twitchCodec = "avc1"
563+ default:
564+ twitchCodec = firstPreferredCodec
565+ }
566+ js.WriteString(`
567+ else if (true && "` + firstPreferredCodec + `" !== "") {
568+ const twitchCodec = "` + twitchCodec + `";
569+ descriptor.value = twitchCodec;
570+ console.log('[dumber-codec] Twitch: Set preferred codec:', twitchCodec);
571+ }`)
572+ }
573+
574+ js.WriteString(`
575+ }
576+
577+ return originalDefineProperty.call(this, obj, prop, descriptor);
578+ };
579+ }
580+
581+ // Passive video monitoring without interfering with element creation
582+ const monitorExistingVideos = () => {
583+ const videos = document.querySelectorAll('video');
584+ videos.forEach(video => {
585+ if (!video.__codecMonitored) {
586+ video.__codecMonitored = true;
587+ console.log('[dumber-codec] Found video element, adding passive monitoring');
588+
589+ // Passive codec detection only
590+ video.addEventListener('canplay', function() {
591+ console.log('[dumber-codec] Video can play - resolution:', this.videoWidth + 'x' + this.videoHeight);
592+ }, { passive: true, once: true });
593+ }
594+ });
595+ };
596+
597+ // Monitor for video elements periodically without overriding createElement
598+ const videoObserver = new MutationObserver(monitorExistingVideos);
599+ videoObserver.observe(document.body, { childList: true, subtree: true });
600+ monitorExistingVideos(); // Check existing videos
601+
602+ console.log('[dumber-codec] Codec control initialization complete');
603+})();`)
604+
605+ return js.String()
606+}
607+
608+// convertConfigCodecsToWebKitCodecs converts config codec preferences to WebKit format
609+func convertConfigCodecsToWebKitCodecs(codecPrefs interface{}) CodecPreferencesConfig {
610+ // This function will be called from the main browser service to convert
611+ // the config.CodecConfig to webkit.CodecPreferencesConfig
612+
613+ // We'll need to import the config package for this conversion
614+ // For now, return empty config and implement the conversion later
615+ return CodecPreferencesConfig{}
616+}
@@ -0,0 +1,616 @@
1+package webkit
2+
3+import (
4+ "strings"
5+)
6+
7+// GenerateCodecControlScript creates JavaScript code to control codec preferences
8+// This script overrides MediaCapabilities API and platform-specific player configurations
9+func GenerateCodecControlScript(prefs CodecPreferencesConfig) string {
10+ // Build blocked codecs regex pattern
11+ var blockedCodecs []string
12+ for _, codec := range prefs.BlockedCodecs {
13+ switch strings.ToLower(codec) {
14+ case "vp9":
15+ blockedCodecs = append(blockedCodecs, "vp9", "vp09")
16+ case "vp8":
17+ blockedCodecs = append(blockedCodecs, "vp8", "vp08")
18+ case "h264":
19+ blockedCodecs = append(blockedCodecs, "h264", "avc1")
20+ case "h265", "hevc":
21+ blockedCodecs = append(blockedCodecs, "h265", "hevc", "hev1", "hvc1")
22+ default:
23+ blockedCodecs = append(blockedCodecs, strings.ToLower(codec))
24+ }
25+ }
26+
27+ // Build preferred codecs list
28+ var preferredCodecs []string
29+ for _, codec := range prefs.PreferredCodecs {
30+ switch strings.ToLower(codec) {
31+ case "av1":
32+ preferredCodecs = append(preferredCodecs, "av01", "av1")
33+ case "h264":
34+ preferredCodecs = append(preferredCodecs, "h264", "avc1")
35+ case "vp9":
36+ preferredCodecs = append(preferredCodecs, "vp9", "vp09")
37+ case "vp8":
38+ preferredCodecs = append(preferredCodecs, "vp8", "vp08")
39+ default:
40+ preferredCodecs = append(preferredCodecs, strings.ToLower(codec))
41+ }
42+ }
43+
44+ blockedPattern := strings.Join(blockedCodecs, "|")
45+ hasBlockedCodecs := len(blockedCodecs) > 0
46+ hasPreferredCodecs := len(preferredCodecs) > 0
47+ firstPreferredCodec := ""
48+ if len(preferredCodecs) > 0 {
49+ firstPreferredCodec = preferredCodecs[0]
50+ }
51+
52+ // Convert preferredCodecs slice to proper JavaScript array syntax
53+ preferredCodecsJS := "[" + strings.Join(func() []string {
54+ var quoted []string
55+ for _, codec := range preferredCodecs {
56+ quoted = append(quoted, `"`+codec+`"`)
57+ }
58+ return quoted
59+ }(), ", ") + "]"
60+
61+ // Build JavaScript directly without format specifiers
62+ var js strings.Builder
63+
64+ js.WriteString(`(() => {
65+ 'use strict';
66+
67+ console.log('[dumber-codec] Initializing codec control');
68+
69+ // Smart fullscreen handling that works with WebKit's native behavior
70+ const videoFullscreenStates = new WeakMap();
71+ let fullscreenTransitionInProgress = false;
72+
73+ // Minimal state tracking - only what's essential
74+ function trackVideoForFullscreen(video) {
75+ if (!video || video.tagName !== 'VIDEO') return;
76+
77+ // Only track if video has meaningful content
78+ if (video.readyState < 2 || !video.duration) return;
79+
80+ const state = {
81+ wasPlaying: !video.paused && !video.ended,
82+ currentTime: video.currentTime,
83+ playbackRate: video.playbackRate
84+ };
85+
86+ videoFullscreenStates.set(video, state);
87+ console.log('[dumber-codec] Tracking video for fullscreen - playing:', state.wasPlaying, 'time:', state.currentTime);
88+ }
89+
90+ // Gentle recovery without forcing reloads
91+ function recoverVideoAfterFullscreen(video) {
92+ const state = videoFullscreenStates.get(video);
93+ if (!state) return;
94+
95+ console.log('[dumber-codec] Recovering video after fullscreen transition');
96+
97+ // Only intervene if video is stuck in a loading state
98+ const isStuck = video.readyState < 2 && video.networkState === 2; // NETWORK_LOADING but not ready
99+ const hasLostTime = Math.abs(video.currentTime - state.currentTime) > 2;
100+
101+ if (isStuck || hasLostTime) {
102+ console.log('[dumber-codec] Video appears stuck, applying gentle recovery');
103+
104+ // Gentle nudge - just try to resume if it was playing
105+ if (state.wasPlaying && video.paused) {
106+ setTimeout(() => {
107+ if (video.readyState >= 2) {
108+ video.play().catch(e => console.log('[dumber-codec] Play failed:', e));
109+ }
110+ }, 200);
111+ }
112+ }
113+
114+ videoFullscreenStates.delete(video);
115+ }
116+
117+ // WebKit-aware fullscreen change detection
118+ function handleWebKitFullscreenTransition() {
119+ if (fullscreenTransitionInProgress) return;
120+ fullscreenTransitionInProgress = true;
121+
122+ console.log('[dumber-codec] WebKit fullscreen transition detected');
123+
124+ const videos = document.querySelectorAll('video');
125+ const isEntering = !!(document.fullscreenElement || document.webkitFullscreenElement);
126+
127+ videos.forEach(video => {
128+ if (isEntering) {
129+ trackVideoForFullscreen(video);
130+ } else {
131+ // Give WebKit time to complete its native fullscreen handling
132+ setTimeout(() => recoverVideoAfterFullscreen(video), 500);
133+ }
134+ });
135+
136+ setTimeout(() => {
137+ fullscreenTransitionInProgress = false;
138+ }, 1000);
139+ }
140+
141+ // Use passive listeners to avoid interfering with WebKit's native handling
142+ document.addEventListener('fullscreenchange', handleWebKitFullscreenTransition, { passive: true });
143+ document.addEventListener('webkitfullscreenchange', handleWebKitFullscreenTransition, { passive: true });
144+
145+ // Override canPlayType for HTMLMediaElement (primary codec detection)
146+ const originalCanPlayType = HTMLMediaElement.prototype.canPlayType;
147+ HTMLMediaElement.prototype.canPlayType = function(type) {
148+ console.log('[dumber-codec] canPlayType called with:', type);
149+
150+ // Block unwanted codecs`)
151+
152+ if hasBlockedCodecs {
153+ js.WriteString(`
154+ const blockedRegex = /` + blockedPattern + `/i;
155+ if (blockedRegex.test(type)) {
156+ console.log('[dumber-codec] Blocking codec via canPlayType:', type);
157+ return '';
158+ }`)
159+ }
160+
161+ if hasPreferredCodecs {
162+ js.WriteString(`
163+
164+ // Boost preferred codecs
165+ const preferredCodecs = ` + preferredCodecsJS + `;
166+ for (const preferred of preferredCodecs) {
167+ if (type.toLowerCase().includes(preferred)) {
168+ console.log('[dumber-codec] Boosting preferred codec via canPlayType:', type);
169+ return 'probably';
170+ }
171+ }`)
172+ }
173+
174+ js.WriteString(`
175+
176+ // Call original for other codecs
177+ return originalCanPlayType.call(this, type);
178+ };
179+
180+ // Override MediaSource.isTypeSupported for more aggressive codec blocking
181+ if (window.MediaSource && MediaSource.isTypeSupported) {
182+ const originalIsTypeSupported = MediaSource.isTypeSupported.bind(MediaSource);
183+ MediaSource.isTypeSupported = function(type) {
184+ console.log('[dumber-codec] MediaSource.isTypeSupported called with:', type);`)
185+
186+ if hasBlockedCodecs {
187+ js.WriteString(`
188+
189+ // Block unwanted codecs
190+ const blockedRegex = /` + blockedPattern + `/i;
191+ if (blockedRegex.test(type)) {
192+ console.log('[dumber-codec] Blocking codec via MediaSource.isTypeSupported:', type);
193+ return false;
194+ }`)
195+ }
196+
197+ if hasPreferredCodecs {
198+ js.WriteString(`
199+
200+ // Boost preferred codecs
201+ const preferredCodecs = ` + preferredCodecsJS + `;
202+ for (const preferred of preferredCodecs) {
203+ if (type.toLowerCase().includes(preferred)) {
204+ console.log('[dumber-codec] Boosting preferred codec via MediaSource.isTypeSupported:', type);
205+ return true;
206+ }
207+ }`)
208+ }
209+
210+ js.WriteString(`
211+
212+ // Call original for other codecs
213+ return originalIsTypeSupported(type);
214+ };
215+ }
216+
217+ // Override MediaCapabilities API
218+ if (navigator.mediaCapabilities && navigator.mediaCapabilities.decodingInfo) {
219+ const originalDecodingInfo = navigator.mediaCapabilities.decodingInfo.bind(navigator.mediaCapabilities);
220+
221+ navigator.mediaCapabilities.decodingInfo = async function(config) {
222+ const contentType = config.video?.contentType || config.type || '';
223+
224+ // Parse resolution from video config for resolution-aware decisions
225+ let width = 0, height = 0;
226+ if (config.video) {
227+ width = config.video.width || 0;
228+ height = config.video.height || 0;
229+ }
230+
231+ // Determine if this is a high resolution request
232+ const isHighRes = height >= 1440 || width >= 2560; // 1440p/1080p ultrawide+
233+ const is4K = height >= 2160 || width >= 3840;
234+ const is1080p60 = (height >= 1080 || width >= 1920) &&
235+ (config.video?.framerate >= 50 || config.video?.frameRate >= 50);
236+
237+ console.log('[dumber-codec] MediaCapabilities query - contentType:', contentType,
238+ 'resolution:', width + 'x' + height, 'highRes:', isHighRes, '4K:', is4K, '1080p60:', is1080p60);
239+
240+ // Block unwanted codecs`)
241+
242+ if hasBlockedCodecs {
243+ js.WriteString(`
244+ const blockedRegex = /` + blockedPattern + `/i;
245+ if (blockedRegex.test(contentType)) {
246+ console.log('[dumber-codec] Blocking codec:', contentType);
247+ return {
248+ supported: false,
249+ smooth: false,
250+ powerEfficient: false
251+ };
252+ }`)
253+ }
254+
255+ js.WriteString(`
256+
257+ // Resolution-aware preferred codec handling`)
258+
259+ if hasPreferredCodecs {
260+ js.WriteString(`
261+ const preferredCodecs = ` + preferredCodecsJS + `;
262+ for (const preferred of preferredCodecs) {
263+ if (contentType.toLowerCase().includes(preferred)) {
264+ console.log('[dumber-codec] Boosting preferred codec:', contentType);
265+
266+ // Resolution-aware capability reporting for AV1
267+ if (preferred === 'av01' || preferred === 'av1') {
268+ // AV1 handling based on resolution
269+ if (is4K) {
270+ // 4K AV1: supported but not smooth (encourage VP9 fallback)
271+ return {
272+ supported: true,
273+ smooth: false,
274+ powerEfficient: false
275+ };
276+ } else if (is1080p60) {
277+ // 1080p60 AV1: supported but not power efficient
278+ return {
279+ supported: true,
280+ smooth: true,
281+ powerEfficient: false
282+ };
283+ } else if (isHighRes) {
284+ // 1440p AV1: supported but not optimal
285+ return {
286+ supported: true,
287+ smooth: false,
288+ powerEfficient: true
289+ };
290+ } else {
291+ // <= 1080p30 AV1: fully supported
292+ return {
293+ supported: true,
294+ smooth: true,
295+ powerEfficient: true
296+ };
297+ }
298+ } else {
299+ // Other preferred codecs (VP9, H264) - always report as capable
300+ return {
301+ supported: true,
302+ smooth: true,
303+ powerEfficient: true
304+ };
305+ }
306+ }
307+ }`)
308+ }
309+
310+ js.WriteString(`
311+
312+ // Call original for other codecs
313+ try {
314+ return await originalDecodingInfo(config);
315+ } catch (e) {
316+ console.warn('[dumber-codec] MediaCapabilities error:', e);
317+ return {
318+ supported: false,
319+ smooth: false,
320+ powerEfficient: false
321+ };
322+ }
323+ };
324+ }
325+
326+ // YouTube-specific codec forcing
327+ if (location.hostname.includes('youtube.com')) {
328+ console.log('[dumber-codec] Applying YouTube codec preferences');
329+
330+ // Set YouTube's localStorage preferences for smart AV1 usage
331+ // 2048 = prefer AV1 for lower resolutions, fallback to VP9 for higher res
332+ // This allows YouTube to make smarter codec decisions based on our MediaCapabilities
333+ try {
334+ localStorage.setItem('yt-player-av1-pref', '2048');
335+ Object.defineProperty(localStorage, 'yt-player-av1-pref', {
336+ value: '2048',
337+ writable: false,
338+ configurable: false
339+ });
340+ console.log('[dumber-codec] YouTube: Set smart AV1 localStorage preference (2048)');
341+ } catch (e) {
342+ console.warn('[dumber-codec] YouTube: Failed to set localStorage:', e);
343+ }
344+
345+ // Override ytInitialPlayerResponse parsing
346+ Object.defineProperty(window, 'ytplayer', {
347+ configurable: true,
348+ get() {
349+ return this._ytplayer || null;
350+ },
351+ set(player) {
352+ if (player && player.config && player.config.args) {`)
353+
354+ if prefs.ForceAV1 {
355+ js.WriteString(`
356+ // Force AV1 if enabled
357+ player.config.args.preferred_codecs = 'av01';
358+ console.log('[dumber-codec] YouTube: Forced AV1 codec');`)
359+ }
360+
361+ if hasPreferredCodecs {
362+ js.WriteString(`
363+ // Set codec preference order
364+ const preferredCodecs = ` + preferredCodecsJS + `;
365+ const codecOrder = preferredCodecs.map(c => {
366+ switch(c.toLowerCase()) {
367+ case 'av1': case 'av01': return 'av01';
368+ case 'h264': case 'avc1': return 'avc1';
369+ case 'vp9': case 'vp09': return 'vp9';
370+ case 'vp8': case 'vp08': return 'vp8';
371+ default: return c;
372+ }
373+ }).join(',');
374+ player.config.args.preferred_codecs = codecOrder;
375+ console.log('[dumber-codec] YouTube: Set codec order:', codecOrder);`)
376+ }
377+
378+ js.WriteString(`
379+ }
380+ this._ytplayer = player;
381+ }
382+ });
383+
384+ // Intercept YouTube's format selection
385+ const originalFetch = window.fetch;
386+ window.fetch = function(input, init) {
387+ if (typeof input === 'string' && input.includes('/youtubei/v1/player')) {
388+ console.log('[dumber-codec] YouTube: Intercepting player request');`)
389+
390+ if hasPreferredCodecs {
391+ js.WriteString(`
392+
393+ // Modify request to prefer our codecs
394+ if (init && init.body) {
395+ try {
396+ const body = JSON.parse(init.body);
397+ if (body.videoId) {
398+ // Add codec preferences to the request
399+ body.codecPreferences = ` + preferredCodecsJS + `;
400+ init.body = JSON.stringify(body);
401+ }
402+ } catch (e) {
403+ // Non-JSON body, ignore
404+ }
405+ }`)
406+ }
407+
408+ js.WriteString(`
409+ }
410+
411+ // Process YouTube player API responses for format manipulation
412+ if (typeof input === 'string' && input.includes('/youtubei/v1/player')) {
413+ return originalFetch.call(this, input, init).then(response => {
414+ if (!response.ok) return response;
415+
416+ return response.text().then(text => {
417+ try {
418+ const data = JSON.parse(text);
419+
420+ // Manipulate streaming data if present
421+ if (data.streamingData && data.streamingData.formats) {
422+ console.log('[dumber-codec] YouTube: Processing format manifest');
423+
424+ // Sort formats to prioritize AV1 for lower resolutions, VP9 for higher
425+ data.streamingData.formats.sort((a, b) => {
426+ const aHeight = a.height || 0;
427+ const bHeight = b.height || 0;
428+ const aIsAV1 = a.mimeType && a.mimeType.includes('av01');
429+ const bIsAV1 = b.mimeType && b.mimeType.includes('av01');
430+ const aIsVP9 = a.mimeType && a.mimeType.includes('vp9');
431+ const bIsVP9 = b.mimeType && b.mimeType.includes('vp9');
432+
433+ // For resolutions <= 1080p, prioritize AV1
434+ if (Math.max(aHeight, bHeight) <= 1080) {
435+ if (aIsAV1 && !bIsAV1) return -1;
436+ if (!aIsAV1 && bIsAV1) return 1;
437+ }
438+ // For resolutions > 1080p, prioritize VP9
439+ else {
440+ if (aIsVP9 && !bIsVP9) return -1;
441+ if (!aIsVP9 && bIsVP9) return 1;
442+ }
443+
444+ return 0;
445+ });
446+
447+ console.log('[dumber-codec] YouTube: Reordered formats for optimal codec selection');
448+ }
449+
450+ return new Response(JSON.stringify(data), {
451+ status: response.status,
452+ statusText: response.statusText,
453+ headers: response.headers
454+ });
455+ } catch (e) {
456+ console.warn('[dumber-codec] YouTube: Failed to process response:', e);
457+ return new Response(text, {
458+ status: response.status,
459+ statusText: response.statusText,
460+ headers: response.headers
461+ });
462+ }
463+ });
464+ });
465+ }
466+
467+ return originalFetch.call(this, input, init);
468+ };
469+ }
470+
471+ // Twitch-specific codec control
472+ if (location.hostname.includes('twitch.tv')) {
473+ console.log('[dumber-codec] Applying Twitch codec preferences');
474+
475+ // Intercept Twitch Enhanced Broadcasting API calls
476+ const originalFetch = window.fetch;
477+ window.fetch = function(input, init) {
478+ const url = typeof input === 'string' ? input : input.url;
479+
480+ // Intercept Enhanced Broadcasting API calls
481+ if (url && url.includes('/api/channel/hls') || url.includes('/enhanced_broadcasting')) {
482+ console.log('[dumber-codec] Twitch: Intercepting Enhanced Broadcasting API call');
483+
484+ // Modify request to prefer AV1 if available
485+ if (init && init.body) {
486+ try {
487+ const body = JSON.parse(init.body);
488+ if (body.supported_codecs) {`)
489+
490+ if prefs.ForceAV1 || hasPreferredCodecs {
491+ js.WriteString(`
492+ // Reorder supported codecs to prioritize AV1
493+ const preferredOrder = ['av01', 'avc1', 'vp9', 'vp8'];
494+ body.supported_codecs.sort((a, b) => {
495+ const aIdx = preferredOrder.indexOf(a.toLowerCase()) !== -1 ?
496+ preferredOrder.indexOf(a.toLowerCase()) : 999;
497+ const bIdx = preferredOrder.indexOf(b.toLowerCase()) !== -1 ?
498+ preferredOrder.indexOf(b.toLowerCase()) : 999;
499+ return aIdx - bIdx;
500+ });
501+ console.log('[dumber-codec] Twitch: Reordered supported codecs:', body.supported_codecs);`)
502+ }
503+
504+ js.WriteString(`
505+ }
506+ init.body = JSON.stringify(body);
507+ } catch (e) {
508+ // Non-JSON body, ignore
509+ }
510+ }
511+ }
512+
513+ // Intercept HLS manifest requests for codec modification
514+ if (url && (url.includes('.m3u8') || url.includes('/playlist/'))) {
515+ return originalFetch.call(this, input, init).then(response => {
516+ if (response.ok && response.headers.get('content-type')?.includes('application/vnd.apple.mpegurl')) {
517+ return response.text().then(text => {`)
518+
519+ if hasBlockedCodecs {
520+ js.WriteString(`
521+ // Remove blocked codecs from HLS manifest
522+ const blockedCodecs = ` + preferredCodecsJS + `.join('|');
523+ const blockedRegex = new RegExp('CODECS="[^"]*(' + blockedCodecs + ')[^"]*"', 'gi');
524+ text = text.replace(blockedRegex, '');`)
525+ }
526+
527+ js.WriteString(`
528+
529+ console.log('[dumber-codec] Twitch: Modified HLS manifest');
530+ return new Response(text, {
531+ status: response.status,
532+ statusText: response.statusText,
533+ headers: response.headers
534+ });
535+ });
536+ }
537+ return response;
538+ });
539+ }
540+
541+ return originalFetch.call(this, input, init);
542+ };
543+
544+ // Override Twitch player codec selection
545+ const originalDefineProperty = Object.defineProperty;
546+ Object.defineProperty = function(obj, prop, descriptor) {
547+ // Intercept video codec settings
548+ if (prop === 'videoCodec' && descriptor.value) {`)
549+
550+ if prefs.ForceAV1 && hasPreferredCodecs {
551+ js.WriteString(`
552+ if (true) {
553+ descriptor.value = 'av01';
554+ console.log('[dumber-codec] Twitch: Forced AV1 codec');
555+ }`)
556+ } else if hasPreferredCodecs && firstPreferredCodec != "" {
557+ twitchCodec := firstPreferredCodec
558+ switch strings.ToLower(firstPreferredCodec) {
559+ case "av1", "av01":
560+ twitchCodec = "av01"
561+ case "h264", "avc1":
562+ twitchCodec = "avc1"
563+ default:
564+ twitchCodec = firstPreferredCodec
565+ }
566+ js.WriteString(`
567+ else if (true && "` + firstPreferredCodec + `" !== "") {
568+ const twitchCodec = "` + twitchCodec + `";
569+ descriptor.value = twitchCodec;
570+ console.log('[dumber-codec] Twitch: Set preferred codec:', twitchCodec);
571+ }`)
572+ }
573+
574+ js.WriteString(`
575+ }
576+
577+ return originalDefineProperty.call(this, obj, prop, descriptor);
578+ };
579+ }
580+
581+ // Passive video monitoring without interfering with element creation
582+ const monitorExistingVideos = () => {
583+ const videos = document.querySelectorAll('video');
584+ videos.forEach(video => {
585+ if (!video.__codecMonitored) {
586+ video.__codecMonitored = true;
587+ console.log('[dumber-codec] Found video element, adding passive monitoring');
588+
589+ // Passive codec detection only
590+ video.addEventListener('canplay', function() {
591+ console.log('[dumber-codec] Video can play - resolution:', this.videoWidth + 'x' + this.videoHeight);
592+ }, { passive: true, once: true });
593+ }
594+ });
595+ };
596+
597+ // Monitor for video elements periodically without overriding createElement
598+ const videoObserver = new MutationObserver(monitorExistingVideos);
599+ videoObserver.observe(document.body, { childList: true, subtree: true });
600+ monitorExistingVideos(); // Check existing videos
601+
602+ console.log('[dumber-codec] Codec control initialization complete');
603+})();`)
604+
605+ return js.String()
606+}
607+
608+// convertConfigCodecsToWebKitCodecs converts config codec preferences to WebKit format
609+func convertConfigCodecsToWebKitCodecs(codecPrefs interface{}) CodecPreferencesConfig {
610+ // This function will be called from the main browser service to convert
611+ // the config.CodecConfig to webkit.CodecPreferencesConfig
612+
613+ // We'll need to import the config package for this conversion
614+ // For now, return empty config and implement the conversion later
615+ return CodecPreferencesConfig{}
616+}
M pkg/webkit/log_capture.go +14 -14
@@ -21,7 +21,7 @@ var webkitCaptureChan chan struct{}
2121 func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) {
2222 goDomain := C.GoString(domain)
2323 goMessage := C.GoString(message)
24-
24+
2525 var levelStr string
2626 switch level {
2727 case C.G_LOG_LEVEL_ERROR:
@@ -39,12 +39,12 @@ func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) {
3939 default:
4040 levelStr = "UNKNOWN"
4141 }
42-
42+
4343 logMessage := goMessage
4444 if goDomain != "" {
4545 logMessage = "[" + goDomain + "] " + goMessage
4646 }
47-
47+
4848 // Send to logging system with appropriate level
4949 if logger := logging.GetLogger(); logger != nil {
5050 logger.WriteTagged("WEBKIT-"+levelStr, logMessage)
@@ -56,19 +56,19 @@ func InitWebKitLogCapture() error {
5656 if webkitCaptureActive {
5757 return nil
5858 }
59-
59+
6060 // Initialize C-level log capture
6161 C.webkit_init_log_capture()
62-
62+
6363 // Set up GLib log handlers
6464 C.webkit_setup_glib_log_handlers()
65-
65+
6666 // Create channel for stopping capture
6767 webkitCaptureChan = make(chan struct{})
68-
68+
6969 // Start goroutine to read captured C output
7070 go webkitLogReader()
71-
71+
7272 webkitCaptureActive = true
7373 return nil
7474 }
@@ -78,20 +78,20 @@ func StopWebKitLogCapture() {
7878 if !webkitCaptureActive {
7979 return
8080 }
81-
81+
8282 // Signal the reader goroutine to stop
8383 close(webkitCaptureChan)
84-
84+
8585 // Stop C-level capture
8686 C.webkit_stop_log_capture()
87-
87+
8888 webkitCaptureActive = false
8989 }
9090
9191 // webkitLogReader reads captured C output in a goroutine
9292 func webkitLogReader() {
9393 buffer := make([]byte, 4096)
94-
94+
9595 for {
9696 select {
9797 case <-webkitCaptureChan:
@@ -105,7 +105,7 @@ func webkitLogReader() {
105105 logger.WriteTagged("WEBKIT-PRINTF", message)
106106 }
107107 }
108-
108+
109109 // Small delay to prevent busy waiting
110110 time.Sleep(10 * time.Millisecond)
111111 }
@@ -122,4 +122,4 @@ func StartWebKitOutputCapture() {
122122 // IsWebKitLogCaptureActive returns whether WebKit log capture is active
123123 func IsWebKitLogCaptureActive() bool {
124124 return webkitCaptureActive
125-}⏎̸
125+}
@@ -21,7 +21,7 @@ var webkitCaptureChan chan struct{}
21 func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) {21 func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) {
22 goDomain := C.GoString(domain)22 goDomain := C.GoString(domain)
23 goMessage := C.GoString(message)23 goMessage := C.GoString(message)
24- 24+
25 var levelStr string25 var levelStr string
26 switch level {26 switch level {
27 case C.G_LOG_LEVEL_ERROR:27 case C.G_LOG_LEVEL_ERROR:
@@ -39,12 +39,12 @@ func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) {
39 default:39 default:
40 levelStr = "UNKNOWN"40 levelStr = "UNKNOWN"
41 }41 }
42- 42+
43 logMessage := goMessage43 logMessage := goMessage
44 if goDomain != "" {44 if goDomain != "" {
45 logMessage = "[" + goDomain + "] " + goMessage45 logMessage = "[" + goDomain + "] " + goMessage
46 }46 }
47- 47+
48 // Send to logging system with appropriate level48 // Send to logging system with appropriate level
49 if logger := logging.GetLogger(); logger != nil {49 if logger := logging.GetLogger(); logger != nil {
50 logger.WriteTagged("WEBKIT-"+levelStr, logMessage)50 logger.WriteTagged("WEBKIT-"+levelStr, logMessage)
@@ -56,19 +56,19 @@ func InitWebKitLogCapture() error {
56 if webkitCaptureActive {56 if webkitCaptureActive {
57 return nil57 return nil
58 }58 }
59- 59+
60 // Initialize C-level log capture60 // Initialize C-level log capture
61 C.webkit_init_log_capture()61 C.webkit_init_log_capture()
62- 62+
63 // Set up GLib log handlers63 // Set up GLib log handlers
64 C.webkit_setup_glib_log_handlers()64 C.webkit_setup_glib_log_handlers()
65- 65+
66 // Create channel for stopping capture66 // Create channel for stopping capture
67 webkitCaptureChan = make(chan struct{})67 webkitCaptureChan = make(chan struct{})
68- 68+
69 // Start goroutine to read captured C output69 // Start goroutine to read captured C output
70 go webkitLogReader()70 go webkitLogReader()
71- 71+
72 webkitCaptureActive = true72 webkitCaptureActive = true
73 return nil73 return nil
74 }74 }
@@ -78,20 +78,20 @@ func StopWebKitLogCapture() {
78 if !webkitCaptureActive {78 if !webkitCaptureActive {
79 return79 return
80 }80 }
81- 81+
82 // Signal the reader goroutine to stop82 // Signal the reader goroutine to stop
83 close(webkitCaptureChan)83 close(webkitCaptureChan)
84- 84+
85 // Stop C-level capture85 // Stop C-level capture
86 C.webkit_stop_log_capture()86 C.webkit_stop_log_capture()
87- 87+
88 webkitCaptureActive = false88 webkitCaptureActive = false
89 }89 }
90 90
91 // webkitLogReader reads captured C output in a goroutine91 // webkitLogReader reads captured C output in a goroutine
92 func webkitLogReader() {92 func webkitLogReader() {
93 buffer := make([]byte, 4096)93 buffer := make([]byte, 4096)
94- 94+
95 for {95 for {
96 select {96 select {
97 case <-webkitCaptureChan:97 case <-webkitCaptureChan:
@@ -105,7 +105,7 @@ func webkitLogReader() {
105 logger.WriteTagged("WEBKIT-PRINTF", message)105 logger.WriteTagged("WEBKIT-PRINTF", message)
106 }106 }
107 }107 }
108- 108+
109 // Small delay to prevent busy waiting109 // Small delay to prevent busy waiting
110 time.Sleep(10 * time.Millisecond)110 time.Sleep(10 * time.Millisecond)
111 }111 }
@@ -122,4 +122,4 @@ func StartWebKitOutputCapture() {
122 // IsWebKitLogCaptureActive returns whether WebKit log capture is active122 // IsWebKitLogCaptureActive returns whether WebKit log capture is active
123 func IsWebKitLogCaptureActive() bool {123 func IsWebKitLogCaptureActive() bool {
124 return webkitCaptureActive124 return webkitCaptureActive
125-}⏎̸125+}
M pkg/webkit/log_capture_stub.go +1 -1
@@ -21,4 +21,4 @@ func StartWebKitOutputCapture() {
2121 // IsWebKitLogCaptureActive returns whether WebKit log capture is active (stub version)
2222 func IsWebKitLogCaptureActive() bool {
2323 return false
24-}⏎̸
24+}
@@ -21,4 +21,4 @@ func StartWebKitOutputCapture() {
21 // IsWebKitLogCaptureActive returns whether WebKit log capture is active (stub version)21 // IsWebKitLogCaptureActive returns whether WebKit log capture is active (stub version)
22 func IsWebKitLogCaptureActive() bool {22 func IsWebKitLogCaptureActive() bool {
23 return false23 return false
24-}⏎̸24+}
M pkg/webkit/settings.go +11 -0
@@ -34,6 +34,17 @@ type Config struct {
3434
3535 // Memory controls memory optimization settings
3636 Memory MemoryConfig
37+
38+ // CodecPreferences for media playback
39+ CodecPreferences CodecPreferencesConfig
40+}
41+
42+// CodecPreferencesConfig holds codec preferences for WebKit media playback
43+type CodecPreferencesConfig struct {
44+ PreferredCodecs []string
45+ BlockedCodecs []string
46+ ForceAV1 bool
47+ CustomUserAgent string
3748 }
3849
3950 // RenderingConfig controls hardware acceleration preferences.
@@ -34,6 +34,17 @@ type Config struct {
34 34
35 // Memory controls memory optimization settings35 // Memory controls memory optimization settings
36 Memory MemoryConfig36 Memory MemoryConfig
37+
38+ // CodecPreferences for media playback
39+ CodecPreferences CodecPreferencesConfig
40+}
41+
42+// CodecPreferencesConfig holds codec preferences for WebKit media playback
43+type CodecPreferencesConfig struct {
44+ PreferredCodecs []string
45+ BlockedCodecs []string
46+ ForceAV1 bool
47+ CustomUserAgent string
37 }48 }
38 49
39 // RenderingConfig controls hardware acceleration preferences.50 // RenderingConfig controls hardware acceleration preferences.
M pkg/webkit/webview_cgo.go +59 -24
@@ -40,11 +40,11 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
4040 gpointer user_data);
4141
4242 // Handle create signal for _blank links - redirect to current window
43-static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
43+static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
4444 WebKitNavigationAction* navigation_action,
4545 gpointer user_data) {
4646 (void)user_data;
47-
47+
4848 // Get the request from navigation action
4949 WebKitURIRequest* request = webkit_navigation_action_get_request(navigation_action);
5050 if (request) {
@@ -54,7 +54,7 @@ static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
5454 webkit_web_view_load_uri(web_view, uri);
5555 }
5656 }
57-
57+
5858 // Return NULL to prevent new window creation
5959 return NULL;
6060 }
@@ -78,7 +78,7 @@ static void setup_video_acceleration(const char* driver_name, int enable_all, in
7878 // Don't set other env vars in legacy mode to avoid conflicts
7979 return;
8080 }
81-
81+
8282 // Modern mode (default): use improved VA-API handling
8383 if (driver_name && strlen(driver_name) > 0) {
8484 if (strcmp(driver_name, "nvidia") == 0) {
@@ -93,7 +93,7 @@ static void setup_video_acceleration(const char* driver_name, int enable_all, in
9393 setenv("LIBVA_DRIVER_NAME", driver_name, 1);
9494 }
9595 }
96-
96+
9797 if (enable_all) {
9898 setenv("GST_VAAPI_ALL_DRIVERS", "1", 1);
9999 }
@@ -135,10 +135,10 @@ static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const c
135135 // Set WebView background to black (easier on eyes when pages are loading)
136136 GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 };
137137 webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color);
138-
138+
139139 // Handle create signal to redirect _blank links to current window
140140 g_signal_connect(G_OBJECT(w), "create", G_CALLBACK(on_create_new_window), NULL);
141-
141+
142142 // Connect TLS error handler
143143 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));
144144 if (out_ucm) { *out_ucm = u; }
@@ -412,7 +412,7 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
412412 // Extract host from URI for the Go callback
413413 const char* host = failing_uri;
414414 char* host_copy = NULL;
415-
415+
416416 if (strstr(failing_uri, "://")) {
417417 host = strstr(failing_uri, "://") + 3;
418418 char* slash = strchr(host, '/');
@@ -430,7 +430,7 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
430430
431431 // Extract certificate information
432432 char* cert_info = extract_certificate_info(certificate);
433-
433+
434434 gboolean should_proceed = goHandleTLSError((char*)failing_uri, (char*)host, (int)errors, cert_info);
435435
436436 // If user accepted, allow the certificate for this host
@@ -443,14 +443,14 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
443443 webkit_network_session_allow_tls_certificate_for_host(session, certificate, host);
444444 printf("[dumber] Certificate exception added for host: %s\n", host);
445445 fflush(stdout);
446-
446+
447447 // Try loading the URL again after adding the exception
448448 webkit_web_view_load_uri(web_view, failing_uri);
449449 printf("[dumber] Triggered new load of %s with certificate exception\n", failing_uri);
450450 fflush(stdout);
451451 }
452452 }
453-
453+
454454 // Free the certificate info string
455455 if (cert_info) {
456456 g_free(cert_info);
@@ -468,18 +468,18 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
468468 if (!certificate) {
469469 return g_strdup("Certificate information not available");
470470 }
471-
471+
472472 printf("[dumber] Extracting certificate information...\n");
473473 fflush(stdout);
474-
474+
475475 GString* info = g_string_new("Certificate Information:\n");
476-
476+
477477 // Try to get certificate properties - but these might not be available in all GTK versions
478478 gchar* subject = NULL;
479479 gchar* issuer = NULL;
480480 GDateTime* not_valid_before = NULL;
481481 GDateTime* not_valid_after = NULL;
482-
482+
483483 // Try property access but handle failures gracefully
484484 g_object_get(certificate,
485485 "subject-name", &subject,
@@ -487,7 +487,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
487487 "not-valid-before", &not_valid_before,
488488 "not-valid-after", &not_valid_after,
489489 NULL);
490-
490+
491491 if (subject) {
492492 g_string_append_printf(info, "Subject: %s\n", subject);
493493 g_free(subject);
@@ -495,7 +495,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
495495 } else {
496496 g_string_append(info, "Subject: Information not available\n");
497497 }
498-
498+
499499 if (issuer) {
500500 g_string_append_printf(info, "Issued by: %s\n", issuer);
501501 g_free(issuer);
@@ -503,7 +503,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
503503 } else {
504504 g_string_append(info, "Issued by: Information not available\n");
505505 }
506-
506+
507507 if (not_valid_before) {
508508 gchar* date_str = g_date_time_format(not_valid_before, "%Y-%m-%d %H:%M:%S UTC");
509509 g_string_append_printf(info, "Valid from: %s\n", date_str);
@@ -512,7 +512,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
512512 } else {
513513 g_string_append(info, "Valid from: Information not available\n");
514514 }
515-
515+
516516 if (not_valid_after) {
517517 gchar* date_str = g_date_time_format(not_valid_after, "%Y-%m-%d %H:%M:%S UTC");
518518 g_string_append_printf(info, "Valid until: %s\n", date_str);
@@ -521,7 +521,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
521521 } else {
522522 g_string_append(info, "Valid until: Information not available\n");
523523 }
524-
524+
525525 char* result = g_string_free(info, FALSE);
526526 printf("[dumber] Certificate info: %s\n", result);
527527 fflush(stdout);
@@ -539,7 +539,7 @@ static void on_tls_dialog_response(GtkDialog *dialog, gint response_id, gpointer
539539 TLSDialogData *data = (TLSDialogData*)user_data;
540540 data->user_accepted = (response_id == GTK_RESPONSE_ACCEPT);
541541 data->dialog_completed = TRUE;
542- printf("[dumber] TLS dialog response: %s (response_id=%d)\n",
542+ printf("[dumber] TLS dialog response: %s (response_id=%d)\n",
543543 data->user_accepted ? "ACCEPTED" : "REJECTED", response_id);
544544 fflush(stdout);
545545 gtk_window_destroy(GTK_WINDOW(dialog));
@@ -550,7 +550,7 @@ static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* host
550550 printf("[dumber] Creating TLS warning dialog for hostname: %s\n", hostname);
551551 printf("[dumber] Error message: %s\n", error_msg);
552552 fflush(stdout);
553-
553+
554554 // Create the dialog
555555 GtkWidget *dialog = gtk_message_dialog_new(
556556 parent,
@@ -904,6 +904,24 @@ func NewWebView(cfg *Config) (*WebView, error) {
904904 C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))
905905 }
906906 }
907+
908+ // Apply custom User-Agent for codec negotiation
909+ if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
910+ if cfg.CodecPreferences.CustomUserAgent != "" {
911+ cUserAgent := C.CString(cfg.CodecPreferences.CustomUserAgent)
912+ C.webkit_settings_set_user_agent(settings, (*C.gchar)(cUserAgent))
913+ C.free(unsafe.Pointer(cUserAgent))
914+ log.Printf("[webkit] Set custom User-Agent: %s", cfg.CodecPreferences.CustomUserAgent)
915+ } else if cfg.CodecPreferences.ForceAV1 {
916+ // Use modern Chrome UA that signals AV1 support
917+ av1UA := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
918+ cAV1UA := C.CString(av1UA)
919+ C.webkit_settings_set_user_agent(settings, (*C.gchar)(cAV1UA))
920+ C.free(unsafe.Pointer(cAV1UA))
921+ log.Printf("[webkit] Set AV1-optimized User-Agent")
922+ }
923+ }
924+
907925 // Apply default fonts if provided in cfg
908926 if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
909927 if cfg.DefaultSansFont != "" {
@@ -938,7 +956,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
938956
939957 // Initialize UCM scripts and handlers
940958 if v.native != nil && v.native.ucm != nil {
941- v.enableUserContentManager()
959+ v.enableUserContentManager(cfg)
942960 }
943961
944962 // Start periodic JavaScript garbage collection if configured
@@ -1112,7 +1130,7 @@ func (w *WebView) dispatchZoomChanged(level float64) {
11121130 }
11131131
11141132 // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
1115-func (w *WebView) enableUserContentManager() {
1133+func (w *WebView) enableUserContentManager(cfg *Config) {
11161134 if w == nil || w.native == nil || w.native.ucm == nil {
11171135 return
11181136 }
@@ -1175,6 +1193,23 @@ func (w *WebView) enableUserContentManager() {
11751193 C.webkit_user_script_unref(wailsScript)
11761194 }
11771195
1196+ // Inject codec control script if codec preferences are configured
1197+ if cfg != nil && (cfg.CodecPreferences.ForceAV1 || len(cfg.CodecPreferences.BlockedCodecs) > 0 || len(cfg.CodecPreferences.PreferredCodecs) > 0) {
1198+ codecScript := GenerateCodecControlScript(cfg.CodecPreferences)
1199+ cCodec := C.CString(codecScript)
1200+ defer C.free(unsafe.Pointer(cCodec))
1201+ codecUserScript := C.webkit_user_script_new(
1202+ (*C.gchar)(cCodec),
1203+ C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
1204+ C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
1205+ nil, nil)
1206+ if codecUserScript != nil {
1207+ C.webkit_user_content_manager_add_script(w.native.ucm, codecUserScript)
1208+ C.webkit_user_script_unref(codecUserScript)
1209+ log.Printf("[webkit] Codec control script injected")
1210+ }
1211+ }
1212+
11781213 // No JS fallback bridge — native UCM is active
11791214 }
11801215
@@ -40,11 +40,11 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
40 gpointer user_data);40 gpointer user_data);
41 41
42 // Handle create signal for _blank links - redirect to current window42 // Handle create signal for _blank links - redirect to current window
43-static WebKitWebView* on_create_new_window(WebKitWebView* web_view, 43+static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
44 WebKitNavigationAction* navigation_action,44 WebKitNavigationAction* navigation_action,
45 gpointer user_data) {45 gpointer user_data) {
46 (void)user_data;46 (void)user_data;
47- 47+
48 // Get the request from navigation action48 // Get the request from navigation action
49 WebKitURIRequest* request = webkit_navigation_action_get_request(navigation_action);49 WebKitURIRequest* request = webkit_navigation_action_get_request(navigation_action);
50 if (request) {50 if (request) {
@@ -54,7 +54,7 @@ static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
54 webkit_web_view_load_uri(web_view, uri);54 webkit_web_view_load_uri(web_view, uri);
55 }55 }
56 }56 }
57- 57+
58 // Return NULL to prevent new window creation58 // Return NULL to prevent new window creation
59 return NULL;59 return NULL;
60 }60 }
@@ -78,7 +78,7 @@ static void setup_video_acceleration(const char* driver_name, int enable_all, in
78 // Don't set other env vars in legacy mode to avoid conflicts78 // Don't set other env vars in legacy mode to avoid conflicts
79 return;79 return;
80 }80 }
81- 81+
82 // Modern mode (default): use improved VA-API handling82 // Modern mode (default): use improved VA-API handling
83 if (driver_name && strlen(driver_name) > 0) {83 if (driver_name && strlen(driver_name) > 0) {
84 if (strcmp(driver_name, "nvidia") == 0) {84 if (strcmp(driver_name, "nvidia") == 0) {
@@ -93,7 +93,7 @@ static void setup_video_acceleration(const char* driver_name, int enable_all, in
93 setenv("LIBVA_DRIVER_NAME", driver_name, 1);93 setenv("LIBVA_DRIVER_NAME", driver_name, 1);
94 }94 }
95 }95 }
96- 96+
97 if (enable_all) {97 if (enable_all) {
98 setenv("GST_VAAPI_ALL_DRIVERS", "1", 1);98 setenv("GST_VAAPI_ALL_DRIVERS", "1", 1);
99 }99 }
@@ -135,10 +135,10 @@ static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const c
135 // Set WebView background to black (easier on eyes when pages are loading)135 // Set WebView background to black (easier on eyes when pages are loading)
136 GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 };136 GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 };
137 webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color);137 webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color);
138- 138+
139 // Handle create signal to redirect _blank links to current window139 // Handle create signal to redirect _blank links to current window
140 g_signal_connect(G_OBJECT(w), "create", G_CALLBACK(on_create_new_window), NULL);140 g_signal_connect(G_OBJECT(w), "create", G_CALLBACK(on_create_new_window), NULL);
141- 141+
142 // Connect TLS error handler142 // Connect TLS error handler
143 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));143 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));
144 if (out_ucm) { *out_ucm = u; }144 if (out_ucm) { *out_ucm = u; }
@@ -412,7 +412,7 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
412 // Extract host from URI for the Go callback412 // Extract host from URI for the Go callback
413 const char* host = failing_uri;413 const char* host = failing_uri;
414 char* host_copy = NULL;414 char* host_copy = NULL;
415- 415+
416 if (strstr(failing_uri, "://")) {416 if (strstr(failing_uri, "://")) {
417 host = strstr(failing_uri, "://") + 3;417 host = strstr(failing_uri, "://") + 3;
418 char* slash = strchr(host, '/');418 char* slash = strchr(host, '/');
@@ -430,7 +430,7 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
430 430
431 // Extract certificate information431 // Extract certificate information
432 char* cert_info = extract_certificate_info(certificate);432 char* cert_info = extract_certificate_info(certificate);
433- 433+
434 gboolean should_proceed = goHandleTLSError((char*)failing_uri, (char*)host, (int)errors, cert_info);434 gboolean should_proceed = goHandleTLSError((char*)failing_uri, (char*)host, (int)errors, cert_info);
435 435
436 // If user accepted, allow the certificate for this host436 // If user accepted, allow the certificate for this host
@@ -443,14 +443,14 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
443 webkit_network_session_allow_tls_certificate_for_host(session, certificate, host);443 webkit_network_session_allow_tls_certificate_for_host(session, certificate, host);
444 printf("[dumber] Certificate exception added for host: %s\n", host);444 printf("[dumber] Certificate exception added for host: %s\n", host);
445 fflush(stdout);445 fflush(stdout);
446- 446+
447 // Try loading the URL again after adding the exception447 // Try loading the URL again after adding the exception
448 webkit_web_view_load_uri(web_view, failing_uri);448 webkit_web_view_load_uri(web_view, failing_uri);
449 printf("[dumber] Triggered new load of %s with certificate exception\n", failing_uri);449 printf("[dumber] Triggered new load of %s with certificate exception\n", failing_uri);
450 fflush(stdout);450 fflush(stdout);
451 }451 }
452 }452 }
453- 453+
454 // Free the certificate info string454 // Free the certificate info string
455 if (cert_info) {455 if (cert_info) {
456 g_free(cert_info);456 g_free(cert_info);
@@ -468,18 +468,18 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
468 if (!certificate) {468 if (!certificate) {
469 return g_strdup("Certificate information not available");469 return g_strdup("Certificate information not available");
470 }470 }
471- 471+
472 printf("[dumber] Extracting certificate information...\n");472 printf("[dumber] Extracting certificate information...\n");
473 fflush(stdout);473 fflush(stdout);
474- 474+
475 GString* info = g_string_new("Certificate Information:\n");475 GString* info = g_string_new("Certificate Information:\n");
476- 476+
477 // Try to get certificate properties - but these might not be available in all GTK versions477 // Try to get certificate properties - but these might not be available in all GTK versions
478 gchar* subject = NULL;478 gchar* subject = NULL;
479 gchar* issuer = NULL;479 gchar* issuer = NULL;
480 GDateTime* not_valid_before = NULL;480 GDateTime* not_valid_before = NULL;
481 GDateTime* not_valid_after = NULL;481 GDateTime* not_valid_after = NULL;
482- 482+
483 // Try property access but handle failures gracefully483 // Try property access but handle failures gracefully
484 g_object_get(certificate,484 g_object_get(certificate,
485 "subject-name", &subject,485 "subject-name", &subject,
@@ -487,7 +487,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
487 "not-valid-before", &not_valid_before,487 "not-valid-before", &not_valid_before,
488 "not-valid-after", &not_valid_after,488 "not-valid-after", &not_valid_after,
489 NULL);489 NULL);
490- 490+
491 if (subject) {491 if (subject) {
492 g_string_append_printf(info, "Subject: %s\n", subject);492 g_string_append_printf(info, "Subject: %s\n", subject);
493 g_free(subject);493 g_free(subject);
@@ -495,7 +495,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
495 } else {495 } else {
496 g_string_append(info, "Subject: Information not available\n");496 g_string_append(info, "Subject: Information not available\n");
497 }497 }
498- 498+
499 if (issuer) {499 if (issuer) {
500 g_string_append_printf(info, "Issued by: %s\n", issuer);500 g_string_append_printf(info, "Issued by: %s\n", issuer);
501 g_free(issuer);501 g_free(issuer);
@@ -503,7 +503,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
503 } else {503 } else {
504 g_string_append(info, "Issued by: Information not available\n");504 g_string_append(info, "Issued by: Information not available\n");
505 }505 }
506- 506+
507 if (not_valid_before) {507 if (not_valid_before) {
508 gchar* date_str = g_date_time_format(not_valid_before, "%Y-%m-%d %H:%M:%S UTC");508 gchar* date_str = g_date_time_format(not_valid_before, "%Y-%m-%d %H:%M:%S UTC");
509 g_string_append_printf(info, "Valid from: %s\n", date_str);509 g_string_append_printf(info, "Valid from: %s\n", date_str);
@@ -512,7 +512,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
512 } else {512 } else {
513 g_string_append(info, "Valid from: Information not available\n");513 g_string_append(info, "Valid from: Information not available\n");
514 }514 }
515- 515+
516 if (not_valid_after) {516 if (not_valid_after) {
517 gchar* date_str = g_date_time_format(not_valid_after, "%Y-%m-%d %H:%M:%S UTC");517 gchar* date_str = g_date_time_format(not_valid_after, "%Y-%m-%d %H:%M:%S UTC");
518 g_string_append_printf(info, "Valid until: %s\n", date_str);518 g_string_append_printf(info, "Valid until: %s\n", date_str);
@@ -521,7 +521,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) {
521 } else {521 } else {
522 g_string_append(info, "Valid until: Information not available\n");522 g_string_append(info, "Valid until: Information not available\n");
523 }523 }
524- 524+
525 char* result = g_string_free(info, FALSE);525 char* result = g_string_free(info, FALSE);
526 printf("[dumber] Certificate info: %s\n", result);526 printf("[dumber] Certificate info: %s\n", result);
527 fflush(stdout);527 fflush(stdout);
@@ -539,7 +539,7 @@ static void on_tls_dialog_response(GtkDialog *dialog, gint response_id, gpointer
539 TLSDialogData *data = (TLSDialogData*)user_data;539 TLSDialogData *data = (TLSDialogData*)user_data;
540 data->user_accepted = (response_id == GTK_RESPONSE_ACCEPT);540 data->user_accepted = (response_id == GTK_RESPONSE_ACCEPT);
541 data->dialog_completed = TRUE;541 data->dialog_completed = TRUE;
542- printf("[dumber] TLS dialog response: %s (response_id=%d)\n", 542+ printf("[dumber] TLS dialog response: %s (response_id=%d)\n",
543 data->user_accepted ? "ACCEPTED" : "REJECTED", response_id);543 data->user_accepted ? "ACCEPTED" : "REJECTED", response_id);
544 fflush(stdout);544 fflush(stdout);
545 gtk_window_destroy(GTK_WINDOW(dialog));545 gtk_window_destroy(GTK_WINDOW(dialog));
@@ -550,7 +550,7 @@ static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* host
550 printf("[dumber] Creating TLS warning dialog for hostname: %s\n", hostname);550 printf("[dumber] Creating TLS warning dialog for hostname: %s\n", hostname);
551 printf("[dumber] Error message: %s\n", error_msg);551 printf("[dumber] Error message: %s\n", error_msg);
552 fflush(stdout);552 fflush(stdout);
553- 553+
554 // Create the dialog554 // Create the dialog
555 GtkWidget *dialog = gtk_message_dialog_new(555 GtkWidget *dialog = gtk_message_dialog_new(
556 parent,556 parent,
@@ -904,6 +904,24 @@ func NewWebView(cfg *Config) (*WebView, error) {
904 C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))904 C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))
905 }905 }
906 }906 }
907+
908+ // Apply custom User-Agent for codec negotiation
909+ if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
910+ if cfg.CodecPreferences.CustomUserAgent != "" {
911+ cUserAgent := C.CString(cfg.CodecPreferences.CustomUserAgent)
912+ C.webkit_settings_set_user_agent(settings, (*C.gchar)(cUserAgent))
913+ C.free(unsafe.Pointer(cUserAgent))
914+ log.Printf("[webkit] Set custom User-Agent: %s", cfg.CodecPreferences.CustomUserAgent)
915+ } else if cfg.CodecPreferences.ForceAV1 {
916+ // Use modern Chrome UA that signals AV1 support
917+ av1UA := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
918+ cAV1UA := C.CString(av1UA)
919+ C.webkit_settings_set_user_agent(settings, (*C.gchar)(cAV1UA))
920+ C.free(unsafe.Pointer(cAV1UA))
921+ log.Printf("[webkit] Set AV1-optimized User-Agent")
922+ }
923+ }
924+
907 // Apply default fonts if provided in cfg925 // Apply default fonts if provided in cfg
908 if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {926 if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
909 if cfg.DefaultSansFont != "" {927 if cfg.DefaultSansFont != "" {
@@ -938,7 +956,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
938 956
939 // Initialize UCM scripts and handlers957 // Initialize UCM scripts and handlers
940 if v.native != nil && v.native.ucm != nil {958 if v.native != nil && v.native.ucm != nil {
941- v.enableUserContentManager()959+ v.enableUserContentManager(cfg)
942 }960 }
943 961
944 // Start periodic JavaScript garbage collection if configured962 // Start periodic JavaScript garbage collection if configured
@@ -1112,7 +1130,7 @@ func (w *WebView) dispatchZoomChanged(level float64) {
1112 }1130 }
1113 1131
1114 // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.1132 // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
1115-func (w *WebView) enableUserContentManager() {1133+func (w *WebView) enableUserContentManager(cfg *Config) {
1116 if w == nil || w.native == nil || w.native.ucm == nil {1134 if w == nil || w.native == nil || w.native.ucm == nil {
1117 return1135 return
1118 }1136 }
@@ -1175,6 +1193,23 @@ func (w *WebView) enableUserContentManager() {
1175 C.webkit_user_script_unref(wailsScript)1193 C.webkit_user_script_unref(wailsScript)
1176 }1194 }
1177 1195
1196+ // Inject codec control script if codec preferences are configured
1197+ if cfg != nil && (cfg.CodecPreferences.ForceAV1 || len(cfg.CodecPreferences.BlockedCodecs) > 0 || len(cfg.CodecPreferences.PreferredCodecs) > 0) {
1198+ codecScript := GenerateCodecControlScript(cfg.CodecPreferences)
1199+ cCodec := C.CString(codecScript)
1200+ defer C.free(unsafe.Pointer(cCodec))
1201+ codecUserScript := C.webkit_user_script_new(
1202+ (*C.gchar)(cCodec),
1203+ C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
1204+ C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
1205+ nil, nil)
1206+ if codecUserScript != nil {
1207+ C.webkit_user_content_manager_add_script(w.native.ucm, codecUserScript)
1208+ C.webkit_user_script_unref(codecUserScript)
1209+ log.Printf("[webkit] Codec control script injected")
1210+ }
1211+ }
1212+
1178 // No JS fallback bridge — native UCM is active1213 // No JS fallback bridge — native UCM is active
1179 }1214 }
1180 1215
M tests/integration/browser_controls_integration_test.go +1 -1
@@ -373,7 +373,7 @@ func verifyZoomInDatabase(t *testing.T, queries *db.Queries, url string, expecte
373373
374374 // Extract domain from URL (zoom is stored per domain, not full URL)
375375 domain := extractDomain(url)
376-
376+
377377 // Get zoom level from database
378378 zoom, err := queries.GetZoomLevel(ctx, domain)
379379 if err != nil {
@@ -373,7 +373,7 @@ func verifyZoomInDatabase(t *testing.T, queries *db.Queries, url string, expecte
373 373
374 // Extract domain from URL (zoom is stored per domain, not full URL)374 // Extract domain from URL (zoom is stored per domain, not full URL)
375 domain := extractDomain(url)375 domain := extractDomain(url)
376- 376+
377 // Get zoom level from database377 // Get zoom level from database
378 zoom, err := queries.GetZoomLevel(ctx, domain)378 zoom, err := queries.GetZoomLevel(ctx, domain)
379 if err != nil {379 if err != nil {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close