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. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### 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 | 17 | - Page refresh functionality with standard keyboard shortcuts: | |
| 9 | 18 | - **Ctrl+R** / **Cmd+R**: Reload current page | |
| 10 | 19 | - **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 | ### Added | 7 | ### 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 page | 18 | - **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 | |||
| 221 | 221 | header := BinaryHeader{ | |
| 222 | 222 | Magic: CacheMagic, | |
| 223 | 223 | 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 | |
| 226 | 226 | LastModified: c.lastModified, | |
| 227 | 227 | } | |
| 228 | 228 | ||
| @@ -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 above | 224 | + | EntryCount: uint32(len(c.entries)), //nolint:gosec // G115: bounds checked above |
| 225 | - | IndexOffset: uint64(indexOffset), //nolint:gosec // G115: bounds checked above | 225 | + | 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "image" | |
| 7 | 7 | "image/color" | |
| 8 | - | "image/png" | |
| 9 | - | _ "image/jpeg" // Register JPEG decoder | |
| 10 | 8 | _ "image/gif" // Register GIF decoder | |
| 9 | + | _ "image/jpeg" // Register JPEG decoder | |
| 10 | + | "image/png" | |
| 11 | 11 | "io" | |
| 12 | 12 | "log" | |
| 13 | 13 | "net/http" | |
| @@ -107,9 +107,9 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error { | |||
| 107 | 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 | 111 | limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max | |
| 112 | - | ||
| 112 | + | ||
| 113 | 113 | // Download to temporary file first | |
| 114 | 114 | tempDownloadFile := cachedPath + ".download" | |
| 115 | 115 | downloadFile, err := os.Create(tempDownloadFile) | |
| @@ -140,18 +140,18 @@ func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) e | |||
| 140 | 140 | log.Printf("[favicon] ICO file detected for %s, using go-ico decoder", faviconURL) | |
| 141 | 141 | return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL) | |
| 142 | 142 | } | |
| 143 | - | ||
| 143 | + | ||
| 144 | 144 | // Try standard Go image decoder for other formats (PNG, JPEG, GIF) | |
| 145 | 145 | if img, err := fc.tryStandardImageDecode(inputPath); err == nil { | |
| 146 | 146 | log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL) | |
| 147 | - | ||
| 147 | + | ||
| 148 | 148 | // Apply dark theme improvements | |
| 149 | 149 | img = invertIfTooDark(img) | |
| 150 | - | ||
| 150 | + | ||
| 151 | 151 | // Save as PNG | |
| 152 | 152 | return fc.savePNG(img, outputPath) | |
| 153 | 153 | } | |
| 154 | - | ||
| 154 | + | ||
| 155 | 155 | // Standard decode failed, fallback to go-ico | |
| 156 | 156 | log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL) | |
| 157 | 157 | return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL) | |
| @@ -164,7 +164,7 @@ func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, er | |||
| 164 | 164 | return nil, err | |
| 165 | 165 | } | |
| 166 | 166 | defer file.Close() | |
| 167 | - | ||
| 167 | + | ||
| 168 | 168 | img, _, err := image.Decode(file) | |
| 169 | 169 | return img, err | |
| 170 | 170 | } | |
| @@ -177,20 +177,20 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error { | |||
| 177 | 177 | return fmt.Errorf("failed to create temp file: %w", err) | |
| 178 | 178 | } | |
| 179 | 179 | defer file.Close() | |
| 180 | - | ||
| 180 | + | ||
| 181 | 181 | if err := png.Encode(file, img); err != nil { | |
| 182 | 182 | os.Remove(tempFile) | |
| 183 | 183 | return fmt.Errorf("failed to encode PNG: %w", err) | |
| 184 | 184 | } | |
| 185 | - | ||
| 185 | + | ||
| 186 | 186 | file.Close() | |
| 187 | - | ||
| 187 | + | ||
| 188 | 188 | // Atomic move | |
| 189 | 189 | if err := os.Rename(tempFile, outputPath); err != nil { | |
| 190 | 190 | os.Remove(tempFile) | |
| 191 | 191 | return fmt.Errorf("failed to move PNG to cache: %w", err) | |
| 192 | 192 | } | |
| 193 | - | ||
| 193 | + | ||
| 194 | 194 | return nil | |
| 195 | 195 | } | |
| 196 | 196 | ||
| @@ -202,18 +202,18 @@ func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL st | |||
| 202 | 202 | return fmt.Errorf("failed to open ICO file: %w", err) | |
| 203 | 203 | } | |
| 204 | 204 | defer file.Close() | |
| 205 | - | ||
| 205 | + | ||
| 206 | 206 | // Decode ICO file | |
| 207 | 207 | img, err := ico.Decode(file) | |
| 208 | 208 | if err != nil { | |
| 209 | 209 | return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err) | |
| 210 | 210 | } | |
| 211 | - | ||
| 211 | + | ||
| 212 | 212 | log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL) | |
| 213 | - | ||
| 213 | + | ||
| 214 | 214 | // Apply dark theme improvements | |
| 215 | 215 | img = invertIfTooDark(img) | |
| 216 | - | ||
| 216 | + | ||
| 217 | 217 | // Save as PNG | |
| 218 | 218 | return fc.savePNG(img, outputPath) | |
| 219 | 219 | } | |
| @@ -226,7 +226,7 @@ func (fc *FaviconCache) CleanOld() error { | |||
| 226 | 226 | } | |
| 227 | 227 | ||
| 228 | 228 | cutoff := time.Now().Add(-30 * 24 * time.Hour) | |
| 229 | - | ||
| 229 | + | ||
| 230 | 230 | for _, entry := range entries { | |
| 231 | 231 | if entry.IsDir() { | |
| 232 | 232 | continue | |
| @@ -249,35 +249,35 @@ func (fc *FaviconCache) CleanOld() error { | |||
| 249 | 249 | // Only inverts truly monochrome dark icons, preserves colorful icons | |
| 250 | 250 | func invertIfTooDark(img image.Image) image.Image { | |
| 251 | 251 | bounds := img.Bounds() | |
| 252 | - | ||
| 252 | + | ||
| 253 | 253 | // Sample pixels to calculate statistics | |
| 254 | 254 | var totalR, totalG, totalB uint32 | |
| 255 | 255 | var pixelCount uint32 | |
| 256 | 256 | var colorVariance uint32 | |
| 257 | 257 | var transparentPixels uint32 | |
| 258 | - | ||
| 258 | + | ||
| 259 | 259 | // Sample every 4th pixel for performance | |
| 260 | 260 | for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 { | |
| 261 | 261 | for x := bounds.Min.X; x < bounds.Max.X; x += 4 { | |
| 262 | 262 | r, g, b, a := img.At(x, y).RGBA() | |
| 263 | - | ||
| 263 | + | ||
| 264 | 264 | if a == 0 { | |
| 265 | 265 | transparentPixels++ | |
| 266 | 266 | continue | |
| 267 | 267 | } | |
| 268 | - | ||
| 268 | + | ||
| 269 | 269 | if a > 0 { // Only count non-transparent pixels | |
| 270 | 270 | totalR += r | |
| 271 | 271 | totalG += g | |
| 272 | 272 | totalB += b | |
| 273 | 273 | pixelCount++ | |
| 274 | - | ||
| 274 | + | ||
| 275 | 275 | // Calculate color variance to detect colorful vs monochrome icons | |
| 276 | 276 | // High variance = colorful (don't invert), low variance = monochrome | |
| 277 | 277 | rNorm := r >> 8 // Convert to 0-255 range | |
| 278 | 278 | gNorm := g >> 8 | |
| 279 | 279 | bNorm := b >> 8 | |
| 280 | - | ||
| 280 | + | ||
| 281 | 281 | // Simple color variance: difference between max and min color channels | |
| 282 | 282 | maxColor := rNorm | |
| 283 | 283 | if gNorm > maxColor { | |
| @@ -286,7 +286,7 @@ func invertIfTooDark(img image.Image) image.Image { | |||
| 286 | 286 | if bNorm > maxColor { | |
| 287 | 287 | maxColor = bNorm | |
| 288 | 288 | } | |
| 289 | - | ||
| 289 | + | ||
| 290 | 290 | minColor := rNorm | |
| 291 | 291 | if gNorm < minColor { | |
| 292 | 292 | minColor = gNorm | |
| @@ -294,47 +294,47 @@ func invertIfTooDark(img image.Image) image.Image { | |||
| 294 | 294 | if bNorm < minColor { | |
| 295 | 295 | minColor = bNorm | |
| 296 | 296 | } | |
| 297 | - | ||
| 297 | + | ||
| 298 | 298 | colorVariance += maxColor - minColor | |
| 299 | 299 | } | |
| 300 | 300 | } | |
| 301 | 301 | } | |
| 302 | - | ||
| 302 | + | ||
| 303 | 303 | if pixelCount == 0 { | |
| 304 | 304 | return img // All transparent | |
| 305 | 305 | } | |
| 306 | - | ||
| 306 | + | ||
| 307 | 307 | // Calculate average brightness (0-65535 range) | |
| 308 | 308 | avgR := totalR / pixelCount | |
| 309 | 309 | avgG := totalG / pixelCount | |
| 310 | 310 | avgB := totalB / pixelCount | |
| 311 | - | ||
| 311 | + | ||
| 312 | 312 | // Calculate perceived brightness using standard luminance formula | |
| 313 | 313 | brightness := (299*avgR + 587*avgG + 114*avgB) / 1000 | |
| 314 | - | ||
| 314 | + | ||
| 315 | 315 | // Calculate average color variance (0-255 range) | |
| 316 | 316 | avgColorVariance := colorVariance / pixelCount | |
| 317 | - | ||
| 317 | + | ||
| 318 | 318 | // Calculate transparency ratio | |
| 319 | 319 | totalPixels := pixelCount + transparentPixels | |
| 320 | 320 | transparencyRatio := float64(transparentPixels) / float64(totalPixels) | |
| 321 | - | ||
| 321 | + | ||
| 322 | 322 | // Only invert if: | |
| 323 | 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 | 325 | // 3. AND icon is not mostly transparent (< 80% transparent) | |
| 326 | 326 | // This makes dark monochrome icons visible on dark themes while preserving colorful icons | |
| 327 | - | ||
| 327 | + | ||
| 328 | 328 | 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", | |
| 331 | 331 | brightness, avgColorVariance, transparencyRatio, shouldInvert) | |
| 332 | - | ||
| 332 | + | ||
| 333 | 333 | if shouldInvert { | |
| 334 | 334 | log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility") | |
| 335 | 335 | return invertImage(img) | |
| 336 | 336 | } | |
| 337 | - | ||
| 337 | + | ||
| 338 | 338 | return img | |
| 339 | 339 | } | |
| 340 | 340 | ||
| @@ -342,28 +342,28 @@ func invertIfTooDark(img image.Image) image.Image { | |||
| 342 | 342 | func invertImage(img image.Image) image.Image { | |
| 343 | 343 | bounds := img.Bounds() | |
| 344 | 344 | inverted := image.NewRGBA(bounds) | |
| 345 | - | ||
| 345 | + | ||
| 346 | 346 | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { | |
| 347 | 347 | for x := bounds.Min.X; x < bounds.Max.X; x++ { | |
| 348 | 348 | r, g, b, a := img.At(x, y).RGBA() | |
| 349 | - | ||
| 349 | + | ||
| 350 | 350 | // Skip fully transparent pixels to avoid noise | |
| 351 | 351 | if a == 0 { | |
| 352 | 352 | inverted.Set(x, y, color.RGBA{A: 0}) | |
| 353 | 353 | continue | |
| 354 | 354 | } | |
| 355 | - | ||
| 355 | + | ||
| 356 | 356 | // Convert to 8-bit for easier processing | |
| 357 | 357 | r8 := uint8(r >> 8) | |
| 358 | 358 | g8 := uint8(g >> 8) | |
| 359 | 359 | b8 := uint8(b >> 8) | |
| 360 | 360 | a8 := uint8(a >> 8) | |
| 361 | - | ||
| 361 | + | ||
| 362 | 362 | // Invert RGB but preserve alpha | |
| 363 | 363 | newR := 255 - r8 | |
| 364 | 364 | newG := 255 - g8 | |
| 365 | 365 | newB := 255 - b8 | |
| 366 | - | ||
| 366 | + | ||
| 367 | 367 | // For semi-transparent pixels, blend the inversion more gently | |
| 368 | 368 | // This reduces edge noise by making transitions smoother | |
| 369 | 369 | if a8 < 255 { | |
| @@ -373,7 +373,7 @@ func invertImage(img image.Image) image.Image { | |||
| 373 | 373 | newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor)) | |
| 374 | 374 | newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor)) | |
| 375 | 375 | } | |
| 376 | - | ||
| 376 | + | ||
| 377 | 377 | inverted.Set(x, y, color.RGBA{ | |
| 378 | 378 | R: newR, | |
| 379 | 379 | G: newG, | |
| @@ -382,6 +382,6 @@ func invertImage(img image.Image) image.Image { | |||
| 382 | 382 | }) | |
| 383 | 383 | } | |
| 384 | 384 | } | |
| 385 | - | ||
| 385 | + | ||
| 386 | 386 | 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 decoder | 8 | _ "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 max | 111 | limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max | ||
| 112 | - | 112 | + | ||
| 113 | // Download to temporary file first | 113 | // 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 improvements | 148 | // Apply dark theme improvements | ||
| 149 | img = invertIfTooDark(img) | 149 | img = invertIfTooDark(img) | ||
| 150 | - | 150 | + | ||
| 151 | // Save as PNG | 151 | // 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-ico | 155 | // 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, err | 164 | 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, err | 169 | 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 move | 188 | // 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 nil | 194 | 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 file | 206 | // 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 improvements | 214 | // Apply dark theme improvements | ||
| 215 | img = invertIfTooDark(img) | 215 | img = invertIfTooDark(img) | ||
| 216 | - | 216 | + | ||
| 217 | // Save as PNG | 217 | // 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 | continue | 232 | continue | ||
| @@ -249,35 +249,35 @@ func (fc *FaviconCache) CleanOld() error { | |||||
| 249 | // Only inverts truly monochrome dark icons, preserves colorful icons | 249 | // 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 statistics | 253 | // Sample pixels to calculate statistics | ||
| 254 | var totalR, totalG, totalB uint32 | 254 | var totalR, totalG, totalB uint32 | ||
| 255 | var pixelCount uint32 | 255 | var pixelCount uint32 | ||
| 256 | var colorVariance uint32 | 256 | var colorVariance uint32 | ||
| 257 | var transparentPixels uint32 | 257 | var transparentPixels uint32 | ||
| 258 | - | 258 | + | ||
| 259 | // Sample every 4th pixel for performance | 259 | // 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 | continue | 266 | continue | ||
| 267 | } | 267 | } | ||
| 268 | - | 268 | + | ||
| 269 | if a > 0 { // Only count non-transparent pixels | 269 | if a > 0 { // Only count non-transparent pixels | ||
| 270 | totalR += r | 270 | totalR += r | ||
| 271 | totalG += g | 271 | totalG += g | ||
| 272 | totalB += b | 272 | totalB += b | ||
| 273 | pixelCount++ | 273 | pixelCount++ | ||
| 274 | - | 274 | + | ||
| 275 | // Calculate color variance to detect colorful vs monochrome icons | 275 | // Calculate color variance to detect colorful vs monochrome icons | ||
| 276 | // High variance = colorful (don't invert), low variance = monochrome | 276 | // High variance = colorful (don't invert), low variance = monochrome | ||
| 277 | rNorm := r >> 8 // Convert to 0-255 range | 277 | rNorm := r >> 8 // Convert to 0-255 range | ||
| 278 | gNorm := g >> 8 | 278 | gNorm := g >> 8 | ||
| 279 | bNorm := b >> 8 | 279 | bNorm := b >> 8 | ||
| 280 | - | 280 | + | ||
| 281 | // Simple color variance: difference between max and min color channels | 281 | // Simple color variance: difference between max and min color channels | ||
| 282 | maxColor := rNorm | 282 | 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 = bNorm | 287 | maxColor = bNorm | ||
| 288 | } | 288 | } | ||
| 289 | - | 289 | + | ||
| 290 | minColor := rNorm | 290 | minColor := rNorm | ||
| 291 | if gNorm < minColor { | 291 | if gNorm < minColor { | ||
| 292 | minColor = gNorm | 292 | minColor = gNorm | ||
| @@ -294,47 +294,47 @@ func invertIfTooDark(img image.Image) image.Image { | |||||
| 294 | if bNorm < minColor { | 294 | if bNorm < minColor { | ||
| 295 | minColor = bNorm | 295 | minColor = bNorm | ||
| 296 | } | 296 | } | ||
| 297 | - | 297 | + | ||
| 298 | colorVariance += maxColor - minColor | 298 | colorVariance += maxColor - minColor | ||
| 299 | } | 299 | } | ||
| 300 | } | 300 | } | ||
| 301 | } | 301 | } | ||
| 302 | - | 302 | + | ||
| 303 | if pixelCount == 0 { | 303 | if pixelCount == 0 { | ||
| 304 | return img // All transparent | 304 | 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 / pixelCount | 308 | avgR := totalR / pixelCount | ||
| 309 | avgG := totalG / pixelCount | 309 | avgG := totalG / pixelCount | ||
| 310 | avgB := totalB / pixelCount | 310 | avgB := totalB / pixelCount | ||
| 311 | - | 311 | + | ||
| 312 | // Calculate perceived brightness using standard luminance formula | 312 | // Calculate perceived brightness using standard luminance formula | ||
| 313 | brightness := (299*avgR + 587*avgG + 114*avgB) / 1000 | 313 | 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 / pixelCount | 316 | avgColorVariance := colorVariance / pixelCount | ||
| 317 | - | 317 | + | ||
| 318 | // Calculate transparency ratio | 318 | // Calculate transparency ratio | ||
| 319 | totalPixels := pixelCount + transparentPixels | 319 | 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 icons | 326 | // This makes dark monochrome icons visible on dark themes while preserving colorful icons | ||
| 327 | - | 327 | + | ||
| 328 | shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8 | 328 | 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 img | 338 | 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 noise | 350 | // 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 | continue | 353 | continue | ||
| 354 | } | 354 | } | ||
| 355 | - | 355 | + | ||
| 356 | // Convert to 8-bit for easier processing | 356 | // 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 alpha | 362 | // Invert RGB but preserve alpha | ||
| 363 | newR := 255 - r8 | 363 | newR := 255 - r8 | ||
| 364 | newG := 255 - g8 | 364 | newG := 255 - g8 | ||
| 365 | newB := 255 - b8 | 365 | newB := 255 - b8 | ||
| 366 | - | 366 | + | ||
| 367 | // For semi-transparent pixels, blend the inversion more gently | 367 | // For semi-transparent pixels, blend the inversion more gently | ||
| 368 | // This reduces edge noise by making transitions smoother | 368 | // 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 inverted | 386 | return inverted | ||
| 387 | - | }⏎̸ | 387 | + | } |
M internal/cache/fuzzy_cache.go +5 -5
| @@ -15,11 +15,11 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | // Cache update constants | |
| 17 | 17 | 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 | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | 25 | // CacheManager handles the lifecycle of the fuzzy cache with smart invalidation. | |
| @@ -15,11 +15,11 @@ import ( | |||||
| 15 | 15 | ||||
| 16 | // Cache update constants | 16 | // Cache update constants | ||
| 17 | const ( | 17 | const ( | ||
| 18 | - | updateWaitIntervalMs = 10 // Milliseconds to wait during concurrent cache build | 18 | + | updateWaitIntervalMs = 10 // Milliseconds to wait during concurrent cache build |
| 19 | - | dbHashTimeoutSec = 5 // Seconds timeout for database hash calculation | 19 | + | dbHashTimeoutSec = 5 // Seconds timeout for database hash calculation |
| 20 | - | recentHistoryLimit = 20 // Number of recent entries to check for changes | 20 | + | recentHistoryLimit = 20 // Number of recent entries to check for changes |
| 21 | - | exactMatchBonus = 0.95 // Score bonus for exact matches | 21 | + | exactMatchBonus = 0.95 // Score bonus for exact matches |
| 22 | - | dirPerm = 0755 // Directory permissions | 22 | + | 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) { | |||
| 487 | 487 | ||
| 488 | 488 | // First call returns initial history | |
| 489 | 489 | firstHistory := createTestHistory()[:3] // Only 3 entries | |
| 490 | - | secondHistory := createTestHistory() // All 5 entries | |
| 491 | - | ||
| 490 | + | secondHistory := createTestHistory() // All 5 entries | |
| 491 | + | ||
| 492 | 492 | // Set up multiple expectations for the different calls | |
| 493 | 493 | 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 | |
| 495 | 495 | 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 | 542 | func create40TestEntries() []db.History { | |
| 543 | 543 | now := time.Now() | |
| 544 | 544 | entries := make([]db.History, 40) | |
| 545 | - | ||
| 545 | + | ||
| 546 | 546 | urls := []string{ | |
| 547 | 547 | "https://github.com", "https://stackoverflow.com", "https://golang.org", "https://news.ycombinator.com", | |
| 548 | 548 | "https://youtube.com", "https://google.com", "https://reddit.com", "https://twitter.com", | |
| @@ -555,7 +555,7 @@ func create40TestEntries() []db.History { | |||
| 555 | 555 | "https://instagram.com", "https://tiktok.com", "https://netflix.com", "https://spotify.com", | |
| 556 | 556 | "https://apple.com", "https://microsoft.com", "https://amazon.com", "https://wikipedia.org", | |
| 557 | 557 | } | |
| 558 | - | ||
| 558 | + | ||
| 559 | 559 | titles := []string{ | |
| 560 | 560 | "GitHub", "Stack Overflow", "Go Programming Language", "Hacker News", | |
| 561 | 561 | "YouTube", "Google", "Reddit", "Twitter", | |
| @@ -568,7 +568,7 @@ func create40TestEntries() []db.History { | |||
| 568 | 568 | "Instagram", "TikTok", "Netflix", "Spotify", | |
| 569 | 569 | "Apple", "Microsoft", "Amazon", "Wikipedia", | |
| 570 | 570 | } | |
| 571 | - | ||
| 571 | + | ||
| 572 | 572 | for i := 0; i < 40; i++ { | |
| 573 | 573 | entries[i] = db.History{ | |
| 574 | 574 | ID: int64(i + 1), | |
| @@ -578,7 +578,7 @@ func create40TestEntries() []db.History { | |||
| 578 | 578 | LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true}, | |
| 579 | 579 | } | |
| 580 | 580 | } | |
| 581 | - | ||
| 581 | + | ||
| 582 | 582 | return entries | |
| 583 | 583 | } | |
| 584 | 584 | ||
| @@ -586,13 +586,13 @@ func create40TestEntries() []db.History { | |||
| 586 | 586 | func BenchmarkGetTopEntries(b *testing.B) { | |
| 587 | 587 | ctrl := gomock.NewController(b) | |
| 588 | 588 | defer ctrl.Finish() | |
| 589 | - | ||
| 589 | + | ||
| 590 | 590 | mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl) | |
| 591 | - | ||
| 591 | + | ||
| 592 | 592 | // Setup 40 test entries | |
| 593 | 593 | testHistory := create40TestEntries() | |
| 594 | 594 | mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes() | |
| 595 | - | ||
| 595 | + | ||
| 596 | 596 | // Create temporary directory for cache file | |
| 597 | 597 | tempDir, err := os.MkdirTemp("", "cache_benchmark") | |
| 598 | 598 | if err != nil { | |
| @@ -603,23 +603,23 @@ func BenchmarkGetTopEntries(b *testing.B) { | |||
| 603 | 603 | b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err) | |
| 604 | 604 | } | |
| 605 | 605 | }() | |
| 606 | - | ||
| 606 | + | ||
| 607 | 607 | config := DefaultCacheConfig() | |
| 608 | 608 | config.CacheFile = filepath.Join(tempDir, "benchmark_cache.bin") | |
| 609 | 609 | config.MaxResults = 50 | |
| 610 | - | ||
| 610 | + | ||
| 611 | 611 | manager := NewCacheManager(mockQuerier, config) | |
| 612 | 612 | ctx := context.Background() | |
| 613 | - | ||
| 613 | + | ||
| 614 | 614 | // Pre-build cache once (this isn't part of the benchmark) | |
| 615 | 615 | _, err = manager.GetCache(ctx) | |
| 616 | 616 | if err != nil { | |
| 617 | 617 | b.Fatalf("Failed to pre-build cache: %v", err) | |
| 618 | 618 | } | |
| 619 | - | ||
| 619 | + | ||
| 620 | 620 | b.ResetTimer() // Start timing from here | |
| 621 | 621 | b.ReportAllocs() | |
| 622 | - | ||
| 622 | + | ||
| 623 | 623 | // This is the critical path that needs to be under 2ms | |
| 624 | 624 | for i := 0; i < b.N; i++ { | |
| 625 | 625 | result, err := manager.GetTopEntries(ctx) | |
| @@ -636,13 +636,13 @@ func BenchmarkGetTopEntries(b *testing.B) { | |||
| 636 | 636 | func BenchmarkGetTopEntriesParallel(b *testing.B) { | |
| 637 | 637 | ctrl := gomock.NewController(b) | |
| 638 | 638 | defer ctrl.Finish() | |
| 639 | - | ||
| 639 | + | ||
| 640 | 640 | mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl) | |
| 641 | - | ||
| 641 | + | ||
| 642 | 642 | // Setup 40 test entries | |
| 643 | 643 | testHistory := create40TestEntries() | |
| 644 | 644 | mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes() | |
| 645 | - | ||
| 645 | + | ||
| 646 | 646 | // Create temporary directory for cache file | |
| 647 | 647 | tempDir, err := os.MkdirTemp("", "cache_benchmark_parallel") | |
| 648 | 648 | if err != nil { | |
| @@ -653,23 +653,23 @@ func BenchmarkGetTopEntriesParallel(b *testing.B) { | |||
| 653 | 653 | b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err) | |
| 654 | 654 | } | |
| 655 | 655 | }() | |
| 656 | - | ||
| 656 | + | ||
| 657 | 657 | config := DefaultCacheConfig() | |
| 658 | 658 | config.CacheFile = filepath.Join(tempDir, "benchmark_parallel_cache.bin") | |
| 659 | 659 | config.MaxResults = 50 | |
| 660 | - | ||
| 660 | + | ||
| 661 | 661 | manager := NewCacheManager(mockQuerier, config) | |
| 662 | 662 | ctx := context.Background() | |
| 663 | - | ||
| 663 | + | ||
| 664 | 664 | // Pre-build cache once (this isn't part of the benchmark) | |
| 665 | 665 | _, err = manager.GetCache(ctx) | |
| 666 | 666 | if err != nil { | |
| 667 | 667 | b.Fatalf("Failed to pre-build cache: %v", err) | |
| 668 | 668 | } | |
| 669 | - | ||
| 669 | + | ||
| 670 | 670 | b.ResetTimer() | |
| 671 | 671 | b.ReportAllocs() | |
| 672 | - | ||
| 672 | + | ||
| 673 | 673 | b.RunParallel(func(pb *testing.PB) { | |
| 674 | 674 | for pb.Next() { | |
| 675 | 675 | result, err := manager.GetTopEntries(ctx) | |
| @@ -487,11 +487,11 @@ func TestCacheInvalidation(t *testing.T) { | |||||
| 487 | 487 | ||||
| 488 | // First call returns initial history | 488 | // First call returns initial history | ||
| 489 | firstHistory := createTestHistory()[:3] // Only 3 entries | 489 | firstHistory := createTestHistory()[:3] // Only 3 entries | ||
| 490 | - | secondHistory := createTestHistory() // All 5 entries | 490 | + | secondHistory := createTestHistory() // All 5 entries |
| 491 | - | 491 | + | ||
| 492 | // Set up multiple expectations for the different calls | 492 | // 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 + hash | 494 | + | 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 calls | 495 | 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 entries | 582 | 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 entries | 592 | // 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 file | 596 | // 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 = 50 | 609 | 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 here | 620 | 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 2ms | 623 | // 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 entries | 642 | // 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 file | 646 | // 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 = 50 | 659 | 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | // Fuzzy search constants | |
| 12 | 12 | 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 | |
| 16 | 16 | maxVisitNormal = 1000.0 // Maximum visits for normalization | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| @@ -10,9 +10,9 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | // Fuzzy search constants | 11 | // Fuzzy search constants | ||
| 12 | const ( | 12 | const ( | ||
| 13 | - | prefixMatchBonus = 0.9 // Score bonus for prefix matches | 13 | + | prefixMatchBonus = 0.9 // Score bonus for prefix matches |
| 14 | - | prefixLength = 4 // Maximum prefix length to check | 14 | + | prefixLength = 4 // Maximum prefix length to check |
| 15 | - | recencyDecayDays = 30.0 // Days over which recency score decays | 15 | + | recencyDecayDays = 30.0 // Days over which recency score decays |
| 16 | maxVisitNormal = 1000.0 // Maximum visits for normalization | 16 | 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 { | |||
| 88 | 88 | return openURLWithConfig(finalURL, cli.Config) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | - | ||
| 92 | 91 | // recordVisit adds or updates a URL visit in the history | |
| 93 | 92 | func recordVisit(ctx context.Context, cli *CLI, url, title string) error { | |
| 94 | 93 | var sqlTitle sql.NullString | |
| @@ -119,7 +118,7 @@ func openURLWithConfig(url string, cfg *config.Config) error { | |||
| 119 | 118 | // 1. Config file defaults | |
| 120 | 119 | // 2. Environment variables (override config) | |
| 121 | 120 | // 3. Command line flags (override both) | |
| 122 | - | // | |
| 121 | + | // | |
| 123 | 122 | // We don't convert config to env vars here because that would break precedence. | |
| 124 | 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 | 166 | faviconCache.CacheAsync(faviconURL) | |
| 168 | 167 | } | |
| 169 | 168 | } | |
| 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 history | 91 | // 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.NullString | 93 | var sqlTitle sql.NullString | ||
| @@ -119,7 +118,7 @@ func openURLWithConfig(url string, cfg *config.Config) error { | |||||
| 119 | // 1. Config file defaults | 118 | // 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 { | |||
| 85 | 85 | // Add history entries from cache (already sorted by relevance!) | |
| 86 | 86 | for _, match := range result.Matches { | |
| 87 | 87 | entry := match.Entry | |
| 88 | - | ||
| 88 | + | ||
| 89 | 89 | // Parse URL for domain extraction | |
| 90 | 90 | parsedURL, err := url.Parse(entry.URL) | |
| 91 | 91 | var domain string | |
| @@ -94,7 +94,7 @@ func generateOptions(cli *CLI) error { | |||
| 94 | 94 | } else { | |
| 95 | 95 | domain = parsedURL.Host | |
| 96 | 96 | } | |
| 97 | - | ||
| 97 | + | ||
| 98 | 98 | // Determine title | |
| 99 | 99 | title := entry.Title | |
| 100 | 100 | if title == "" || title == entry.URL { | |
| @@ -105,7 +105,7 @@ func generateOptions(cli *CLI) error { | |||
| 105 | 105 | title = fmt.Sprintf("[%s]", domain) | |
| 106 | 106 | } | |
| 107 | 107 | } | |
| 108 | - | ||
| 108 | + | ||
| 109 | 109 | // Check if it's a Google search result and format accordingly | |
| 110 | 110 | isGoogleSearch := domain == "www.google.com" && strings.Contains(entry.URL, "/search?q=") | |
| 111 | 111 | if isGoogleSearch { | |
| @@ -115,17 +115,17 @@ func generateOptions(cli *CLI) error { | |||
| 115 | 115 | } | |
| 116 | 116 | } | |
| 117 | 117 | } | |
| 118 | - | ||
| 118 | + | ||
| 119 | 119 | // Format: "Title | domain.com | full-url" | |
| 120 | 120 | // Using pipe separator for rofi/dmenu compatibility | |
| 121 | - | display := fmt.Sprintf("%s | %s | %s", | |
| 121 | + | display := fmt.Sprintf("%s | %s | %s", | |
| 122 | 122 | truncateString(title, 50), | |
| 123 | 123 | domain, | |
| 124 | 124 | truncateString(entry.URL, 70)) | |
| 125 | 125 | ||
| 126 | 126 | // Get favicon URL from cached entry | |
| 127 | 127 | faviconURL := match.Entry.FaviconURL | |
| 128 | - | ||
| 128 | + | ||
| 129 | 129 | options = append(options, DmenuOption{ | |
| 130 | 130 | Display: display, | |
| 131 | 131 | Value: entry.URL, | |
| @@ -263,7 +263,6 @@ func handleSelection(cli *CLI) error { | |||
| 263 | 263 | return fmt.Errorf("empty selection") | |
| 264 | 264 | } | |
| 265 | 265 | ||
| 266 | - | ||
| 267 | 266 | // Parse the selection to get the actual URL from history entry | |
| 268 | 267 | input := parseSelection(selection) | |
| 269 | 268 | ||
| @@ -273,7 +272,7 @@ func handleSelection(cli *CLI) error { | |||
| 273 | 272 | ||
| 274 | 273 | // Browse the selected/entered URL | |
| 275 | 274 | err := browse(cli, input) | |
| 276 | - | ||
| 275 | + | ||
| 277 | 276 | // After successful browse, invalidate cache so next dmenu shows updated order | |
| 278 | 277 | if err == nil { | |
| 279 | 278 | ctx := context.Background() | |
| @@ -281,11 +280,10 @@ func handleSelection(cli *CLI) error { | |||
| 281 | 280 | cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig) | |
| 282 | 281 | cacheManager.InvalidateAndRefresh(ctx) | |
| 283 | 282 | } | |
| 284 | - | ||
| 283 | + | ||
| 285 | 284 | return err | |
| 286 | 285 | } | |
| 287 | 286 | ||
| 288 | - | ||
| 289 | 287 | // DmenuOption represents a selectable option in dmenu mode | |
| 290 | 288 | type DmenuOption struct { | |
| 291 | 289 | Display string | |
| @@ -352,7 +350,7 @@ func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string { | |||
| 352 | 350 | if option.Type != historyType { | |
| 353 | 351 | return "" | |
| 354 | 352 | } | |
| 355 | - | ||
| 353 | + | ||
| 356 | 354 | // Only use cached favicons - no system theme fallbacks for consistent sizing | |
| 357 | 355 | if option.FaviconURL != "" && faviconCache != nil { | |
| 358 | 356 | cachedPath := faviconCache.GetCachedPath(option.FaviconURL) | |
| @@ -362,7 +360,7 @@ func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string { | |||
| 362 | 360 | // Start async download for next time | |
| 363 | 361 | faviconCache.CacheAsync(option.FaviconURL) | |
| 364 | 362 | } | |
| 365 | - | ||
| 363 | + | ||
| 366 | 364 | // No favicon available - return empty string to show no icon | |
| 367 | 365 | return "" | |
| 368 | 366 | } | |
| @@ -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.Entry | 87 | entry := match.Entry | ||
| 88 | - | 88 | + | ||
| 89 | // Parse URL for domain extraction | 89 | // Parse URL for domain extraction | ||
| 90 | parsedURL, err := url.Parse(entry.URL) | 90 | parsedURL, err := url.Parse(entry.URL) | ||
| 91 | var domain string | 91 | var domain string | ||
| @@ -94,7 +94,7 @@ func generateOptions(cli *CLI) error { | |||||
| 94 | } else { | 94 | } else { | ||
| 95 | domain = parsedURL.Host | 95 | domain = parsedURL.Host | ||
| 96 | } | 96 | } | ||
| 97 | - | 97 | + | ||
| 98 | // Determine title | 98 | // Determine title | ||
| 99 | title := entry.Title | 99 | 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 accordingly | 109 | // 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 compatibility | 120 | // 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 entry | 126 | // Get favicon URL from cached entry | ||
| 127 | faviconURL := match.Entry.FaviconURL | 127 | 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 entry | 266 | // 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 URL | 273 | // 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 order | 276 | // 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 err | 284 | return err | ||
| 286 | } | 285 | } | ||
| 287 | 286 | ||||
| 288 | - | ||||
| 289 | // DmenuOption represents a selectable option in dmenu mode | 287 | // DmenuOption represents a selectable option in dmenu mode | ||
| 290 | type DmenuOption struct { | 288 | type DmenuOption struct { | ||
| 291 | Display string | 289 | 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 sizing | 354 | // 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 time | 360 | // 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 icon | 364 | // 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 { | |||
| 180 | 180 | if file.IsDir() { | |
| 181 | 181 | continue | |
| 182 | 182 | } | |
| 183 | - | ||
| 183 | + | ||
| 184 | 184 | name := file.Name() | |
| 185 | 185 | if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") { | |
| 186 | 186 | info, err := file.Info() | |
| @@ -204,7 +204,7 @@ func listLogFiles(logDir string) error { | |||
| 204 | 204 | // Display files | |
| 205 | 205 | fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE") | |
| 206 | 206 | fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----") | |
| 207 | - | ||
| 207 | + | ||
| 208 | 208 | for _, file := range logFiles { | |
| 209 | 209 | size := formatSize(file.Size()) | |
| 210 | 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 | 254 | if info.ModTime().Before(cutoffTime) { | |
| 255 | 255 | filePath := filepath.Join(logDir, name) | |
| 256 | - | ||
| 256 | + | ||
| 257 | 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 | 259 | name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05")) | |
| 260 | 260 | } else { | |
| 261 | 261 | err := os.Remove(filePath) | |
| @@ -265,17 +265,17 @@ func cleanOldLogs(logDir string, maxAge int, dryRun bool) error { | |||
| 265 | 265 | fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024) | |
| 266 | 266 | } | |
| 267 | 267 | } | |
| 268 | - | ||
| 268 | + | ||
| 269 | 269 | deletedCount++ | |
| 270 | 270 | deletedSize += info.Size() | |
| 271 | 271 | } | |
| 272 | 272 | } | |
| 273 | 273 | ||
| 274 | 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 | 276 | deletedCount, float64(deletedSize)/1024) | |
| 277 | 277 | } else { | |
| 278 | - | fmt.Printf("\nCleaned %d files (%.2f KB total)\n", | |
| 278 | + | fmt.Printf("\nCleaned %d files (%.2f KB total)\n", | |
| 279 | 279 | deletedCount, float64(deletedSize)/1024) | |
| 280 | 280 | } | |
| 281 | 281 | ||
| @@ -296,4 +296,4 @@ func formatSize(size int64) string { | |||
| 296 | 296 | } | |
| 297 | 297 | ||
| 298 | 298 | 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 | continue | 181 | 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 files | 204 | // 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 { | |||
| 276 | 276 | return os.RemoveAll(path) | |
| 277 | 277 | } | |
| 278 | 278 | 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 { | |||
| 192 | 192 | return rootCmd | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 195 | - | ||
| 196 | 195 | // initializeDatabase runs embedded migrations and ensures database is up to date | |
| 197 | 196 | func initializeDatabase(db *sql.DB, cfg *config.Config) error { | |
| 198 | 197 | // 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 rootCmd | 192 | return rootCmd | ||
| 193 | } | 193 | } | ||
| 194 | 194 | ||||
| 195 | - | ||||
| 196 | // initializeDatabase runs embedded migrations and ensures database is up to date | 195 | // 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 migrations | 197 | // 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 ( | |||
| 24 | 24 | ||
| 25 | 25 | // Config represents the complete configuration for dumber. | |
| 26 | 26 | 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"` | |
| 34 | 35 | // RenderingMode controls GPU/CPU rendering selection for WebKit | |
| 35 | 36 | RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"` | |
| 36 | 37 | } | |
| @@ -79,13 +80,13 @@ type DmenuConfig struct { | |||
| 79 | 80 | ||
| 80 | 81 | // LoggingConfig holds logging configuration. | |
| 81 | 82 | 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"` | |
| 89 | 90 | ||
| 90 | 91 | // File output configuration | |
| 91 | 92 | LogDir string `mapstructure:"log_dir" yaml:"log_dir"` | |
| @@ -120,6 +121,33 @@ type VideoAccelerationConfig struct { | |||
| 120 | 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 | 151 | // Manager handles configuration loading, watching, and reloading. | |
| 124 | 152 | type Manager struct { | |
| 125 | 153 | config *Config | |
| @@ -194,7 +222,7 @@ func NewManager() (*Manager, error) { | |||
| 194 | 222 | "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE", | |
| 195 | 223 | "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT", | |
| 196 | 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 | 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 | 255 | return &Manager{ | |
| 208 | 256 | viper: v, | |
| 209 | 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 | 325 | m.config = config | |
| 275 | 326 | 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 | 423 | m.config = config | |
| 370 | 424 | return nil | |
| 371 | 425 | } | |
| @@ -428,6 +482,18 @@ func (m *Manager) setDefaults() { | |||
| 428 | 482 | m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers) | |
| 429 | 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 | 497 | // Rendering defaults | |
| 432 | 498 | m.viper.SetDefault("rendering_mode", string(RenderingModeAuto)) | |
| 433 | 499 | } | |
| @@ -508,3 +574,49 @@ func OnConfigChange(callback func(*Config)) { | |||
| 508 | 574 | } | |
| 509 | 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 | + | } | |
| @@ -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 WebKit | 35 | // 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 configuration | 91 | // 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 *Config | 153 | 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 = config | 325 | m.config = config | ||
| 275 | return nil | 326 | 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 = config | 423 | m.config = config | ||
| 370 | return nil | 424 | 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 defaults | 497 | // 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 ( | |||
| 8 | 8 | // Default configuration constants | |
| 9 | 9 | const ( | |
| 10 | 10 | // Database defaults | |
| 11 | - | defaultMaxIdleTimeMin = 5 // minutes | |
| 12 | - | defaultQueryTimeoutSec = 30 // seconds | |
| 11 | + | defaultMaxIdleTimeMin = 5 // minutes | |
| 12 | + | defaultQueryTimeoutSec = 30 // seconds | |
| 13 | 13 | ||
| 14 | - | // History defaults | |
| 14 | + | // History defaults | |
| 15 | 15 | defaultMaxHistoryEntries = 10000 // entries | |
| 16 | 16 | defaultRetentionDays = 365 // 1 year | |
| 17 | 17 | ||
| @@ -78,20 +78,20 @@ func DefaultConfig() *Config { | |||
| 78 | 78 | SortByVisitCount: true, | |
| 79 | 79 | }, | |
| 80 | 80 | 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, | |
| 92 | 92 | CaptureCOutput: false, // Disabled by default to avoid performance impact | |
| 93 | - | DebugFile: "debug.log", | |
| 94 | - | VerboseWebKit: false, | |
| 93 | + | DebugFile: "debug.log", | |
| 94 | + | VerboseWebKit: false, | |
| 95 | 95 | }, | |
| 96 | 96 | Appearance: AppearanceConfig{ | |
| 97 | 97 | SansFont: "Fira Sans", | |
| @@ -106,6 +106,18 @@ func DefaultConfig() *Config { | |||
| 106 | 106 | EnableAllDrivers: true, | |
| 107 | 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 | 121 | RenderingMode: RenderingModeAuto, | |
| 110 | 122 | } | |
| 111 | 123 | } | |
| @@ -8,10 +8,10 @@ import ( | |||||
| 8 | // Default configuration constants | 8 | // Default configuration constants | ||
| 9 | const ( | 9 | const ( | ||
| 10 | // Database defaults | 10 | // Database defaults | ||
| 11 | - | defaultMaxIdleTimeMin = 5 // minutes | 11 | + | defaultMaxIdleTimeMin = 5 // minutes |
| 12 | - | defaultQueryTimeoutSec = 30 // seconds | 12 | + | defaultQueryTimeoutSec = 30 // seconds |
| 13 | 13 | ||||
| 14 | - | // History defaults | 14 | + | // History defaults |
| 15 | defaultMaxHistoryEntries = 10000 // entries | 15 | defaultMaxHistoryEntries = 10000 // entries | ||
| 16 | defaultRetentionDays = 365 // 1 year | 16 | 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 json | 82 | + | Format: "text", // text or json |
| 83 | - | Filename: "", // empty means stdout | 83 | + | Filename: "", // empty means stdout |
| 84 | - | MaxSize: defaultMaxLogSizeMB, // MB | 84 | + | MaxSize: defaultMaxLogSizeMB, // MB |
| 85 | - | MaxBackups: defaultMaxBackups, | 85 | + | MaxBackups: defaultMaxBackups, |
| 86 | - | MaxAge: defaultMaxLogAgeDays, // days | 86 | + | 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 impact | 92 | 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) { | |||
| 91 | 91 | return filepath.Join(stateDir, "logs"), nil | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | - | ||
| 95 | 94 | // GetConfigFile returns the path to the main configuration file. | |
| 96 | 95 | func GetConfigFile() (string, error) { | |
| 97 | 96 | configDir, err := GetConfigDir() | |
| @@ -91,7 +91,6 @@ func GetLogDir() (string, error) { | |||||
| 91 | return filepath.Join(stateDir, "logs"), nil | 91 | 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) { | |||
| 128 | 128 | } | |
| 129 | 129 | } | |
| 130 | 130 | } | |
| 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 | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | outputs := []io.Writer{} | |
| 106 | - | ||
| 106 | + | ||
| 107 | 107 | // Always include stdout | |
| 108 | 108 | outputs = append(outputs, os.Stdout) | |
| 109 | 109 | ||
| @@ -142,7 +142,7 @@ func (l *Logger) Write(p []byte) (n int, err error) { | |||
| 142 | 142 | if message == "" { | |
| 143 | 143 | return len(p), nil | |
| 144 | 144 | } | |
| 145 | - | ||
| 145 | + | ||
| 146 | 146 | l.writeLog(INFO, message, "") | |
| 147 | 147 | return len(p), nil | |
| 148 | 148 | } | |
| @@ -225,4 +225,4 @@ func CaptureWebKitLog(message string) { | |||
| 225 | 225 | // LogLevelInfo returns the INFO log level constant for external packages | |
| 226 | 226 | func LogLevelInfo() LogLevel { | |
| 227 | 227 | 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 stdout | 107 | // 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), nil | 143 | return len(p), nil | ||
| 144 | } | 144 | } | ||
| 145 | - | 145 | + | ||
| 146 | l.writeLog(INFO, message, "") | 146 | l.writeLog(INFO, message, "") | ||
| 147 | return len(p), nil | 147 | return len(p), nil | ||
| 148 | } | 148 | } | ||
| @@ -225,4 +225,4 @@ func CaptureWebKitLog(message string) { | |||||
| 225 | // LogLevelInfo returns the INFO log level constant for external packages | 225 | // LogLevelInfo returns the INFO log level constant for external packages | ||
| 226 | func LogLevelInfo() LogLevel { | 226 | func LogLevelInfo() LogLevel { | ||
| 227 | return INFO | 227 | return INFO | ||
| 228 | - | }⏎̸ | 228 | + | } |
M internal/logging/rotator.go +4 -4
| @@ -43,7 +43,7 @@ func NewLogRotator(baseDir string, maxSizeMB, maxBackups, maxAgeDays int, compre | |||
| 43 | 43 | ||
| 44 | 44 | func (r *LogRotator) openCurrentFile() error { | |
| 45 | 45 | logPath := filepath.Join(r.baseDir, r.baseName) | |
| 46 | - | ||
| 46 | + | ||
| 47 | 47 | // Get current file size if it exists | |
| 48 | 48 | if info, err := os.Stat(logPath); err == nil { | |
| 49 | 49 | r.currentSize = info.Size() | |
| @@ -92,7 +92,7 @@ func (r *LogRotator) rotate() error { | |||
| 92 | 92 | // Generate backup filename with timestamp | |
| 93 | 93 | timestamp := time.Now().Format("2006-01-02-15-04-05") | |
| 94 | 94 | backupName := fmt.Sprintf("%s.%s", r.baseName, timestamp) | |
| 95 | - | ||
| 95 | + | ||
| 96 | 96 | currentPath := filepath.Join(r.baseDir, r.baseName) | |
| 97 | 97 | backupPath := filepath.Join(r.baseDir, backupName) | |
| 98 | 98 | ||
| @@ -191,9 +191,9 @@ func (r *LogRotator) cleanup() { | |||
| 191 | 191 | func (r *LogRotator) Close() error { | |
| 192 | 192 | r.mu.Lock() | |
| 193 | 193 | defer r.mu.Unlock() | |
| 194 | - | ||
| 194 | + | ||
| 195 | 195 | if r.currentFile != nil { | |
| 196 | 196 | return r.currentFile.Close() | |
| 197 | 197 | } | |
| 198 | 198 | 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 exists | 47 | // 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 timestamp | 92 | // 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 nil | 198 | return nil | ||
| 199 | - | }⏎̸ | 199 | + | } |
M internal/migrations/migrations.go +1 -1
| @@ -187,4 +187,4 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) { | |||
| 187 | 187 | } | |
| 188 | 188 | ||
| 189 | 189 | return versions, nil | |
| 190 | - | }⏎̸ | |
| 190 | + | } | |
| @@ -187,4 +187,4 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) { | |||||
| 187 | } | 187 | } | ||
| 188 | 188 | ||||
| 189 | return versions, nil | 189 | return versions, nil | ||
| 190 | - | }⏎̸ | 190 | + | } |
M internal/parser/parser.go +1 -1
| @@ -127,7 +127,7 @@ func (p *Parser) parseInputUsingCLILogic(input string) (string, error) { | |||
| 127 | 127 | if commonTLDs.MatchString(input) { | |
| 128 | 128 | return "https://" + input, nil | |
| 129 | 129 | } | |
| 130 | - | ||
| 130 | + | ||
| 131 | 131 | // If it has a dot but uncommon TLD, still try as URL | |
| 132 | 132 | return "https://" + input, nil | |
| 133 | 133 | } | |
| @@ -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, nil | 128 | return "https://" + input, nil | ||
| 129 | } | 129 | } | ||
| 130 | - | 130 | + | ||
| 131 | // If it has a dot but uncommon TLD, still try as URL | 131 | // If it has a dot but uncommon TLD, still try as URL | ||
| 132 | return "https://" + input, nil | 132 | return "https://" + input, nil | ||
| 133 | } | 133 | } | ||
M main.go +156 -17
| @@ -42,11 +42,11 @@ const ( | |||
| 42 | 42 | ||
| 43 | 43 | // Parsing and utility constants | |
| 44 | 44 | 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 | |
| 47 | 47 | // 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() | |
| 50 | 50 | ) | |
| 51 | 51 | ||
| 52 | 52 | func main() { | |
| @@ -165,20 +165,20 @@ func runBrowser() { | |||
| 165 | 165 | // Demote VAAPI-related elements so they won't be auto-picked | |
| 166 | 166 | if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" { | |
| 167 | 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) | |
| 169 | - | } | |
| 168 | + | log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err) | |
| 169 | + | } | |
| 170 | 170 | } | |
| 171 | 171 | // Provide a safe audio sink to avoid autoaudiosink failures | |
| 172 | 172 | if os.Getenv("GST_AUDIO_SINK") == "" { | |
| 173 | 173 | 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 | + | } | |
| 176 | 176 | } | |
| 177 | 177 | // Optionally disable media stream features that can engage complex pipelines | |
| 178 | 178 | if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" { | |
| 179 | 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) | |
| 181 | - | } | |
| 180 | + | log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err) | |
| 181 | + | } | |
| 182 | 182 | } | |
| 183 | 183 | log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)") | |
| 184 | 184 | } | |
| @@ -191,7 +191,10 @@ func runBrowser() { | |||
| 191 | 191 | os.Exit(1) | |
| 192 | 192 | } | |
| 193 | 193 | cfg := config.Get() | |
| 194 | - | ||
| 194 | + | ||
| 195 | + | // Apply codec preferences configuration | |
| 196 | + | applyCodecConfiguration(cfg.CodecPreferences) | |
| 197 | + | ||
| 195 | 198 | // Initialize logging system early | |
| 196 | 199 | if err := logging.Init( | |
| 197 | 200 | cfg.Logging.LogDir, | |
| @@ -260,10 +263,10 @@ func runBrowser() { | |||
| 260 | 263 | // Track current URL for zoom persistence | |
| 261 | 264 | // Initialize to the initial homepage so shortcuts work immediately | |
| 262 | 265 | var currentURL string = "dumb://homepage" | |
| 263 | - | ||
| 266 | + | ||
| 264 | 267 | // Track last zoom domain to avoid showing toaster on every navigation within same domain | |
| 265 | 268 | var lastZoomDomain string | |
| 266 | - | ||
| 269 | + | ||
| 267 | 270 | // Flag to prevent showing toast when programmatically setting zoom (not user-initiated) | |
| 268 | 271 | var programmaticZoomChange bool | |
| 269 | 272 | ||
| @@ -451,6 +454,12 @@ func runBrowser() { | |||
| 451 | 454 | LegacyVAAPI: cfg.VideoAcceleration.LegacyVAAPI, | |
| 452 | 455 | }, | |
| 453 | 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 | 464 | if err != nil { | |
| 456 | 465 | log.Printf("Warning: failed to create WebView: %v", err) | |
| @@ -478,13 +487,13 @@ func runBrowser() { | |||
| 478 | 487 | } | |
| 479 | 488 | ctx := context.Background() | |
| 480 | 489 | currentDomain := services.ZoomKeyForLog(u) | |
| 481 | - | ||
| 490 | + | ||
| 482 | 491 | if z, err := browserService.GetZoomLevel(ctx, u); err == nil { | |
| 483 | 492 | programmaticZoomChange = true | |
| 484 | 493 | _ = view.SetZoom(z) | |
| 485 | 494 | programmaticZoomChange = false | |
| 486 | 495 | log.Printf("[zoom] loaded %.2f for %s", z, currentDomain) | |
| 487 | - | ||
| 496 | + | ||
| 488 | 497 | // Only show toast when entering a new domain (not on first load) | |
| 489 | 498 | if currentDomain != lastZoomDomain && lastZoomDomain != "" { | |
| 490 | 499 | js := fmt.Sprintf(`try { | |
| @@ -514,7 +523,7 @@ func runBrowser() { | |||
| 514 | 523 | } | |
| 515 | 524 | key := services.ZoomKeyForLog(url) | |
| 516 | 525 | log.Printf("[zoom] saved %.2f for %s", level, key) | |
| 517 | - | ||
| 526 | + | ||
| 518 | 527 | // Only show toast for user-initiated zoom changes, not programmatic ones | |
| 519 | 528 | if !programmaticZoomChange { | |
| 520 | 529 | js := fmt.Sprintf(`try { | |
| @@ -723,7 +732,7 @@ func runBrowser() { | |||
| 723 | 732 | _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`) | |
| 724 | 733 | return | |
| 725 | 734 | } | |
| 726 | - | ||
| 735 | + | ||
| 727 | 736 | if err := clipboard.CopyToClipboard(currentURL); err != nil { | |
| 728 | 737 | log.Printf("Failed to copy URL to clipboard: %v", err) | |
| 729 | 738 | _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`) | |
| @@ -926,6 +935,136 @@ func detectAndSetKeyboardLocale() { | |||
| 926 | 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 | 1068 | // getHomepageFaviconSVG returns the SVG used as a favicon for the homepage | |
| 930 | 1069 | // when WebKit requests dumb://homepage/favicon.ico. | |
| 931 | 1070 | // getHomepageFaviconSVG was inlined previously; now favicon.svg is emitted at build time. | |
| @@ -42,11 +42,11 @@ const ( | |||||
| 42 | 42 | ||||
| 43 | // Parsing and utility constants | 43 | // Parsing and utility constants | ||
| 44 | const ( | 44 | const ( | ||
| 45 | - | colonSplitParts = 2 // Number of parts when splitting on colon | 45 | + | colonSplitParts = 2 // Number of parts when splitting on colon |
| 46 | - | dirPerm = 0o755 // Directory permissions | 46 | + | 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-picked | 165 | // 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 failures | 171 | // 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 pipelines | 177 | // 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 early | 198 | // 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 persistence | 263 | // Track current URL for zoom persistence | ||
| 261 | // Initialize to the initial homepage so shortcuts work immediately | 264 | // 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 domain | 267 | // Track last zoom domain to avoid showing toaster on every navigation within same domain | ||
| 265 | var lastZoomDomain string | 268 | 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 bool | 271 | 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 = true | 492 | programmaticZoomChange = true | ||
| 484 | _ = view.SetZoom(z) | 493 | _ = view.SetZoom(z) | ||
| 485 | programmaticZoomChange = false | 494 | 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 ones | 527 | // 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 | return | 733 | 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 homepage | 1068 | // 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 { | |||
| 31 | 31 | func tryWlCopy(text string) error { | |
| 32 | 32 | cmd := exec.Command("wl-copy") | |
| 33 | 33 | cmd.Stdin = nil | |
| 34 | - | ||
| 34 | + | ||
| 35 | 35 | // Set up stdin pipe to pass the text | |
| 36 | 36 | stdin, err := cmd.StdinPipe() | |
| 37 | 37 | if err != nil { | |
| @@ -70,7 +70,7 @@ func tryWlCopy(text string) error { | |||
| 70 | 70 | // tryXclip attempts to copy text using xclip (X11 clipboard) | |
| 71 | 71 | func tryXclip(text string) error { | |
| 72 | 72 | cmd := exec.Command("xclip", "-selection", "clipboard") | |
| 73 | - | ||
| 73 | + | ||
| 74 | 74 | // Set up stdin pipe to pass the text | |
| 75 | 75 | stdin, err := cmd.StdinPipe() | |
| 76 | 76 | if err != nil { | |
| @@ -112,11 +112,11 @@ func IsAvailable() bool { | |||
| 112 | 112 | if _, err := exec.LookPath("wl-copy"); err == nil { | |
| 113 | 113 | return true | |
| 114 | 114 | } | |
| 115 | - | ||
| 115 | + | ||
| 116 | 116 | // Check if xclip is available | |
| 117 | 117 | if _, err := exec.LookPath("xclip"); err == nil { | |
| 118 | 118 | return true | |
| 119 | 119 | } | |
| 120 | - | ||
| 120 | + | ||
| 121 | 121 | 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 = nil | 33 | cmd.Stdin = nil | ||
| 34 | - | 34 | + | ||
| 35 | // Set up stdin pipe to pass the text | 35 | // 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 text | 74 | // 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 true | 113 | return true | ||
| 114 | } | 114 | } | ||
| 115 | - | 115 | + | ||
| 116 | // Check if xclip is available | 116 | // Check if xclip is available | ||
| 117 | if _, err := exec.LookPath("xclip"); err == nil { | 117 | if _, err := exec.LookPath("xclip"); err == nil { | ||
| 118 | return true | 118 | return true | ||
| 119 | } | 119 | } | ||
| 120 | - | 120 | + | ||
| 121 | return false | 121 | return false | ||
| 122 | - | }⏎̸ | 122 | + | } |
M pkg/clipboard/clipboard_test.go +9 -9
| @@ -12,7 +12,7 @@ func TestCopyToClipboard(t *testing.T) { | |||
| 12 | 12 | } | |
| 13 | 13 | ||
| 14 | 14 | testText := "https://example.com/test-url" | |
| 15 | - | ||
| 15 | + | ||
| 16 | 16 | err := CopyToClipboard(testText) | |
| 17 | 17 | if err != nil { | |
| 18 | 18 | t.Errorf("CopyToClipboard failed: %v", err) | |
| @@ -28,21 +28,21 @@ func TestCopyToClipboard_EmptyString(t *testing.T) { | |||
| 28 | 28 | ||
| 29 | 29 | func TestIsAvailable(t *testing.T) { | |
| 30 | 30 | available := IsAvailable() | |
| 31 | - | ||
| 31 | + | ||
| 32 | 32 | // Check if the result matches the actual availability | |
| 33 | 33 | wlCopyExists := false | |
| 34 | 34 | xclipExists := false | |
| 35 | - | ||
| 35 | + | ||
| 36 | 36 | if _, err := exec.LookPath("wl-copy"); err == nil { | |
| 37 | 37 | wlCopyExists = true | |
| 38 | 38 | } | |
| 39 | - | ||
| 39 | + | ||
| 40 | 40 | if _, err := exec.LookPath("xclip"); err == nil { | |
| 41 | 41 | xclipExists = true | |
| 42 | 42 | } | |
| 43 | - | ||
| 43 | + | ||
| 44 | 44 | expected := wlCopyExists || xclipExists | |
| 45 | - | ||
| 45 | + | ||
| 46 | 46 | if available != expected { | |
| 47 | 47 | t.Errorf("IsAvailable() = %v, expected %v", available, expected) | |
| 48 | 48 | } | |
| @@ -52,7 +52,7 @@ func TestTryWlCopy(t *testing.T) { | |||
| 52 | 52 | if _, err := exec.LookPath("wl-copy"); err != nil { | |
| 53 | 53 | t.Skip("wl-copy not available, skipping test") | |
| 54 | 54 | } | |
| 55 | - | ||
| 55 | + | ||
| 56 | 56 | err := tryWlCopy("test content") | |
| 57 | 57 | if err != nil { | |
| 58 | 58 | t.Errorf("tryWlCopy failed: %v", err) | |
| @@ -63,9 +63,9 @@ func TestTryXclip(t *testing.T) { | |||
| 63 | 63 | if _, err := exec.LookPath("xclip"); err != nil { | |
| 64 | 64 | t.Skip("xclip not available, skipping test") | |
| 65 | 65 | } | |
| 66 | - | ||
| 66 | + | ||
| 67 | 67 | err := tryXclip("test content") | |
| 68 | 68 | if err != nil { | |
| 69 | 69 | t.Errorf("tryXclip failed: %v", err) | |
| 70 | 70 | } | |
| 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 availability | 32 | // Check if the result matches the actual availability | ||
| 33 | wlCopyExists := false | 33 | wlCopyExists := false | ||
| 34 | xclipExists := false | 34 | xclipExists := false | ||
| 35 | - | 35 | + | ||
| 36 | if _, err := exec.LookPath("wl-copy"); err == nil { | 36 | if _, err := exec.LookPath("wl-copy"); err == nil { | ||
| 37 | wlCopyExists = true | 37 | wlCopyExists = true | ||
| 38 | } | 38 | } | ||
| 39 | - | 39 | + | ||
| 40 | if _, err := exec.LookPath("xclip"); err == nil { | 40 | if _, err := exec.LookPath("xclip"); err == nil { | ||
| 41 | xclipExists = true | 41 | xclipExists = true | ||
| 42 | } | 42 | } | ||
| 43 | - | 43 | + | ||
| 44 | expected := wlCopyExists || xclipExists | 44 | 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 { | |||
| 38 | 38 | // DetectGPU attempts to detect the GPU vendor and driver using multiple methods | |
| 39 | 39 | func DetectGPU() GPUInfo { | |
| 40 | 40 | logging.Debug("Starting GPU detection") | |
| 41 | - | ||
| 41 | + | ||
| 42 | 42 | // Try methods in order of reliability | |
| 43 | 43 | methods := []func() GPUInfo{ | |
| 44 | 44 | detectViaGLXInfo, | |
| @@ -200,7 +200,7 @@ func extractDeviceName(line string) string { | |||
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | 202 | name := strings.TrimSpace(parts[len(parts)-1]) | |
| 203 | - | ||
| 203 | + | ||
| 204 | 204 | // Remove revision info in parentheses | |
| 205 | 205 | if idx := strings.Index(name, " (rev "); idx != -1 { | |
| 206 | 206 | name = name[:idx] | |
| @@ -230,4 +230,262 @@ func (g GPUInfo) GetVAAPIDriverName() string { | |||
| 230 | 230 | // SupportsVAAPI returns true if the GPU vendor supports VA-API | |
| 231 | 231 | func (g GPUInfo) SupportsVAAPI() bool { | |
| 232 | 232 | 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 methods | 38 | // 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 reliability | 42 | // 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 parentheses | 204 | // 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-API | 230 | // 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 == GPUVendorNVIDIA | 232 | 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{} | |||
| 21 | 21 | func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) { | |
| 22 | 22 | goDomain := C.GoString(domain) | |
| 23 | 23 | goMessage := C.GoString(message) | |
| 24 | - | ||
| 24 | + | ||
| 25 | 25 | var levelStr string | |
| 26 | 26 | switch level { | |
| 27 | 27 | case C.G_LOG_LEVEL_ERROR: | |
| @@ -39,12 +39,12 @@ func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) { | |||
| 39 | 39 | default: | |
| 40 | 40 | levelStr = "UNKNOWN" | |
| 41 | 41 | } | |
| 42 | - | ||
| 42 | + | ||
| 43 | 43 | logMessage := goMessage | |
| 44 | 44 | if goDomain != "" { | |
| 45 | 45 | logMessage = "[" + goDomain + "] " + goMessage | |
| 46 | 46 | } | |
| 47 | - | ||
| 47 | + | ||
| 48 | 48 | // Send to logging system with appropriate level | |
| 49 | 49 | if logger := logging.GetLogger(); logger != nil { | |
| 50 | 50 | logger.WriteTagged("WEBKIT-"+levelStr, logMessage) | |
| @@ -56,19 +56,19 @@ func InitWebKitLogCapture() error { | |||
| 56 | 56 | if webkitCaptureActive { | |
| 57 | 57 | return nil | |
| 58 | 58 | } | |
| 59 | - | ||
| 59 | + | ||
| 60 | 60 | // Initialize C-level log capture | |
| 61 | 61 | C.webkit_init_log_capture() | |
| 62 | - | ||
| 62 | + | ||
| 63 | 63 | // Set up GLib log handlers | |
| 64 | 64 | C.webkit_setup_glib_log_handlers() | |
| 65 | - | ||
| 65 | + | ||
| 66 | 66 | // Create channel for stopping capture | |
| 67 | 67 | webkitCaptureChan = make(chan struct{}) | |
| 68 | - | ||
| 68 | + | ||
| 69 | 69 | // Start goroutine to read captured C output | |
| 70 | 70 | go webkitLogReader() | |
| 71 | - | ||
| 71 | + | ||
| 72 | 72 | webkitCaptureActive = true | |
| 73 | 73 | return nil | |
| 74 | 74 | } | |
| @@ -78,20 +78,20 @@ func StopWebKitLogCapture() { | |||
| 78 | 78 | if !webkitCaptureActive { | |
| 79 | 79 | return | |
| 80 | 80 | } | |
| 81 | - | ||
| 81 | + | ||
| 82 | 82 | // Signal the reader goroutine to stop | |
| 83 | 83 | close(webkitCaptureChan) | |
| 84 | - | ||
| 84 | + | ||
| 85 | 85 | // Stop C-level capture | |
| 86 | 86 | C.webkit_stop_log_capture() | |
| 87 | - | ||
| 87 | + | ||
| 88 | 88 | webkitCaptureActive = false | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | 91 | // webkitLogReader reads captured C output in a goroutine | |
| 92 | 92 | func webkitLogReader() { | |
| 93 | 93 | buffer := make([]byte, 4096) | |
| 94 | - | ||
| 94 | + | ||
| 95 | 95 | for { | |
| 96 | 96 | select { | |
| 97 | 97 | case <-webkitCaptureChan: | |
| @@ -105,7 +105,7 @@ func webkitLogReader() { | |||
| 105 | 105 | logger.WriteTagged("WEBKIT-PRINTF", message) | |
| 106 | 106 | } | |
| 107 | 107 | } | |
| 108 | - | ||
| 108 | + | ||
| 109 | 109 | // Small delay to prevent busy waiting | |
| 110 | 110 | time.Sleep(10 * time.Millisecond) | |
| 111 | 111 | } | |
| @@ -122,4 +122,4 @@ func StartWebKitOutputCapture() { | |||
| 122 | 122 | // IsWebKitLogCaptureActive returns whether WebKit log capture is active | |
| 123 | 123 | func IsWebKitLogCaptureActive() bool { | |
| 124 | 124 | 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 string | 25 | 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 := goMessage | 43 | logMessage := goMessage | ||
| 44 | if goDomain != "" { | 44 | if goDomain != "" { | ||
| 45 | logMessage = "[" + goDomain + "] " + goMessage | 45 | logMessage = "[" + goDomain + "] " + goMessage | ||
| 46 | } | 46 | } | ||
| 47 | - | 47 | + | ||
| 48 | // Send to logging system with appropriate level | 48 | // 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 nil | 57 | return nil | ||
| 58 | } | 58 | } | ||
| 59 | - | 59 | + | ||
| 60 | // Initialize C-level log capture | 60 | // Initialize C-level log capture | ||
| 61 | C.webkit_init_log_capture() | 61 | C.webkit_init_log_capture() | ||
| 62 | - | 62 | + | ||
| 63 | // Set up GLib log handlers | 63 | // 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 capture | 66 | // Create channel for stopping capture | ||
| 67 | webkitCaptureChan = make(chan struct{}) | 67 | webkitCaptureChan = make(chan struct{}) | ||
| 68 | - | 68 | + | ||
| 69 | // Start goroutine to read captured C output | 69 | // Start goroutine to read captured C output | ||
| 70 | go webkitLogReader() | 70 | go webkitLogReader() | ||
| 71 | - | 71 | + | ||
| 72 | webkitCaptureActive = true | 72 | webkitCaptureActive = true | ||
| 73 | return nil | 73 | return nil | ||
| 74 | } | 74 | } | ||
| @@ -78,20 +78,20 @@ func StopWebKitLogCapture() { | |||||
| 78 | if !webkitCaptureActive { | 78 | if !webkitCaptureActive { | ||
| 79 | return | 79 | return | ||
| 80 | } | 80 | } | ||
| 81 | - | 81 | + | ||
| 82 | // Signal the reader goroutine to stop | 82 | // Signal the reader goroutine to stop | ||
| 83 | close(webkitCaptureChan) | 83 | close(webkitCaptureChan) | ||
| 84 | - | 84 | + | ||
| 85 | // Stop C-level capture | 85 | // Stop C-level capture | ||
| 86 | C.webkit_stop_log_capture() | 86 | C.webkit_stop_log_capture() | ||
| 87 | - | 87 | + | ||
| 88 | webkitCaptureActive = false | 88 | webkitCaptureActive = false | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | // webkitLogReader reads captured C output in a goroutine | 91 | // 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 waiting | 109 | // 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 active | 122 | // IsWebKitLogCaptureActive returns whether WebKit log capture is active | ||
| 123 | func IsWebKitLogCaptureActive() bool { | 123 | func IsWebKitLogCaptureActive() bool { | ||
| 124 | return webkitCaptureActive | 124 | return webkitCaptureActive | ||
| 125 | - | }⏎̸ | 125 | + | } |
M pkg/webkit/log_capture_stub.go +1 -1
| @@ -21,4 +21,4 @@ func StartWebKitOutputCapture() { | |||
| 21 | 21 | // IsWebKitLogCaptureActive returns whether WebKit log capture is active (stub version) | |
| 22 | 22 | func IsWebKitLogCaptureActive() bool { | |
| 23 | 23 | 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 false | 23 | return false | ||
| 24 | - | }⏎̸ | 24 | + | } |
M pkg/webkit/settings.go +11 -0
| @@ -34,6 +34,17 @@ type Config struct { | |||
| 34 | 34 | ||
| 35 | 35 | // Memory controls memory optimization settings | |
| 36 | 36 | 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 | 50 | // RenderingConfig controls hardware acceleration preferences. | |
| @@ -34,6 +34,17 @@ type Config struct { | |||||
| 34 | 34 | ||||
| 35 | // Memory controls memory optimization settings | 35 | // Memory controls memory optimization settings | ||
| 36 | Memory MemoryConfig | 36 | 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, | |||
| 40 | 40 | gpointer user_data); | |
| 41 | 41 | ||
| 42 | 42 | // 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 | 44 | WebKitNavigationAction* navigation_action, | |
| 45 | 45 | gpointer user_data) { | |
| 46 | 46 | (void)user_data; | |
| 47 | - | ||
| 47 | + | ||
| 48 | 48 | // Get the request from navigation action | |
| 49 | 49 | WebKitURIRequest* request = webkit_navigation_action_get_request(navigation_action); | |
| 50 | 50 | if (request) { | |
| @@ -54,7 +54,7 @@ static WebKitWebView* on_create_new_window(WebKitWebView* web_view, | |||
| 54 | 54 | webkit_web_view_load_uri(web_view, uri); | |
| 55 | 55 | } | |
| 56 | 56 | } | |
| 57 | - | ||
| 57 | + | ||
| 58 | 58 | // Return NULL to prevent new window creation | |
| 59 | 59 | return NULL; | |
| 60 | 60 | } | |
| @@ -78,7 +78,7 @@ static void setup_video_acceleration(const char* driver_name, int enable_all, in | |||
| 78 | 78 | // Don't set other env vars in legacy mode to avoid conflicts | |
| 79 | 79 | return; | |
| 80 | 80 | } | |
| 81 | - | ||
| 81 | + | ||
| 82 | 82 | // Modern mode (default): use improved VA-API handling | |
| 83 | 83 | if (driver_name && strlen(driver_name) > 0) { | |
| 84 | 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 | 93 | setenv("LIBVA_DRIVER_NAME", driver_name, 1); | |
| 94 | 94 | } | |
| 95 | 95 | } | |
| 96 | - | ||
| 96 | + | ||
| 97 | 97 | if (enable_all) { | |
| 98 | 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 | 135 | // Set WebView background to black (easier on eyes when pages are loading) | |
| 136 | 136 | GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 }; | |
| 137 | 137 | webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color); | |
| 138 | - | ||
| 138 | + | ||
| 139 | 139 | // Handle create signal to redirect _blank links to current window | |
| 140 | 140 | g_signal_connect(G_OBJECT(w), "create", G_CALLBACK(on_create_new_window), NULL); | |
| 141 | - | ||
| 141 | + | ||
| 142 | 142 | // Connect TLS error handler | |
| 143 | 143 | connect_tls_error_handler(WEBKIT_WEB_VIEW(w)); | |
| 144 | 144 | if (out_ucm) { *out_ucm = u; } | |
| @@ -412,7 +412,7 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view, | |||
| 412 | 412 | // Extract host from URI for the Go callback | |
| 413 | 413 | const char* host = failing_uri; | |
| 414 | 414 | char* host_copy = NULL; | |
| 415 | - | ||
| 415 | + | ||
| 416 | 416 | if (strstr(failing_uri, "://")) { | |
| 417 | 417 | host = strstr(failing_uri, "://") + 3; | |
| 418 | 418 | char* slash = strchr(host, '/'); | |
| @@ -430,7 +430,7 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view, | |||
| 430 | 430 | ||
| 431 | 431 | // Extract certificate information | |
| 432 | 432 | char* cert_info = extract_certificate_info(certificate); | |
| 433 | - | ||
| 433 | + | ||
| 434 | 434 | gboolean should_proceed = goHandleTLSError((char*)failing_uri, (char*)host, (int)errors, cert_info); | |
| 435 | 435 | ||
| 436 | 436 | // 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 | 443 | webkit_network_session_allow_tls_certificate_for_host(session, certificate, host); | |
| 444 | 444 | printf("[dumber] Certificate exception added for host: %s\n", host); | |
| 445 | 445 | fflush(stdout); | |
| 446 | - | ||
| 446 | + | ||
| 447 | 447 | // Try loading the URL again after adding the exception | |
| 448 | 448 | webkit_web_view_load_uri(web_view, failing_uri); | |
| 449 | 449 | printf("[dumber] Triggered new load of %s with certificate exception\n", failing_uri); | |
| 450 | 450 | fflush(stdout); | |
| 451 | 451 | } | |
| 452 | 452 | } | |
| 453 | - | ||
| 453 | + | ||
| 454 | 454 | // Free the certificate info string | |
| 455 | 455 | if (cert_info) { | |
| 456 | 456 | g_free(cert_info); | |
| @@ -468,18 +468,18 @@ static char* extract_certificate_info(GTlsCertificate* certificate) { | |||
| 468 | 468 | if (!certificate) { | |
| 469 | 469 | return g_strdup("Certificate information not available"); | |
| 470 | 470 | } | |
| 471 | - | ||
| 471 | + | ||
| 472 | 472 | printf("[dumber] Extracting certificate information...\n"); | |
| 473 | 473 | fflush(stdout); | |
| 474 | - | ||
| 474 | + | ||
| 475 | 475 | GString* info = g_string_new("Certificate Information:\n"); | |
| 476 | - | ||
| 476 | + | ||
| 477 | 477 | // Try to get certificate properties - but these might not be available in all GTK versions | |
| 478 | 478 | gchar* subject = NULL; | |
| 479 | 479 | gchar* issuer = NULL; | |
| 480 | 480 | GDateTime* not_valid_before = NULL; | |
| 481 | 481 | GDateTime* not_valid_after = NULL; | |
| 482 | - | ||
| 482 | + | ||
| 483 | 483 | // Try property access but handle failures gracefully | |
| 484 | 484 | g_object_get(certificate, | |
| 485 | 485 | "subject-name", &subject, | |
| @@ -487,7 +487,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) { | |||
| 487 | 487 | "not-valid-before", ¬_valid_before, | |
| 488 | 488 | "not-valid-after", ¬_valid_after, | |
| 489 | 489 | NULL); | |
| 490 | - | ||
| 490 | + | ||
| 491 | 491 | if (subject) { | |
| 492 | 492 | g_string_append_printf(info, "Subject: %s\n", subject); | |
| 493 | 493 | g_free(subject); | |
| @@ -495,7 +495,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) { | |||
| 495 | 495 | } else { | |
| 496 | 496 | g_string_append(info, "Subject: Information not available\n"); | |
| 497 | 497 | } | |
| 498 | - | ||
| 498 | + | ||
| 499 | 499 | if (issuer) { | |
| 500 | 500 | g_string_append_printf(info, "Issued by: %s\n", issuer); | |
| 501 | 501 | g_free(issuer); | |
| @@ -503,7 +503,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) { | |||
| 503 | 503 | } else { | |
| 504 | 504 | g_string_append(info, "Issued by: Information not available\n"); | |
| 505 | 505 | } | |
| 506 | - | ||
| 506 | + | ||
| 507 | 507 | if (not_valid_before) { | |
| 508 | 508 | gchar* date_str = g_date_time_format(not_valid_before, "%Y-%m-%d %H:%M:%S UTC"); | |
| 509 | 509 | g_string_append_printf(info, "Valid from: %s\n", date_str); | |
| @@ -512,7 +512,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) { | |||
| 512 | 512 | } else { | |
| 513 | 513 | g_string_append(info, "Valid from: Information not available\n"); | |
| 514 | 514 | } | |
| 515 | - | ||
| 515 | + | ||
| 516 | 516 | if (not_valid_after) { | |
| 517 | 517 | gchar* date_str = g_date_time_format(not_valid_after, "%Y-%m-%d %H:%M:%S UTC"); | |
| 518 | 518 | g_string_append_printf(info, "Valid until: %s\n", date_str); | |
| @@ -521,7 +521,7 @@ static char* extract_certificate_info(GTlsCertificate* certificate) { | |||
| 521 | 521 | } else { | |
| 522 | 522 | g_string_append(info, "Valid until: Information not available\n"); | |
| 523 | 523 | } | |
| 524 | - | ||
| 524 | + | ||
| 525 | 525 | char* result = g_string_free(info, FALSE); | |
| 526 | 526 | printf("[dumber] Certificate info: %s\n", result); | |
| 527 | 527 | fflush(stdout); | |
| @@ -539,7 +539,7 @@ static void on_tls_dialog_response(GtkDialog *dialog, gint response_id, gpointer | |||
| 539 | 539 | TLSDialogData *data = (TLSDialogData*)user_data; | |
| 540 | 540 | data->user_accepted = (response_id == GTK_RESPONSE_ACCEPT); | |
| 541 | 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 | 543 | data->user_accepted ? "ACCEPTED" : "REJECTED", response_id); | |
| 544 | 544 | fflush(stdout); | |
| 545 | 545 | gtk_window_destroy(GTK_WINDOW(dialog)); | |
| @@ -550,7 +550,7 @@ static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* host | |||
| 550 | 550 | printf("[dumber] Creating TLS warning dialog for hostname: %s\n", hostname); | |
| 551 | 551 | printf("[dumber] Error message: %s\n", error_msg); | |
| 552 | 552 | fflush(stdout); | |
| 553 | - | ||
| 553 | + | ||
| 554 | 554 | // Create the dialog | |
| 555 | 555 | GtkWidget *dialog = gtk_message_dialog_new( | |
| 556 | 556 | parent, | |
| @@ -904,6 +904,24 @@ func NewWebView(cfg *Config) (*WebView, error) { | |||
| 904 | 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 | 925 | // Apply default fonts if provided in cfg | |
| 908 | 926 | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 909 | 927 | if cfg.DefaultSansFont != "" { | |
| @@ -938,7 +956,7 @@ func NewWebView(cfg *Config) (*WebView, error) { | |||
| 938 | 956 | ||
| 939 | 957 | // Initialize UCM scripts and handlers | |
| 940 | 958 | if v.native != nil && v.native.ucm != nil { | |
| 941 | - | v.enableUserContentManager() | |
| 959 | + | v.enableUserContentManager(cfg) | |
| 942 | 960 | } | |
| 943 | 961 | ||
| 944 | 962 | // Start periodic JavaScript garbage collection if configured | |
| @@ -1112,7 +1130,7 @@ func (w *WebView) dispatchZoomChanged(level float64) { | |||
| 1112 | 1130 | } | |
| 1113 | 1131 | ||
| 1114 | 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 | 1134 | if w == nil || w.native == nil || w.native.ucm == nil { | |
| 1117 | 1135 | return | |
| 1118 | 1136 | } | |
| @@ -1175,6 +1193,23 @@ func (w *WebView) enableUserContentManager() { | |||
| 1175 | 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 | 1213 | // No JS fallback bridge — native UCM is active | |
| 1179 | 1214 | } | |
| 1180 | 1215 | ||
| @@ -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 window | 42 | // 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 action | 48 | // 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 creation | 58 | // 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 conflicts | 78 | // 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 handling | 82 | // 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 window | 139 | // 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 handler | 142 | // 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 callback | 412 | // 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 information | 431 | // 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 host | 436 | // 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 exception | 447 | // 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 string | 454 | // 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 versions | 477 | // 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 gracefully | 483 | // 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", ¬_valid_before, | 487 | "not-valid-before", ¬_valid_before, | ||
| 488 | "not-valid-after", ¬_valid_after, | 488 | "not-valid-after", ¬_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 dialog | 554 | // 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 cfg | 925 | // 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 handlers | 957 | // 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 configured | 962 | // 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 | return | 1135 | 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 active | 1213 | // 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 | |||
| 373 | 373 | ||
| 374 | 374 | // Extract domain from URL (zoom is stored per domain, not full URL) | |
| 375 | 375 | domain := extractDomain(url) | |
| 376 | - | ||
| 376 | + | ||
| 377 | 377 | // Get zoom level from database | |
| 378 | 378 | zoom, err := queries.GetZoomLevel(ctx, domain) | |
| 379 | 379 | 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 database | 377 | // 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 { | ||