merge conflicts
This commit is contained in:
+72
-15
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user