merge conflicts

This commit is contained in:
Siiiiinth
2026-02-09 08:11:05 +01:00
56 changed files with 4364 additions and 369 deletions
+72 -15
View File
@@ -46,17 +46,19 @@ var (
)
type App struct {
Config *Config
ServerManager *ServerManager
LyricsManager *LyricsManager
ImageManager *ImageManager
AudioCache *AudioCache
PlaybackManager *PlaybackManager
LocalPlayer *mpv.Player
UpdateChecker UpdateChecker
MPRISHandler *MPRISHandler
WinSMTC *windows.SMTC
ipcServer ipc.IPCServer
Config *Config
ServerManager *ServerManager
LyricsManager *LyricsManager
ImageManager *ImageManager
AudioCache *AudioCache
AutoEQManager *AutoEQManager
EQPresetManager *EQPresetManager
PlaybackManager *PlaybackManager
LocalPlayer *mpv.Player
UpdateChecker UpdateChecker
MPRISHandler *MPRISHandler
WinSMTC *windows.SMTC
ipcServer ipc.IPCServer
// UI callbacks to be set in main
OnReactivate func()
@@ -168,6 +170,11 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
fetch = NewLrcLibFetcher(a.cacheDir, a.Config.Application.CustomLrcLibUrl, timeout)
}
a.LyricsManager = NewLyricsManager(a.ServerManager, fetch)
a.EQPresetManager = NewEQPresetManager(confDir)
// Initialize AutoEQ manager
autoEQTimeout := time.Duration(a.Config.Application.RequestTimeoutSeconds) * time.Second
a.AutoEQManager = NewAutoEQManager(filepath.Join(cacheDir, "autoeq"), autoEQTimeout)
// Periodically scan for remote players
go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/)
@@ -206,8 +213,21 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
ipc.DestroyConn() // cleanup socket possibly orphaned by crashed process
listener, err := ipc.Listen()
if err == nil {
ipcRatingHandler := func(rating int) {
if s := a.ServerManager.GetServer(); s != nil {
if tr := a.PlaybackManager.NowPlaying(); tr != nil && tr.Metadata().Type == mediaprovider.MediaItemTypeTrack {
if supportsRating, ok := s.(mediaprovider.SupportsRating); ok {
supportsRating.SetRating(mediaprovider.RatingFavoriteParameters{
TrackIDs: []string{tr.Metadata().ID},
}, rating)
}
}
}
}
a.ipcServer = ipc.NewServer(
a.PlaybackManager,
ipcRatingHandler,
a.ServerManager,
a.callOnReactivate,
func() { _ = a.callOnExit() })
@@ -389,11 +409,31 @@ func (a *App) setupMPV() error {
a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive)
a.LocalPlayer.SetPauseFade(a.Config.LocalPlayback.PauseFade)
eq := &mpv.ISO15BandEqualizer{
EQPreamp: a.Config.LocalPlayback.EqualizerPreamp,
Disabled: !a.Config.LocalPlayback.EqualizerEnabled,
// Initialize the appropriate equalizer type based on config
var eq mpv.Equalizer
if a.Config.LocalPlayback.EqualizerType == "ISO10Band" {
eq10 := &mpv.ISO10BandEqualizer{
EQPreamp: a.Config.LocalPlayback.EqualizerPreamp,
Disabled: !a.Config.LocalPlayback.EqualizerEnabled,
}
// Copy up to 10 bands
numBands := min(len(a.Config.LocalPlayback.GraphicEqualizerBands), 10)
for i := 0; i < numBands; i++ {
eq10.BandGains[i] = a.Config.LocalPlayback.GraphicEqualizerBands[i]
}
eq = eq10
} else {
eq15 := &mpv.ISO15BandEqualizer{
EQPreamp: a.Config.LocalPlayback.EqualizerPreamp,
Disabled: !a.Config.LocalPlayback.EqualizerEnabled,
}
// Copy up to 15 bands
numBands := min(len(a.Config.LocalPlayback.GraphicEqualizerBands), 15)
for i := 0; i < numBands; i++ {
eq15.BandGains[i] = a.Config.LocalPlayback.GraphicEqualizerBands[i]
}
eq = eq15
}
copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands)
a.LocalPlayer.SetEqualizer(eq)
return nil
@@ -452,6 +492,15 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) {
}
}()
})
a.PlaybackManager.OnRadioMetadataChange(func(radioName, title, artist string) {
if title != "" {
smtc.UpdateMetadata(title, artist)
smtc.UpdatePosition(0, 0)
} else {
smtc.UpdateMetadata(radioName, "")
smtc.UpdatePosition(0, 0)
}
})
a.PlaybackManager.OnSeek(func() {
playbackStatus := a.PlaybackManager.PlaybackStatus()
smtc.UpdatePosition(int(playbackStatus.TimePos*1000), int(playbackStatus.Duration*1000))
@@ -507,6 +556,12 @@ func (a *App) DeleteServerCacheDir(serverID uuid.UUID) error {
return os.RemoveAll(path)
}
// BackgroundContext returns the application's background context
// which is canceled when the application shuts down.
func (a *App) BackgroundContext() context.Context {
return a.bgrndCtx
}
func (a *App) Shutdown() {
if a.logFile != nil {
a.logFile.Close()
@@ -686,6 +741,8 @@ func (a *App) checkFlagsAndSendIPCMsg(cli *ipc.Client) error {
fmt.Println(data)
}
return err
case RateCurrentCLIArg >= 0:
return cli.RateCurrentTrack(RateCurrentCLIArg)
default:
return nil
}
+515
View File
@@ -0,0 +1,515 @@
package backend
import (
"bufio"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/20after4/configdir"
)
const (
autoEQIndexURL = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results/INDEX.md"
autoEQBaseURL = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results/"
indexCacheTTL = 7 * 24 * time.Hour // 7 days
profileCacheTTL = 30 * 24 * time.Hour // 30 days
maxMemoryCacheSize = 20 // LRU cache size
)
var (
ErrProfileNotFound = errors.New("AutoEQ profile not found")
ErrInvalidFormat = errors.New("invalid AutoEQ profile format")
)
// AutoEQProfile represents a headphone equalizer profile from AutoEQ
type AutoEQProfile struct {
Name string // Display name (e.g., "Sennheiser HD 650")
Path string // Full path in repo (e.g., "oratory1990/over-ear/Sennheiser HD 650")
Source string // Measurement source (e.g., "oratory1990")
Type string // Headphone type (e.g., "over-ear")
Preamp float64 // Preamp gain in dB
Bands [10]float64 // 10-band equalizer gains in dB
}
// AutoEQProfileMetadata contains just the metadata without the EQ data
type AutoEQProfileMetadata struct {
Name string
Path string
Source string
Type string
}
// AutoEQManager manages fetching and caching of AutoEQ profiles
type AutoEQManager struct {
cachePath string
timeout time.Duration
// Memory cache (LRU)
memCache map[string]*memoryCacheEntry
memCacheMutex sync.RWMutex
memCacheLRU []string // Keys in LRU order (most recently used at end)
// Index cache
indexCache []AutoEQProfileMetadata
indexCacheTime time.Time
indexCacheMutex sync.RWMutex
}
type memoryCacheEntry struct {
profile *AutoEQProfile
lastAccessed time.Time
}
// NewAutoEQManager creates a new AutoEQ manager
func NewAutoEQManager(cachePath string, timeout time.Duration) *AutoEQManager {
configdir.MakePath(cachePath)
log.Printf("Initializing AutoEQ manager: cache=%s, timeout=%v", cachePath, timeout)
return &AutoEQManager{
cachePath: cachePath,
timeout: timeout,
memCache: make(map[string]*memoryCacheEntry),
memCacheLRU: make([]string, 0, maxMemoryCacheSize),
}
}
// FetchIndex fetches the list of all available AutoEQ profiles
// Results are cached for 7 days
func (m *AutoEQManager) FetchIndex(ctx context.Context) ([]AutoEQProfileMetadata, error) {
// Check if index is already cached in memory
m.indexCacheMutex.RLock()
if len(m.indexCache) > 0 && time.Since(m.indexCacheTime) < indexCacheTTL {
cached := m.indexCache
m.indexCacheMutex.RUnlock()
return cached, nil
}
m.indexCacheMutex.RUnlock()
// Try to load from disk cache
indexCachePath := filepath.Join(m.cachePath, "index.json")
if profiles, err := m.loadIndexFromDisk(indexCachePath); err == nil {
m.indexCacheMutex.Lock()
m.indexCache = profiles
m.indexCacheTime = time.Now()
m.indexCacheMutex.Unlock()
return profiles, nil
}
// Fetch from network
profiles, err := m.fetchIndexFromNetwork(ctx)
if err != nil {
return nil, err
}
log.Printf("Successfully fetched %d AutoEQ profiles", len(profiles))
// Cache in memory and disk
m.indexCacheMutex.Lock()
m.indexCache = profiles
m.indexCacheTime = time.Now()
m.indexCacheMutex.Unlock()
m.saveIndexToDisk(indexCachePath, profiles)
return profiles, nil
}
func (m *AutoEQManager) loadIndexFromDisk(cachePath string) ([]AutoEQProfileMetadata, error) {
info, err := os.Stat(cachePath)
if err != nil {
return nil, err
}
// Check if cache is expired
if time.Since(info.ModTime()) > indexCacheTTL {
return nil, errors.New("cache expired")
}
data, err := os.ReadFile(cachePath)
if err != nil {
return nil, err
}
var profiles []AutoEQProfileMetadata
if err := json.Unmarshal(data, &profiles); err != nil {
return nil, err
}
return profiles, nil
}
func (m *AutoEQManager) saveIndexToDisk(cachePath string, profiles []AutoEQProfileMetadata) {
data, err := json.Marshal(profiles)
if err != nil {
log.Printf("Failed to marshal AutoEQ index: %v", err)
return
}
if err := os.WriteFile(cachePath, data, 0644); err != nil {
log.Printf("Failed to write AutoEQ index cache: %v", err)
}
}
func (m *AutoEQManager) fetchIndexFromNetwork(ctx context.Context) ([]AutoEQProfileMetadata, error) {
log.Printf("Fetching AutoEQ index from: %s (timeout: %v)", autoEQIndexURL, m.timeout)
ctx, cancel := context.WithTimeout(ctx, m.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, autoEQIndexURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("HTTP request failed: %v", err)
return nil, fmt.Errorf("fetching index: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
profiles, err := m.parseIndex(resp.Body)
if err != nil {
log.Printf("Failed to parse AutoEQ index: %v", err)
return nil, err
}
log.Printf("Successfully parsed %d profiles from AutoEQ index", len(profiles))
return profiles, nil
}
// parseIndex parses the INDEX.md markdown file
// Format: [Display Name](./path/to/profile) by source
var indexLinkRegex = regexp.MustCompile(`-\s*\[([^\]]+)\]\(\./([^)]+)\)`)
func (m *AutoEQManager) parseIndex(r io.Reader) ([]AutoEQProfileMetadata, error) {
scanner := bufio.NewScanner(r)
var profiles []AutoEQProfileMetadata
lineCount := 0
matchCount := 0
for scanner.Scan() {
line := scanner.Text()
lineCount++
matches := indexLinkRegex.FindStringSubmatch(line)
if len(matches) == 3 {
matchCount++
name := matches[1]
path := matches[2]
// URL-decode the name to handle any encoded characters
if decodedName, err := url.QueryUnescape(name); err == nil {
name = decodedName
}
// Extract source and type from path
// Format: source/type/name (e.g., "oratory1990/over-ear/Sennheiser HD 650")
parts := strings.Split(path, "/")
source := ""
typ := ""
if len(parts) >= 2 {
source = parts[0]
typ = parts[1]
// URL-decode source and type to display clean text
if decodedSource, err := url.QueryUnescape(source); err == nil {
source = decodedSource
}
if decodedType, err := url.QueryUnescape(typ); err == nil {
typ = decodedType
}
}
profiles = append(profiles, AutoEQProfileMetadata{
Name: name,
Path: path,
Source: source,
Type: typ,
})
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading index: %w", err)
}
log.Printf("Parsed %d lines, found %d profile matches", lineCount, matchCount)
return profiles, nil
}
// FetchProfile fetches a specific AutoEQ profile by its path
// Results are cached with LRU eviction
func (m *AutoEQManager) FetchProfile(ctx context.Context, path string) (*AutoEQProfile, error) {
// Check memory cache
m.memCacheMutex.RLock()
if entry, ok := m.memCache[path]; ok {
entry.lastAccessed = time.Now()
profile := entry.profile
m.memCacheMutex.RUnlock()
m.updateLRU(path)
return profile, nil
}
m.memCacheMutex.RUnlock()
// Check disk cache
profile, err := m.loadProfileFromDisk(path)
if err == nil {
m.addToMemoryCache(path, profile)
return profile, nil
}
// Fetch from network
profile, err = m.fetchProfileFromNetwork(ctx, path)
if err != nil {
return nil, err
}
// Cache in memory and disk
m.addToMemoryCache(path, profile)
m.saveProfileToDisk(path, profile)
return profile, nil
}
func (m *AutoEQManager) loadProfileFromDisk(path string) (*AutoEQProfile, error) {
cacheKey := m.profileCacheKey(path)
cachePath := filepath.Join(m.cachePath, cacheKey+".json")
info, err := os.Stat(cachePath)
if err != nil {
return nil, err
}
// Check if cache is expired
if time.Since(info.ModTime()) > profileCacheTTL {
os.Remove(cachePath)
return nil, errors.New("cache expired")
}
data, err := os.ReadFile(cachePath)
if err != nil {
return nil, err
}
var profile AutoEQProfile
if err := json.Unmarshal(data, &profile); err != nil {
return nil, err
}
return &profile, nil
}
func (m *AutoEQManager) saveProfileToDisk(path string, profile *AutoEQProfile) {
cacheKey := m.profileCacheKey(path)
cachePath := filepath.Join(m.cachePath, cacheKey+".json")
data, err := json.Marshal(profile)
if err != nil {
log.Printf("Failed to marshal AutoEQ profile: %v", err)
return
}
if err := os.WriteFile(cachePath, data, 0644); err != nil {
log.Printf("Failed to write AutoEQ profile cache: %v", err)
}
}
func (m *AutoEQManager) profileCacheKey(path string) string {
hash := md5.Sum([]byte(path))
return hex.EncodeToString(hash[:])
}
func (m *AutoEQManager) fetchProfileFromNetwork(ctx context.Context, path string) (*AutoEQProfile, error) {
// URL-decode the path first (INDEX.md contains HTML-encoded paths like %20 for spaces)
decodedPath, err := url.QueryUnescape(path)
if err != nil {
log.Printf("Failed to decode path %s: %v", path, err)
decodedPath = path // fallback to original
}
// Extract the headphone name from the path (last component)
// Path format: "source/type/Headphone Name"
pathComponents := strings.Split(decodedPath, "/")
if len(pathComponents) == 0 {
return nil, fmt.Errorf("invalid path format: %s", path)
}
headphoneName := pathComponents[len(pathComponents)-1]
// Construct the URL by properly encoding each path component
for i, component := range pathComponents {
pathComponents[i] = url.PathEscape(component)
}
encodedPath := strings.Join(pathComponents, "/")
// The file is named "{HeadphoneName} FixedBandEQ.txt"
encodedFileName := url.PathEscape(headphoneName + " FixedBandEQ.txt")
profileURL := autoEQBaseURL + encodedPath + "/" + encodedFileName
ctx, cancel := context.WithTimeout(ctx, m.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, profileURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching profile: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrProfileNotFound
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
return m.parseProfile(path, resp.Body)
}
// parseProfile parses the FixedBandEQ.txt file
// Format:
// Preamp: -6.0 dB
// Filter 1: ON PK Fc 31 Hz Gain 5.0 dB Q 0.70
// ... (10 filters total)
var preampRegex = regexp.MustCompile(`Preamp:\s*([-+]?\d+\.?\d*)\s*dB`)
var filterRegex = regexp.MustCompile(`Filter\s+\d+:.*?Fc\s+(\d+)\s+Hz.*?Gain\s+([-+]?\d+\.?\d*)\s*dB`)
func (m *AutoEQManager) parseProfile(path string, r io.Reader) (*AutoEQProfile, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("reading profile: %w", err)
}
content := string(data)
// Parse preamp
preampMatch := preampRegex.FindStringSubmatch(content)
if len(preampMatch) < 2 {
return nil, fmt.Errorf("%w: preamp not found", ErrInvalidFormat)
}
preamp, err := strconv.ParseFloat(preampMatch[1], 64)
if err != nil {
return nil, fmt.Errorf("%w: invalid preamp value", ErrInvalidFormat)
}
// Parse filters
filterMatches := filterRegex.FindAllStringSubmatch(content, -1)
if len(filterMatches) != 10 {
return nil, fmt.Errorf("%w: expected 10 filters, found %d", ErrInvalidFormat, len(filterMatches))
}
var bands [10]float64
expectedFreqs := []int{31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000}
for i, match := range filterMatches {
if len(match) < 3 {
return nil, fmt.Errorf("%w: invalid filter %d", ErrInvalidFormat, i+1)
}
freq, err := strconv.Atoi(match[1])
if err != nil {
return nil, fmt.Errorf("%w: invalid frequency in filter %d", ErrInvalidFormat, i+1)
}
// Verify frequency matches expected (with tolerance for rounding)
if freq != expectedFreqs[i] {
log.Printf("Warning: AutoEQ filter %d has unexpected frequency %d Hz (expected %d Hz)",
i+1, freq, expectedFreqs[i])
}
gain, err := strconv.ParseFloat(match[2], 64)
if err != nil {
return nil, fmt.Errorf("%w: invalid gain in filter %d", ErrInvalidFormat, i+1)
}
bands[i] = gain
}
// Extract name and metadata from path
// URL-decode the path first to get clean names
decodedPath, err := url.QueryUnescape(path)
if err != nil {
decodedPath = path // fallback to original if decode fails
}
parts := strings.Split(decodedPath, "/")
name := decodedPath
source := ""
typ := ""
if len(parts) >= 3 {
name = parts[len(parts)-1]
source = parts[0]
typ = parts[1]
}
return &AutoEQProfile{
Name: name,
Path: path,
Source: source,
Type: typ,
Preamp: preamp,
Bands: bands,
}, nil
}
func (m *AutoEQManager) addToMemoryCache(path string, profile *AutoEQProfile) {
m.memCacheMutex.Lock()
defer m.memCacheMutex.Unlock()
// If already in cache, just update
if _, exists := m.memCache[path]; exists {
m.memCache[path].profile = profile
m.memCache[path].lastAccessed = time.Now()
return
}
// Evict LRU if at capacity
if len(m.memCache) >= maxMemoryCacheSize {
lruKey := m.memCacheLRU[0]
delete(m.memCache, lruKey)
m.memCacheLRU = m.memCacheLRU[1:]
}
// Add new entry
m.memCache[path] = &memoryCacheEntry{
profile: profile,
lastAccessed: time.Now(),
}
m.memCacheLRU = append(m.memCacheLRU, path)
}
func (m *AutoEQManager) updateLRU(path string) {
m.memCacheMutex.Lock()
defer m.memCacheMutex.Unlock()
// Find and move to end
for i, key := range m.memCacheLRU {
if key == path {
m.memCacheLRU = append(m.memCacheLRU[:i], m.memCacheLRU[i+1:]...)
m.memCacheLRU = append(m.memCacheLRU, path)
break
}
}
}
+11 -1
View File
@@ -12,6 +12,7 @@ import (
var (
VolumeCLIArg int = -1
SeekToCLIArg float64 = -1
RateCurrentCLIArg int = -1
SeekByCLIArg float64 = 0
VolumePctCLIArg float64 = 0
PlayAlbumCLIArg string = ""
@@ -84,7 +85,9 @@ func init() {
}
flag.Func("first-track", "start playing from given track (positive integer, to be used with either -play-album or -play-playlist)", func(s string) error {
v, err := strconv.Atoi(s)
FirstTrackCLIArg = v
if err == nil {
FirstTrackCLIArg = v
}
return err
})
@@ -100,6 +103,13 @@ func init() {
SearchTrackCLIArg = s
return nil
})
flag.Func("rate-current", "rate the current track with the given rating (0-5)", func(s string) error {
v, err := strconv.Atoi(s)
if err == nil {
RateCurrentCLIArg = v
}
return err
})
}
func HaveCommandLineOptions() bool {
+6 -1
View File
@@ -136,8 +136,12 @@ type LocalPlaybackConfig struct {
InMemoryCacheSizeMB int
Volume int
EqualizerEnabled bool
EqualizerType string // "ISO10Band" or "ISO15Band"
EqualizerPreamp float64
GraphicEqualizerBands []float64
ActiveEQPresetName string // Name of currently selected EQ preset
AutoEQProfilePath string // Path to applied AutoEQ profile (e.g., "oratory1990/over-ear/Sennheiser HD 650")
AutoEQProfileName string // Display name of applied profile (e.g., "Sennheiser HD 650")
PauseFade bool
}
@@ -262,7 +266,7 @@ func DefaultConfig(appVersionTag string) *Config {
Autoplay: false,
Shuffle: false,
RepeatMode: "None",
UseWaveformSeekbar: true,
UseWaveformSeekbar: false,
},
LocalPlayback: LocalPlaybackConfig{
// "auto" is the name to pass to MPV for autoselecting the output device
@@ -271,6 +275,7 @@ func DefaultConfig(appVersionTag string) *Config {
InMemoryCacheSizeMB: 30,
Volume: 100,
EqualizerEnabled: false,
EqualizerType: "ISO15Band",
EqualizerPreamp: 0,
GraphicEqualizerBands: make([]float64, 15),
PauseFade: true,
+173
View File
@@ -0,0 +1,173 @@
package backend
import "math"
// AutoEQ uses 10 bands at these frequencies (in Hz)
var autoEQFreqs = []float64{31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000}
// Supersonic uses 15 bands at these frequencies (in Hz)
var supersonicFreqs = []float64{25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000}
// InterpolateEQ10To15Band converts a 10-band EQ profile to Supersonic's 15-band ISO equalizer.
// Uses logarithmic frequency positioning with linear dB interpolation.
//
// Parameters:
// - eq10Gains: Array of 10 gain values (dB) from 10-band EQ at 31, 62, 125, 250, 500, 1k, 2k, 4k, 8k, 16k Hz
//
// Returns:
// - Array of 15 gain values (dB) for Supersonic at 25, 40, 63, 100, 160, 250, 400, 630, 1k, 1.6k, 2.5k, 4k, 6.3k, 10k, 16k Hz
func InterpolateEQ10To15Band(eq10Gains [10]float64) [15]float64 {
var result [15]float64
for i, targetFreq := range supersonicFreqs {
// Find the surrounding AutoEQ bands for this target frequency
lowerIdx, upperIdx := findSurroundingBands(targetFreq)
if lowerIdx == upperIdx {
// Exact match - use the AutoEQ gain directly
result[i] = eq10Gains[lowerIdx]
} else if lowerIdx == -1 {
// Target frequency is below the lowest AutoEQ band (25 Hz < 31.25 Hz)
// Extrapolate using the first two AutoEQ bands
result[i] = extrapolateBelow(targetFreq, eq10Gains)
} else if upperIdx == -1 {
// Target frequency is above the highest AutoEQ band (should not happen with our ranges)
// Use the highest AutoEQ gain
result[i] = eq10Gains[len(eq10Gains)-1]
} else {
// Interpolate between two AutoEQ bands
fLow := autoEQFreqs[lowerIdx]
fHigh := autoEQFreqs[upperIdx]
gLow := eq10Gains[lowerIdx]
gHigh := eq10Gains[upperIdx]
// Calculate logarithmic position between the two bands
// t = log(targetFreq/fLow) / log(fHigh/fLow)
t := math.Log(targetFreq/fLow) / math.Log(fHigh/fLow)
// Linear interpolation of gain values
result[i] = gLow + t*(gHigh-gLow)
}
}
return result
}
// findSurroundingBands finds the AutoEQ band indices that surround the target frequency.
// Returns (idx, idx) if there's an exact match, (lowerIdx, upperIdx) if between bands,
// (-1, 0) if below all bands, or (lastIdx, -1) if above all bands.
func findSurroundingBands(targetFreq float64) (lowerIdx, upperIdx int) {
const epsilon = 0.01 // Tolerance for floating point comparison
// Check if below all bands
if targetFreq < autoEQFreqs[0]-epsilon {
return -1, 0
}
// Check if above all bands
if targetFreq > autoEQFreqs[len(autoEQFreqs)-1]+epsilon {
return len(autoEQFreqs) - 1, -1
}
// Find surrounding bands
for i := 0; i < len(autoEQFreqs)-1; i++ {
// Check for exact match
if math.Abs(targetFreq-autoEQFreqs[i]) < epsilon {
return i, i
}
// Check if between this band and the next
if targetFreq > autoEQFreqs[i] && targetFreq < autoEQFreqs[i+1] {
return i, i + 1
}
}
// Check for exact match with last band
if math.Abs(targetFreq-autoEQFreqs[len(autoEQFreqs)-1]) < epsilon {
return len(autoEQFreqs) - 1, len(autoEQFreqs) - 1
}
// Should not reach here with valid input
return len(autoEQFreqs) - 1, -1
}
// extrapolateBelow extrapolates the gain for frequencies below the lowest AutoEQ band.
// Uses the slope between the first two AutoEQ bands.
func extrapolateBelow(targetFreq float64, autoEQGains [10]float64) float64 {
// Use the slope between the first two bands to extrapolate
f1 := autoEQFreqs[0]
f2 := autoEQFreqs[1]
g1 := autoEQGains[0]
g2 := autoEQGains[1]
// Calculate the slope in log-frequency space
slope := (g2 - g1) / math.Log(f2/f1)
// Extrapolate
return g1 + slope*math.Log(targetFreq/f1)
}
// InterpolateEQ15BandTo10Band converts a 15-band ISO equalizer to 10-band format.
// Uses logarithmic frequency positioning with linear dB interpolation.
func InterpolateEQ15BandTo10Band(gains15Band [15]float64) [10]float64 {
var result [10]float64
for i, targetFreq := range autoEQFreqs {
// Find the surrounding 15-band frequencies for this target
lowerIdx, upperIdx := findSurrounding15Bands(targetFreq)
if lowerIdx == upperIdx {
// Exact match
result[i] = gains15Band[lowerIdx]
} else if lowerIdx == -1 {
// Below lowest band - use first band value
result[i] = gains15Band[0]
} else if upperIdx == -1 {
// Above highest band - use last band value
result[i] = gains15Band[len(gains15Band)-1]
} else {
// Interpolate between two 15-band frequencies
fLow := supersonicFreqs[lowerIdx]
fHigh := supersonicFreqs[upperIdx]
gLow := gains15Band[lowerIdx]
gHigh := gains15Band[upperIdx]
// Logarithmic position
t := math.Log(targetFreq/fLow) / math.Log(fHigh/fLow)
// Linear interpolation of gain
result[i] = gLow + t*(gHigh-gLow)
}
}
return result
}
// findSurrounding15Bands finds the 15-band indices that surround the target frequency
func findSurrounding15Bands(targetFreq float64) (lowerIdx, upperIdx int) {
const epsilon = 0.01
if targetFreq < supersonicFreqs[0]-epsilon {
return -1, 0
}
if targetFreq > supersonicFreqs[len(supersonicFreqs)-1]+epsilon {
return len(supersonicFreqs) - 1, -1
}
for i := 0; i < len(supersonicFreqs)-1; i++ {
if math.Abs(targetFreq-supersonicFreqs[i]) < epsilon {
return i, i
}
if targetFreq > supersonicFreqs[i] && targetFreq < supersonicFreqs[i+1] {
return i, i + 1
}
}
if math.Abs(targetFreq-supersonicFreqs[len(supersonicFreqs)-1]) < epsilon {
return len(supersonicFreqs) - 1, len(supersonicFreqs) - 1
}
return len(supersonicFreqs) - 1, -1
}
+180
View File
@@ -0,0 +1,180 @@
package backend
import (
"math"
"testing"
)
func TestInterpolateAutoEQTo15Band_FlatProfile(t *testing.T) {
// Test with a flat profile (all zeros)
flatProfile := [10]float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
result := InterpolateEQ10To15Band(flatProfile)
for i, gain := range result {
if math.Abs(gain) > 0.001 {
t.Errorf("Flat profile should result in zero gains, got %f at index %d", gain, i)
}
}
}
func TestInterpolateAutoEQTo15Band_ExactMatches(t *testing.T) {
// Test that exact frequency matches use the AutoEQ gain directly
// AutoEQ bands: 31, 62, 125, 250, 500, 1k, 2k, 4k, 8k, 16k Hz
// Supersonic bands: 25, 40, 63, 100, 160, 250, 400, 630, 1k, 1.6k, 2.5k, 4k, 6.3k, 10k, 16k Hz
// Exact matches: 250 (idx 5→3), 1k (idx 5→8), 4k (idx 7→11), 16k (idx 9→14)
testProfile := [10]float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
result := InterpolateEQ10To15Band(testProfile)
tests := []struct {
supersonicIdx int
autoEQIdx int
expectedGain float64
}{
{5, 3, 4}, // 250 Hz
{8, 5, 6}, // 1000 Hz
{11, 7, 8}, // 4000 Hz
{14, 9, 10}, // 16000 Hz
}
for _, tt := range tests {
if math.Abs(result[tt.supersonicIdx]-tt.expectedGain) > 0.001 {
t.Errorf("Exact match at index %d (AutoEQ idx %d): expected %f, got %f",
tt.supersonicIdx, tt.autoEQIdx, tt.expectedGain, result[tt.supersonicIdx])
}
}
}
func TestInterpolateAutoEQTo15Band_Interpolation(t *testing.T) {
// Test interpolation between bands
// Use a profile with known values to verify interpolation
testProfile := [10]float64{0, 0, 0, 0, 0, 10, 0, 0, 0, 0}
// Only 1000 Hz (index 5) has gain of 10 dB
result := InterpolateEQ10To15Band(testProfile)
// Check that 1000 Hz (index 8) has the exact value
if math.Abs(result[8]-10) > 0.001 {
t.Errorf("1000 Hz should be 10 dB, got %f", result[8])
}
// Check that nearby frequencies are interpolated (should be between 0 and 10)
// 630 Hz (index 7) should be between 500 Hz (0 dB) and 1000 Hz (10 dB)
if result[7] < 0 || result[7] > 10 {
t.Errorf("630 Hz interpolation out of range: %f", result[7])
}
// 1600 Hz (index 9) should be between 1000 Hz (10 dB) and 2000 Hz (0 dB)
if result[9] < 0 || result[9] > 10 {
t.Errorf("1600 Hz interpolation out of range: %f", result[9])
}
}
func TestInterpolateAutoEQTo15Band_Extrapolation(t *testing.T) {
// Test extrapolation for 25 Hz (below lowest AutoEQ band of 31.25 Hz)
testProfile := [10]float64{5, 10, 0, 0, 0, 0, 0, 0, 0, 0}
// 31.25 Hz = 5 dB, 62.5 Hz = 10 dB
result := InterpolateEQ10To15Band(testProfile)
// 25 Hz should be extrapolated below 31.25 Hz
// Since the slope is positive (5 to 10), 25 Hz should be < 5 dB
if result[0] > 5 {
t.Errorf("25 Hz extrapolation should be < 5 dB, got %f", result[0])
}
// It should be a reasonable value (not too extreme)
if math.Abs(result[0]) > 20 {
t.Errorf("25 Hz extrapolation too extreme: %f", result[0])
}
}
func TestInterpolateAutoEQTo15Band_RealProfile(t *testing.T) {
// Test with a realistic profile shape (bass and treble boost)
// Approximating a V-shaped response
testProfile := [10]float64{6, 5, 3, 1, 0, 0, 1, 3, 5, 6}
result := InterpolateEQ10To15Band(testProfile)
// Verify output is reasonable
if len(result) != 15 {
t.Errorf("Expected 15 bands, got %d", len(result))
}
// Check that interpolated values are between neighboring AutoEQ values
// For example, 40 Hz (index 1) should be between 31.25 Hz and 62.5 Hz values
if result[1] < math.Min(testProfile[0], testProfile[1])-1 ||
result[1] > math.Max(testProfile[0], testProfile[1])+1 {
t.Errorf("40 Hz interpolation out of reasonable range: %f (between %f and %f)",
result[1], testProfile[0], testProfile[1])
}
}
func TestInterpolateAutoEQTo15Band_MonotonicPreservation(t *testing.T) {
// Test that monotonic sections are preserved
// If AutoEQ gains are monotonically increasing, interpolated values should also increase
monotonicProfile := [10]float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
result := InterpolateEQ10To15Band(monotonicProfile)
// Check general increasing trend (allowing for some extrapolation variance at edges)
for i := 2; i < len(result)-1; i++ {
if result[i] < result[i-1]-0.5 {
t.Errorf("Monotonicity not preserved at index %d: %f < %f",
i, result[i], result[i-1])
}
}
}
func TestFindSurroundingBands_ExactMatches(t *testing.T) {
tests := []struct {
freq float64
expectedLower int
expectedUpper int
}{
{31.25, 0, 0},
{62.5, 1, 1},
{125, 2, 2},
{250, 3, 3},
{1000, 5, 5},
{4000, 7, 7},
{16000, 9, 9},
}
for _, tt := range tests {
lower, upper := findSurroundingBands(tt.freq)
if lower != tt.expectedLower || upper != tt.expectedUpper {
t.Errorf("findSurroundingBands(%f): expected (%d, %d), got (%d, %d)",
tt.freq, tt.expectedLower, tt.expectedUpper, lower, upper)
}
}
}
func TestFindSurroundingBands_BetweenBands(t *testing.T) {
tests := []struct {
freq float64
expectedLower int
expectedUpper int
}{
{40, 0, 1}, // Between 31.25 and 62.5
{100, 1, 2}, // Between 62.5 and 125
{500, 4, 4}, // Exact match at 500
{2000, 6, 6}, // Exact match at 2000
{10000, 8, 9}, // Between 8000 and 16000
}
for _, tt := range tests {
lower, upper := findSurroundingBands(tt.freq)
if lower != tt.expectedLower || upper != tt.expectedUpper {
t.Errorf("findSurroundingBands(%f): expected (%d, %d), got (%d, %d)",
tt.freq, tt.expectedLower, tt.expectedUpper, lower, upper)
}
}
}
func TestFindSurroundingBands_BelowRange(t *testing.T) {
lower, upper := findSurroundingBands(25)
if lower != -1 || upper != 0 {
t.Errorf("findSurroundingBands(25): expected (-1, 0), got (%d, %d)", lower, upper)
}
}
+150
View File
@@ -0,0 +1,150 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
const eqPresetsDir = "eq_presets"
// EQPreset represents an equalizer preset that can be saved/loaded
type EQPreset struct {
Name string `json:"name"`
Type string `json:"type"` // "ISO10Band" or "ISO15Band"
Preamp float64 `json:"preamp"`
Bands []float64 `json:"bands"`
IsBuiltin bool `json:"-"` // not saved to file, determined at load time
}
// EQPresetManager handles loading and saving EQ presets
type EQPresetManager struct {
presetsDir string
builtinPresets []EQPreset
}
// NewEQPresetManager creates a new preset manager
func NewEQPresetManager(configDir string) *EQPresetManager {
return &EQPresetManager{
presetsDir: filepath.Join(configDir, eqPresetsDir),
builtinPresets: getBuiltinPresets(),
}
}
// getBuiltinPresets returns the built-in equalizer presets
func getBuiltinPresets() []EQPreset {
return []EQPreset{
{Name: "Flat", Type: "ISO15Band", Preamp: 0, Bands: []float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, IsBuiltin: true},
{Name: "Rock", Type: "ISO15Band", Preamp: 0, Bands: []float64{5, 4, 3, 1, -1, -1, 0, 2, 3, 4, 4, 4, 3, 2, 2}, IsBuiltin: true},
{Name: "Pop", Type: "ISO15Band", Preamp: 0, Bands: []float64{-1, -1, 0, 2, 4, 4, 2, 0, -1, -1, 0, 1, 2, 3, 3}, IsBuiltin: true},
{Name: "Jazz", Type: "ISO15Band", Preamp: 0, Bands: []float64{4, 3, 1, 2, -2, -2, 0, 2, 3, 3, 3, 4, 4, 4, 4}, IsBuiltin: true},
{Name: "Classical", Type: "ISO15Band", Preamp: 0, Bands: []float64{5, 4, 3, 2, -1, -1, 0, 2, 3, 3, 3, 2, 2, 2, -1}, IsBuiltin: true},
{Name: "Bass Boost", Type: "ISO15Band", Preamp: 0, Bands: []float64{6, 5, 4, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, IsBuiltin: true},
{Name: "Treble Boost", Type: "ISO15Band", Preamp: 0, Bands: []float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 4, 5, 6, 6}, IsBuiltin: true},
{Name: "Vocal", Type: "ISO15Band", Preamp: 0, Bands: []float64{-2, -3, -3, 1, 4, 4, 4, 3, 2, 1, 0, -1, -2, -2, -3}, IsBuiltin: true},
{Name: "Electronic", Type: "ISO15Band", Preamp: 0, Bands: []float64{5, 4, 2, 0, -2, -2, 0, 2, 3, 4, 4, 3, 4, 4, 3}, IsBuiltin: true},
{Name: "Acoustic", Type: "ISO15Band", Preamp: 0, Bands: []float64{5, 4, 3, 1, 2, 1, 1, 2, 2, 2, 1, 2, 2, 3, 2}, IsBuiltin: true},
{Name: "R&B", Type: "ISO15Band", Preamp: 0, Bands: []float64{3, 6, 5, 2, -2, -2, 2, 3, 2, 2, 3, 3, 3, 3, 4}, IsBuiltin: true},
{Name: "Loudness", Type: "ISO15Band", Preamp: 0, Bands: []float64{6, 5, 3, 0, -1, -1, -1, -1, 0, 1, 2, 4, 5, 5, 3}, IsBuiltin: true},
}
}
// LoadPresets loads all presets (builtin + user-defined)
func (m *EQPresetManager) LoadPresets() ([]EQPreset, error) {
// Start with builtin presets
presets := make([]EQPreset, len(m.builtinPresets))
copy(presets, m.builtinPresets)
// Create presets directory if it doesn't exist
if err := os.MkdirAll(m.presetsDir, 0o755); err != nil {
return presets, err
}
// Load user presets
entries, err := os.ReadDir(m.presetsDir)
if err != nil {
return presets, nil // Return builtins if can't read dir
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
presetPath := filepath.Join(m.presetsDir, entry.Name())
data, err := os.ReadFile(presetPath)
if err != nil {
continue // Skip invalid files
}
var preset EQPreset
if err := json.Unmarshal(data, &preset); err != nil {
continue // Skip invalid JSON
}
preset.IsBuiltin = false
presets = append(presets, preset)
}
// Sort: builtins first, then user presets alphabetically
sort.Slice(presets, func(i, j int) bool {
if presets[i].IsBuiltin != presets[j].IsBuiltin {
return presets[i].IsBuiltin
}
return presets[i].Name < presets[j].Name
})
return presets, nil
}
// SavePreset saves a preset to disk
func (m *EQPresetManager) SavePreset(preset EQPreset) error {
if preset.IsBuiltin {
return fmt.Errorf("cannot overwrite builtin preset")
}
// Create presets directory if it doesn't exist
if err := os.MkdirAll(m.presetsDir, 0o755); err != nil {
return err
}
// Sanitize filename
filename := sanitizeFilename(preset.Name) + ".json"
presetPath := filepath.Join(m.presetsDir, filename)
data, err := json.MarshalIndent(preset, "", " ")
if err != nil {
return err
}
return os.WriteFile(presetPath, data, 0o644)
}
// DeletePreset deletes a user preset
func (m *EQPresetManager) DeletePreset(name string) error {
// Check if it's a builtin preset
for _, bp := range m.builtinPresets {
if bp.Name == name {
return fmt.Errorf("cannot delete builtin preset")
}
}
filename := sanitizeFilename(name) + ".json"
presetPath := filepath.Join(m.presetsDir, filename)
return os.Remove(presetPath)
}
// sanitizeFilename removes characters that aren't safe for filenames
func sanitizeFilename(name string) string {
// Simple sanitization - could be enhanced
var result []rune
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == ' ' {
result = append(result, r)
}
}
return string(result)
}
+64 -21
View File
@@ -18,7 +18,9 @@ type CacheItem struct {
lastAccessed int64
}
// A custom in-memory cache for images with the following eviction strategy:
// ImageCache is a thread-safe in-memory cache for images with LRU eviction.
//
// Eviction strategy:
// 1. If there are fewer than MinSize items in the cache, none will be evicted
// 2. If a new addition would make the cache exceed MaxSize, an item will be immediately evicted
// 2a. in this case, evict the LRU expired item or if none expired, the LRU item
@@ -39,23 +41,30 @@ type ImageCache struct {
cache map[string]CacheItem
}
// ErrNotFound is returned when a requested cache item does not exist.
var ErrNotFound = errors.New("item not found")
// Init initializes the cache and starts a background goroutine for periodic eviction.
// The goroutine stops when the provided context is cancelled.
func (i *ImageCache) Init(ctx context.Context, evictionInterval time.Duration) {
i.cache = make(map[string]CacheItem)
go i.periodicallyEvict(ctx, evictionInterval)
}
// holds writer lock for O(i.MaxSize) worst case
// SetWithTTL stores an image in the cache with a custom time-to-live duration.
// If the cache is at MaxSize, an item will be evicted using LRU strategy.
// Thread-safe. Holds writer lock for O(MaxSize) worst case.
func (i *ImageCache) SetWithTTL(key string, val image.Image, ttl time.Duration) {
i.mu.Lock()
defer i.mu.Unlock()
now := time.Now().Unix()
if v, ok := i.cache[key]; ok {
v.val = val
v.ttl = ttl
v.expiresAt = time.Now().Add(v.ttl).Unix()
v.lastAccessed = time.Now().Unix()
v.lastAccessed = now
i.cache[key] = v // Update the map with modified struct
return
}
if len(i.cache) == i.MaxSize {
@@ -65,14 +74,18 @@ func (i *ImageCache) SetWithTTL(key string, val image.Image, ttl time.Duration)
val: val,
ttl: ttl,
expiresAt: time.Now().Add(ttl).Unix(),
lastAccessed: time.Now().Unix(),
lastAccessed: now,
}
}
// Set stores an image in the cache with the default TTL.
// See SetWithTTL for more details.
func (i *ImageCache) Set(key string, val image.Image) {
i.SetWithTTL(key, val, i.DefaultTTL)
}
// Has returns true if the key exists in the cache, expired or not.
// Thread-safe.
func (i *ImageCache) Has(key string) bool {
i.mu.RLock()
defer i.mu.RUnlock()
@@ -81,51 +94,72 @@ func (i *ImageCache) Has(key string) bool {
return ok
}
// Get retrieves an image from the cache and updates its last accessed time.
// Returns ErrNotFound if the key doesn't exist.
// Thread-safe.
func (i *ImageCache) Get(key string) (image.Image, error) {
return i.GetResetTTL(key, false)
}
// GetResetTTL retrieves an image and optionally resets its expiration time.
// If resetTTL is true, the expiration is reset to now + original TTL.
// Returns ErrNotFound if the key doesn't exist.
// Thread-safe.
func (i *ImageCache) GetResetTTL(key string, resetTTL bool) (image.Image, error) {
i.mu.RLock()
defer i.mu.RUnlock()
i.mu.Lock()
defer i.mu.Unlock()
if v, ok := i.cache[key]; ok {
v.lastAccessed = time.Now().Unix()
if resetTTL {
v.expiresAt = time.Now().Add(v.ttl).Unix()
}
i.cache[key] = v // Update the map with modified struct
return v.val, nil
}
return nil, ErrNotFound
}
// Gets the image if it exists and extends TTL to time.Now + ttl iff the image would expire before then
// GetExtendTTL retrieves an image and extends its TTL if it would expire sooner.
// The expiration time is extended to now + ttl only if the current expiration
// is earlier than that time.
// Returns ErrNotFound if the key doesn't exist.
// Thread-safe.
func (i *ImageCache) GetExtendTTL(key string, ttl time.Duration) (image.Image, error) {
i.mu.RLock()
defer i.mu.RUnlock()
i.mu.Lock()
defer i.mu.Unlock()
if v, ok := i.cache[key]; ok {
v.lastAccessed = time.Now().Unix()
if v.expiresAt < time.Now().Add(ttl).Unix() {
v.expiresAt = time.Now().Add(ttl).Unix()
newExpiry := time.Now().Add(ttl).Unix()
if v.expiresAt < newExpiry {
v.expiresAt = newExpiry
}
i.cache[key] = v // Update the map with modified struct
return v.val, nil
}
return nil, ErrNotFound
}
// GetWithNewTTL retrieves an image and replaces its TTL with a new value.
// The expiration time is set to now + newTtl.
// Returns ErrNotFound if the key doesn't exist.
// Thread-safe.
func (i *ImageCache) GetWithNewTTL(key string, newTtl time.Duration) (image.Image, error) {
i.mu.RLock()
defer i.mu.RUnlock()
i.mu.Lock()
defer i.mu.Unlock()
if v, ok := i.cache[key]; ok {
v.lastAccessed = time.Now().Unix()
v.expiresAt = time.Now().Add(newTtl).Unix()
v.ttl = newTtl
i.cache[key] = v // Update the map with modified struct
return v.val, nil
}
return nil, ErrNotFound
}
// Clear removes all items from the cache.
// Thread-safe.
func (i *ImageCache) Clear() {
i.mu.Lock()
defer i.mu.Unlock()
@@ -137,24 +171,33 @@ func (i *ImageCache) Clear() {
func (i *ImageCache) evictOne() {
now := time.Now().Unix()
var lruKey string
lruTime := now
lruTime := now + 1 // Initialize to future time so any item will be less
var lruExpiredKey string
lruExpiredTime := now
lruExpiredTime := now + 1 // Initialize to future time
hasExpired := false
// Single pass through the cache to find both LRU expired and LRU items
for k, v := range i.cache {
if v.expiresAt < now && v.lastAccessed < lruExpiredTime {
lruExpiredTime = v.lastAccessed
lruExpiredKey = k
if v.expiresAt < now {
// This item is expired
if v.lastAccessed < lruExpiredTime {
lruExpiredTime = v.lastAccessed
lruExpiredKey = k
hasExpired = true
}
}
// Track LRU regardless of expiration
if v.lastAccessed < lruTime {
lruTime = v.lastAccessed
lruKey = k
}
}
if lruExpiredTime < now {
// deleting LRU expired item
if hasExpired {
// Prefer deleting LRU expired item
delete(i.cache, lruExpiredKey)
} else {
// no expired items, delete LRU non-expired item
// No expired items, delete LRU non-expired item
delete(i.cache, lruKey)
}
}
+5
View File
@@ -27,6 +27,7 @@ const (
VolumeAdjustPath = "/volume/adjust" // ?pct=<+/- percentage>
ShowPath = "/window/show"
QuitPath = "/window/quit"
RateCurrentTrackPath = "/current_track/rate" // ?r=<rating 0-5>
)
type Response struct {
@@ -76,3 +77,7 @@ func BuildSearchTrackPath(search string) string {
s := url.QueryEscape(search)
return fmt.Sprintf("%s?s=%s", SearchTrackPath, s)
}
func BuildRateCurrentTrackPath(rating int) string {
return fmt.Sprintf("%s?r=%d", RateCurrentTrackPath, rating)
}
+5
View File
@@ -118,6 +118,11 @@ func (c *Client) AdjustVolumePct(pct float64) error {
return err
}
func (c *Client) RateCurrentTrack(rating int) error {
_, err := c.sendRequest(BuildRateCurrentTrackPath(rating))
return err
}
func (c *Client) Show() error {
_, err := c.sendRequest(ShowPath)
return err
+13 -1
View File
@@ -11,6 +11,12 @@ import (
"runtime"
)
// socketPath is automatically initialized based on platform conventions:
// - macOS: ~/Library/Caches/supersonic/supersonic.sock (or /tmp/supersonic-{uid}.sock as fallback)
// - Linux/Unix: $XDG_RUNTIME_DIR/supersonic.sock (or /tmp/supersonic-{uid}.sock as fallback)
//
// TODO: Add support for portable mode by allowing override via environment variable
// or configuration file (e.g., SUPERSONIC_SOCKET_PATH).
var socketPath = "/tmp/supersonic.sock"
func init() {
@@ -29,15 +35,21 @@ func init() {
}
}
// Dial establishes a connection to the IPC socket.
// Returns an error if the socket doesn't exist or connection fails.
func Dial() (net.Conn, error) {
// TODO - use XDG runtime dir, also handle portable mode
return net.Dial("unix", socketPath)
}
// Listen creates a Unix domain socket listener at the configured path.
// The socket file is created automatically and should be cleaned up
// with DestroyConn() when done.
func Listen() (net.Listener, error) {
return net.Listen("unix", socketPath)
}
// DestroyConn removes the Unix socket file from the filesystem.
// Should be called during application shutdown.
func DestroyConn() error {
return os.Remove(socketPath)
}
+18 -2
View File
@@ -43,13 +43,14 @@ type ServerManager interface {
type serverImpl struct {
server *http.Server
pbHandler PlaybackHandler
rateFn func(int)
sm ServerManager
showFn func()
quitFn func()
}
func NewServer(pbHandler PlaybackHandler, sm ServerManager, showFn, quitFn func()) IPCServer {
s := &serverImpl{pbHandler: pbHandler, sm: sm, showFn: showFn, quitFn: quitFn}
func NewServer(pbHandler PlaybackHandler, rateFn func(int), sm ServerManager, showFn, quitFn func()) IPCServer {
s := &serverImpl{pbHandler: pbHandler, rateFn: rateFn, sm: sm, showFn: showFn, quitFn: quitFn}
s.server = &http.Server{
Handler: s.createHandler(),
}
@@ -172,6 +173,21 @@ func (s *serverImpl) createHandler() http.Handler {
return tracks, nil
}))
m.HandleFunc(RateCurrentTrackPath, func(w http.ResponseWriter, r *http.Request) {
v := r.URL.Query().Get("r")
if rating, err := strconv.Atoi(v); err == nil {
// convert to 0-5 range if needed
if rating > 5 {
rating = 5
} else if rating < 0 {
rating = 0
}
s.rateFn(rating)
s.writeOK(w)
} else {
s.writeErr(w, err)
}
})
return m
}
+11 -1
View File
@@ -1,6 +1,10 @@
package mediaprovider
import "time"
import (
"time"
"fyne.io/fyne/v2"
)
// Bit field flag for the ReleaseTypes property
type ReleaseType = int32
@@ -282,6 +286,7 @@ const (
ContentTypeTrack
ContentTypeGenre
ContentTypeRadioStation
ContentTypeOther
)
func (c ContentType) String() string {
@@ -298,6 +303,8 @@ func (c ContentType) String() string {
return "Genre"
case ContentTypeRadioStation:
return "Radio station"
case ContentTypeOther:
return "Other"
default:
return "Unknown"
}
@@ -309,6 +316,9 @@ type SearchResult struct {
CoverID string
Type ContentType
// Optional icon to display instead of the default for this content type
Icon fyne.Resource
// for Album / Playlist: track count
// Artist / Genre: album count
// Track: length (seconds)
+21 -6
View File
@@ -69,15 +69,28 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
mpMediaEventRecipient = mp
C.register_os_remote_commands()
mp.playbackManager.OnSongChange(func(track mediaprovider.MediaItem, _ *mediaprovider.Track) {
mp.playbackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
// Asynchronously because artwork fetching can take time
var meta *mediaprovider.MediaItemMetadata
if track != nil {
m := track.Metadata()
if item != nil {
m := item.Metadata()
meta = &m
}
go mp.updateMetadata(meta)
})
mp.playbackManager.OnRadioMetadataChange(func(radioName, title, artist string) {
if title != "" {
go mp.updateMetadata(&mediaprovider.MediaItemMetadata{
Name: title,
Artists: []string{artist},
Album: radioName,
})
} else {
go mp.updateMetadata(&mediaprovider.MediaItemMetadata{
Name: radioName,
})
}
})
mp.playbackManager.OnStopped(func() {
C.set_os_playback_state_stopped()
@@ -103,16 +116,18 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
func (mp *MPMediaHandler) updateMetadata(meta *mediaprovider.MediaItemMetadata) {
var title, artist, artURL string
var duration int
if meta != nil && meta.ID != "" {
if meta != nil {
title = meta.Name
artist = strings.Join(meta.Artists, ", ")
duration = int(meta.Duration.Seconds())
}
if meta != nil && meta.ID != "" {
var err error
if artURL, err = mp.artURLLookup(meta.CoverArtID); err != nil {
if meta.CoverArtID != "" {
log.Printf("error fetching art url: %s", err.Error())
}
}
artist = strings.Join(meta.Artists, ", ")
duration = int(meta.Duration.Seconds())
}
cTitle := C.CString(title)
+27 -3
View File
@@ -43,6 +43,11 @@ type MPRISHandler struct {
pm *PlaybackManager
s *server.Server
evt *events.EventHandler
// current radio metadata
radioStationName string
radioIcyTitle string
radioIcyArtist string
}
func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler {
@@ -66,6 +71,14 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler {
m.evt.Player.OnTitle()
}
})
pm.OnRadioMetadataChange(func(radioName, title, artist string) {
m.radioStationName = radioName
m.radioIcyTitle = title
m.radioIcyArtist = artist
if m.connErr == nil {
m.evt.Player.OnTitle()
}
})
pm.OnVolumeChange(func(vol int) {
if m.connErr == nil {
m.evt.Player.OnVolume()
@@ -276,12 +289,23 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) {
artURL = u
}
}
title := meta.Name
artists := meta.Artists
album := meta.Album
// if playing a radio station, override title/artist with current Icy metadata if present
if m.radioStationName == title && m.radioIcyTitle != "" {
title = m.radioIcyTitle
artists = []string{m.radioIcyArtist}
album = m.radioStationName
}
mprisMeta := types.Metadata{
TrackId: dbus.ObjectPath(trackObjPath),
Length: secondsToMicroseconds(status.Duration),
Title: meta.Name,
Album: meta.Album,
Artist: meta.Artists,
Title: title,
Album: album,
Artist: artists,
DiscNumber: discNumber,
TrackNumber: trackNumber,
UserRating: float64(userRating) / 5,
+19
View File
@@ -8,6 +8,7 @@ import (
"math/rand"
"slices"
"strconv"
"strings"
"time"
"github.com/dweymouth/supersonic/backend/mediaprovider"
@@ -116,6 +117,8 @@ type playbackEngine struct {
onStopped []func()
onPlaying []func()
onQueueChange []func()
onRadioMetadataChange []func(radioName, title, artist string)
}
func NewPlaybackEngine(
@@ -845,6 +848,22 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error {
url = filepath
}
}
if mpvP, ok := p.player.(*mpv.Player); ok && !isTrack {
mpvP.ObserveIcyRadioTitle(func(icytitle string) {
var title, artist string
if s := strings.Split(icytitle, " - "); len(s) == 2 {
title, artist = s[1], s[0]
} else {
title = icytitle
}
log.Println("Radio metadata changed: ", icytitle)
for _, cb := range p.onRadioMetadataChange {
cb(meta.Name, title, artist)
}
})
} else if ok {
mpvP.UnobserveIcyRadioTitle()
}
if url == "" {
return errors.New("no stream URL")
}
+5
View File
@@ -318,6 +318,11 @@ func (p *PlaybackManager) OnSongChange(cb func(nowPlaying mediaprovider.MediaIte
p.engine.onSongChange = append(p.engine.onSongChange, cb)
}
// Sets a callback that is notified whenever the Icy radio metadata changes.
func (p *PlaybackManager) OnRadioMetadataChange(cb func(radioName, title, artist string)) {
p.engine.onRadioMetadataChange = append(p.engine.onRadioMetadataChange, cb)
}
// Registers a callback that is notified whenever the play time should be updated.
func (p *PlaybackManager) OnPlayTimeUpdate(cb func(curTime float64, totalTime float64, seeked bool)) {
p.engine.onPlayTimeUpdate = append(p.engine.onPlayTimeUpdate, cb)
+46
View File
@@ -120,3 +120,49 @@ func (w WidthType) String() string {
}
return "x" // not reached
}
type ISO10BandEqualizer struct {
Disabled bool
EQPreamp float64
BandGains [10]float64
}
var (
iso10Bands = []string{"31", "62", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"}
iso10FMult = 2.0 // Octave doubling
)
var _ Equalizer = (*ISO10BandEqualizer)(nil)
func (i *ISO10BandEqualizer) IsEnabled() bool {
return !i.Disabled
}
func (i *ISO10BandEqualizer) Preamp() float64 {
return i.EQPreamp
}
func (i *ISO10BandEqualizer) Curve() EqualizerCurve {
fC := float64(31.25)
curve := make([]EqualizerBand, 0, len(i.BandGains))
for _, bandGain := range i.BandGains {
curve = append(curve, EqualizerBand{
Frequency: int(math.Round(fC)),
Width: 1.0,
WidthType: WidthTypeOctave,
Gain: bandGain,
})
fC *= iso10FMult
}
return curve
}
func (*ISO10BandEqualizer) BandFrequencies() []string {
ret := make([]string, len(iso10Bands))
copy(ret, iso10Bands)
return ret
}
func (*ISO10BandEqualizer) Type() string {
return "ISO10Band"
}
+19
View File
@@ -72,6 +72,8 @@ type Player struct {
peaksEnabled bool
pauseFade bool
icyTitleCb func(string)
fileLoadedLock sync.Mutex
fileLoadedSig *sync.Cond
@@ -130,6 +132,8 @@ func (p *Player) Init(maxCacheMB int) error {
m.SetOptionString("audio-client-name", p.clientName)
}
m.ObserveProperty(0, "metadata", mpv.FORMAT_NODE)
if err := m.Initialize(); err != nil {
return fmt.Errorf("error initializing mpv: %s", err.Error())
}
@@ -442,6 +446,16 @@ func (p *Player) GetMediaInfo() (MediaInfo, error) {
return info, nil
}
func (p *Player) ObserveIcyRadioTitle(cb func(string)) {
p.icyTitleCb = cb
p.mpv.ObserveProperty(1, "metadata/icy-title", mpv.FORMAT_STRING)
}
func (p *Player) UnobserveIcyRadioTitle() {
p.icyTitleCb = nil
p.mpv.UnobserveProperty(1)
}
func (p *Player) getInt64Property(propName string) (int64, error) {
playpos, err := p.mpv.GetProperty(propName, mpv.FORMAT_INT64)
if err != nil {
@@ -548,6 +562,11 @@ func (p *Player) eventHandler(ctx context.Context) {
p.status.Duration = 0
p.status.TimePos = 0
p.setState(player.Stopped)
case mpv.EVENT_PROPERTY_CHANGE:
if e.Reply_Userdata == 1 && p.icyTitleCb != nil {
p.icyTitleCb(p.mpv.GetPropertyString("metadata/icy-title"))
}
}
}
}
+28 -1
View File
@@ -1,14 +1,26 @@
package util
import "time"
import (
"sync"
"time"
)
// Stopwatch is a thread-safe timer for measuring elapsed time.
// It can be started, stopped, and reset, and supports reading
// the elapsed time while running or stopped.
type Stopwatch struct {
mu sync.Mutex
running bool
started time.Time
elapsed time.Duration
}
// Start begins or resumes the stopwatch.
// If already running, this is a no-op.
func (s *Stopwatch) Start() {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return
}
@@ -16,7 +28,12 @@ func (s *Stopwatch) Start() {
s.running = true
}
// Stop pauses the stopwatch and accumulates the elapsed time.
// If already stopped, this is a no-op.
func (s *Stopwatch) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return
}
@@ -24,7 +41,13 @@ func (s *Stopwatch) Stop() {
s.running = false
}
// Elapsed returns the total elapsed time.
// If the stopwatch is running, includes time since last Start().
// Safe to call concurrently with other methods.
func (s *Stopwatch) Elapsed() time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
e := s.elapsed
if s.running {
e += time.Since(s.started)
@@ -32,7 +55,11 @@ func (s *Stopwatch) Elapsed() time.Duration {
return e
}
// Reset stops the stopwatch and clears the elapsed time.
func (s *Stopwatch) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.running = false
s.elapsed = time.Duration(0)
}
+167
View File
@@ -0,0 +1,167 @@
package util
import (
"sync"
"testing"
"time"
)
func TestStopwatch_Basic(t *testing.T) {
sw := &Stopwatch{}
// Test initial state
if elapsed := sw.Elapsed(); elapsed != 0 {
t.Errorf("Expected initial elapsed time to be 0, got %v", elapsed)
}
// Test start and elapsed
sw.Start()
time.Sleep(10 * time.Millisecond)
elapsed := sw.Elapsed()
if elapsed < 10*time.Millisecond {
t.Errorf("Expected at least 10ms elapsed, got %v", elapsed)
}
// Test stop
sw.Stop()
stoppedElapsed := sw.Elapsed()
time.Sleep(10 * time.Millisecond)
if sw.Elapsed() != stoppedElapsed {
t.Error("Elapsed time should not increase after Stop()")
}
// Test reset
sw.Reset()
if elapsed := sw.Elapsed(); elapsed != 0 {
t.Errorf("Expected elapsed time to be 0 after reset, got %v", elapsed)
}
}
func TestStopwatch_StartStop(t *testing.T) {
sw := &Stopwatch{}
// Start, accumulate some time
sw.Start()
time.Sleep(10 * time.Millisecond)
sw.Stop()
firstElapsed := sw.Elapsed()
// Start again, accumulate more time
sw.Start()
time.Sleep(10 * time.Millisecond)
sw.Stop()
secondElapsed := sw.Elapsed()
if secondElapsed <= firstElapsed {
t.Errorf("Expected elapsed time to accumulate, first=%v second=%v", firstElapsed, secondElapsed)
}
}
func TestStopwatch_DoubleStart(t *testing.T) {
sw := &Stopwatch{}
sw.Start()
time.Sleep(5 * time.Millisecond)
firstStart := sw.Elapsed()
// Second Start() should be no-op
sw.Start()
time.Sleep(5 * time.Millisecond)
secondStart := sw.Elapsed()
// Time should continue from first start
if secondStart < firstStart {
t.Error("Second Start() affected timing")
}
}
func TestStopwatch_DoubleStop(t *testing.T) {
sw := &Stopwatch{}
sw.Start()
time.Sleep(10 * time.Millisecond)
sw.Stop()
elapsed := sw.Elapsed()
// Second Stop() should be no-op
sw.Stop()
if sw.Elapsed() != elapsed {
t.Error("Second Stop() changed elapsed time")
}
}
func TestStopwatch_ConcurrentAccess(t *testing.T) {
sw := &Stopwatch{}
var wg sync.WaitGroup
// Test concurrent Start/Stop/Elapsed calls
// This should not cause data races
const goroutines = 10
const iterations = 100
wg.Add(goroutines * 3)
// Concurrent starts
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
sw.Start()
time.Sleep(time.Microsecond)
}
}()
}
// Concurrent stops
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
sw.Stop()
time.Sleep(time.Microsecond)
}
}()
}
// Concurrent reads
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
_ = sw.Elapsed()
time.Sleep(time.Microsecond)
}
}()
}
wg.Wait()
// If we got here without data races, the test passes
// Run with: go test -race
}
func TestStopwatch_Reset(t *testing.T) {
sw := &Stopwatch{}
// Reset when stopped
sw.Reset()
if elapsed := sw.Elapsed(); elapsed != 0 {
t.Errorf("Expected 0 after reset, got %v", elapsed)
}
// Reset when running
sw.Start()
time.Sleep(10 * time.Millisecond)
sw.Reset()
if elapsed := sw.Elapsed(); elapsed != 0 {
t.Errorf("Expected 0 after reset while running, got %v", elapsed)
}
// After reset, should be able to start again
sw.Start()
time.Sleep(5 * time.Millisecond)
elapsed := sw.Elapsed()
if elapsed < 5*time.Millisecond {
t.Errorf("Expected at least 5ms after reset and start, got %v", elapsed)
}
}
+39 -8
View File
@@ -25,6 +25,13 @@ type WaveformImageGenerator struct {
audioCache *AudioCache
}
// Buffer pool for waveform analysis to reduce allocations
var audioBufferPool = sync.Pool{
New: func() any {
return &audio.IntBuffer{Data: make([]int, 4096)}
},
}
type WaveformImage = image.NRGBA
func NewWaveformImage() *WaveformImage {
@@ -192,13 +199,19 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra
}
// Start analyzing the converted wav file
data := &waveformData{}
data := &waveformData{notify: make(chan struct{}, 1)}
go func() {
err := analyzeWavFile(ctx, transcodeFile, data, item.Duration.Milliseconds(), func() bool { return wavConvertDone })
if err != nil {
job.setError(err)
}
data.done = true
// Final notification that processing is complete
select {
case data.notify <- struct{}{}:
default:
}
close(data.notify)
}()
// Start generating the waveform image
@@ -216,6 +229,7 @@ type waveformData struct {
progress int // first invalid index for Peak/RMS data
done bool
notify chan struct{} // signals when new data is available
}
func generateWaveformImage(ctx context.Context, data *waveformData, job *WaveformImageJob) {
@@ -227,14 +241,18 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor
translucentColor := color.NRGBA{R: 255, G: 255, B: 255, A: 128}
for x := range 1024 {
for data.progress <= x {
if data.done {
return
}
if ctx.Err() != nil {
// Wait for data to be available instead of polling
for data.progress <= x && !data.done {
select {
case <-ctx.Done():
return // expired
case <-data.notify:
// New data available or processing complete
}
time.Sleep(50 * time.Millisecond)
}
if data.progress <= x {
return // done but data not available for this x
}
rmsPixels := int(data.RMS[x]) * centerY / 255
@@ -282,7 +300,10 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat
return err
}
buf := &audio.IntBuffer{Data: make([]int, 4096)}
// Get buffer from pool to reduce allocations
buf := audioBufferPool.Get().(*audio.IntBuffer)
defer audioBufferPool.Put(buf)
curChunk := 0
chunkSamples := make([]float64, 0, samplesPerChunk)
bytesPerSample := int64(2 * format.NumChannels) // 16-bit = 2 bytes per channel
@@ -353,6 +374,11 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat
}
curChunk++
data.progress = curChunk
// Notify that new data is available (non-blocking)
select {
case data.notify <- struct{}{}:
default:
}
chunkSamples = chunkSamples[:0]
if curChunk >= 1024 {
break
@@ -367,6 +393,11 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat
data.Peak[curChunk] = float64ToByte(peak)
data.RMS[curChunk] = float64ToByte(rms)
data.progress = curChunk + 1
// Notify that final data is available (non-blocking)
select {
case data.notify <- struct{}{}:
default:
}
}
return nil
+75 -75
View File
@@ -1,75 +1,75 @@
//go:build windows
package windows
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"fyne.io/fyne/v2"
)
// The general approach here is copied from Fyne.
// While it seems very hacky (create a temporary Powershell script and execute it),
// shockingly it may be the best approach, at least in the non-installed case.
// The proper Windows APIs for this require WinRT (ie C++/ a DLL), and also require
// the app to be installed with a unique ID in the start menu, and to pass this ID
// when sending the notification. This could be a future exploration for the installer.
const notificationTemplate = `$title = "%s"
$content = "%s"
$iconPath = "file:///%s"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastImageAndText02)
$toastXml = [xml] $template.GetXml()
$toastXml.GetElementsByTagName("text")[0].AppendChild($toastXml.CreateTextNode($title)) > $null
$toastXml.GetElementsByTagName("text")[1].AppendChild($toastXml.CreateTextNode($content)) > $null
$toastXml.GetElementsByTagName("image")[0].SetAttribute("src", $iconPath) > $null
$audio = $toastXml.CreateElement("audio")
$audio.SetAttribute("silent", "true") > $null
$toastXml.DocumentElement.AppendChild($audio) > $null
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($toastXml.OuterXml)
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("%s").Show($toast);`
func SendNotification(n *fyne.Notification, iconFilePath string) {
title := escapeNotificationString(n.Title)
content := escapeNotificationString(n.Content)
script := fmt.Sprintf(notificationTemplate, title, content, iconFilePath, "supersonic")
go runScript("notify", script)
}
func escapeNotificationString(in string) string {
noSlash := strings.ReplaceAll(in, "`", "``")
return strings.ReplaceAll(noSlash, "\"", "`\"")
}
var scriptNum = 0
func runScript(name, script string) {
scriptNum++
appID := fyne.CurrentApp().UniqueID()
fileName := fmt.Sprintf("supersonic-%s-%s-%d.ps1", appID, name, scriptNum)
tmpFilePath := filepath.Join(os.TempDir(), fileName)
err := os.WriteFile(tmpFilePath, []byte(script), 0o600)
if err != nil {
fyne.LogError("Could not write script to show notification", err)
return
}
defer os.Remove(tmpFilePath)
launch := "(Get-Content -Encoding UTF8 -Path " + tmpFilePath + " -Raw) | Invoke-Expression"
cmd := exec.Command("PowerShell", "-ExecutionPolicy", "Bypass", launch)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
err = cmd.Run()
if err != nil {
fyne.LogError("Failed to launch windows notify script", err)
}
}
//go:build windows
package windows
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"fyne.io/fyne/v2"
)
// The general approach here is copied from Fyne.
// While it seems very hacky (create a temporary Powershell script and execute it),
// shockingly it may be the best approach, at least in the non-installed case.
// The proper Windows APIs for this require WinRT (ie C++/ a DLL), and also require
// the app to be installed with a unique ID in the start menu, and to pass this ID
// when sending the notification. This could be a future exploration for the installer.
const notificationTemplate = `$title = "%s"
$content = "%s"
$iconPath = "file:///%s"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastImageAndText02)
$toastXml = [xml] $template.GetXml()
$toastXml.GetElementsByTagName("text")[0].AppendChild($toastXml.CreateTextNode($title)) > $null
$toastXml.GetElementsByTagName("text")[1].AppendChild($toastXml.CreateTextNode($content)) > $null
$toastXml.GetElementsByTagName("image")[0].SetAttribute("src", $iconPath) > $null
$audio = $toastXml.CreateElement("audio")
$audio.SetAttribute("silent", "true") > $null
$toastXml.DocumentElement.AppendChild($audio) > $null
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($toastXml.OuterXml)
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("%s").Show($toast);`
func SendNotification(n *fyne.Notification, iconFilePath string) {
title := escapeNotificationString(n.Title)
content := escapeNotificationString(n.Content)
script := fmt.Sprintf(notificationTemplate, title, content, iconFilePath, "supersonic")
go runScript("notify", script)
}
func escapeNotificationString(in string) string {
noSlash := strings.ReplaceAll(in, "`", "``")
return strings.ReplaceAll(noSlash, "\"", "`\"")
}
var scriptNum = 0
func runScript(name, script string) {
scriptNum++
appID := fyne.CurrentApp().UniqueID()
fileName := fmt.Sprintf("supersonic-%s-%s-%d.ps1", appID, name, scriptNum)
tmpFilePath := filepath.Join(os.TempDir(), fileName)
err := os.WriteFile(tmpFilePath, []byte(script), 0o600)
if err != nil {
fyne.LogError("Could not write script to show notification", err)
return
}
defer os.Remove(tmpFilePath)
launch := "(Get-Content -Encoding UTF8 -Path " + tmpFilePath + " -Raw) | Invoke-Expression"
cmd := exec.Command("PowerShell", "-ExecutionPolicy", "Bypass", launch)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
err = cmd.Run()
if err != nil {
fyne.LogError("Failed to launch windows notify script", err)
}
}
+10 -10
View File
@@ -1,10 +1,10 @@
//go:build !windows
package windows
import "fyne.io/fyne/v2"
func SendNotification(n *fyne.Notification, iconFilePath string) {
fyne.LogError("windows.SendNotification should not be invoked on non-Windows platform", nil)
fyne.CurrentApp().SendNotification(n)
}
//go:build !windows
package windows
import "fyne.io/fyne/v2"
func SendNotification(n *fyne.Notification, iconFilePath string) {
fyne.LogError("windows.SendNotification should not be invoked on non-Windows platform", nil)
fyne.CurrentApp().SendNotification(n)
}