Files
yellowjacket/backend/app.go
T
yonlu 0860b2fd4b fix(02-01): log MPRIS callback errors instead of discarding them
- OnPause, OnPlayPause, OnStop, OnSeek now check errors and log at Warn level
- All four MPRIS closures use yj.logger.Warn for non-fatal error reporting
- No more silently discarded player.Pause() / player.Seek() errors
2026-03-02 18:39:54 -05:00

268 lines
7.0 KiB
Go

// Package backend contains the main application logic.
package backend
//go:generate go tool templ generate
import (
"context"
"errors"
"fmt"
"log/slog"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/assets"
"yellowjacket/backend/config"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
"yellowjacket/backend/frontendutil"
"yellowjacket/backend/library"
"yellowjacket/backend/mediacontrols"
"yellowjacket/backend/player"
"yellowjacket/backend/playlist"
"yellowjacket/backend/profiling"
"yellowjacket/backend/queue"
)
// YellowJacketApp is the main application struct for Wails.
type YellowJacketApp struct {
FEBindings []any
FrontendUtil *frontendutil.FrontendUtil
logger *slog.Logger
assetHandler *assets.Handler
database *database.DB
library *library.Library
player *player.Player
playlist *playlist.Service
queue *queue.Queue
mediaControls mediacontrols.Handler
appContext context.Context
appConfig *config.Config
startupErr error
}
// NewYellowJacketApp creates and initializes the application.
func NewYellowJacketApp(
logger *slog.Logger,
assetHandler *assets.Handler,
) (*YellowJacketApp, error) {
defer profiling.TimeOp(logger, "app.NewYellowJacketApp")()
// initialize anything that does not need access to the wails runtime here
yjApp := &YellowJacketApp{
logger: logger,
assetHandler: assetHandler,
appContext: context.Background(),
}
// create database
db, err := database.NewDB(logger)
if err != nil {
return nil, fmt.Errorf("could not connect to local database: %w", err)
}
yjApp.database = db
// create config
appConfig, err := config.NewConfig(yjApp.logger)
if err != nil {
return nil, fmt.Errorf("could not get config: %w", err)
}
yjApp.appConfig = appConfig
// create frontendUtil
feUtil, err := frontendutil.NewFrontendUtil()
if err != nil {
return nil, fmt.Errorf("could not create frontendUtil: %w", err)
}
yjApp.FrontendUtil = feUtil
lib, err := library.NewLibrary(
yjApp.appContext,
yjApp.logger,
yjApp.appConfig.Library,
yjApp.database,
)
if err != nil {
return nil, fmt.Errorf("could not create library: %w", err)
}
yjApp.library = lib
// create cover art handler
coverHandler, err := coverart.NewHandler()
if err != nil {
return nil, fmt.Errorf("could not create cover art handler: %w", err)
}
yjApp.assetHandler.RegisterHandler(coverart.PathPrefix, coverHandler)
// create playlist service
yjApp.playlist = playlist.NewService(
yjApp.logger, yjApp.database, yjApp.appConfig,
)
yjApp.playlist.SetFavoritesConfig(yjApp.appConfig)
// create queue (before wails.Run so it can be bound)
yjApp.queue = queue.NewQueue(yjApp.logger, yjApp.database)
// create player (before wails.Run so it can be bound;
// speaker hardware is initialized later in OnStartup)
yjApp.player = player.NewPlayer(
yjApp.logger.WithGroup("player"), yjApp.database,
)
yjApp.FEBindings = []any{
yjApp.FrontendUtil,
yjApp.appConfig,
yjApp.library,
yjApp.playlist,
yjApp.queue,
yjApp.player,
}
return yjApp, nil
}
// WindowConfig returns the window configuration for use by the host.
func (yj *YellowJacketApp) WindowConfig() *config.WindowConfig {
return yj.appConfig.Window
}
// OnStartup initializes components that require the Wails runtime context.
func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
defer profiling.TimeOp(yj.logger, "app.OnStartup")()
// initialize anything that needs to use the wails runtime AFTER its been initialized
// you CANNOT use the wails runtime during this function
yj.appContext = ctx
// Set context for components that need Wails runtime for events
yj.appConfig.SetContext(ctx)
yj.FrontendUtil.SetContext(ctx)
yj.library.SetContext(ctx)
yj.playlist.SetContext(ctx)
yj.playlist.EnsureDefaultPlaylist()
// Initialize speaker hardware (player struct created in
// NewYellowJacketApp for Wails binding registration).
if err := yj.player.InitSpeaker(); err != nil {
yj.startupErr = errors.Join(
yj.startupErr,
fmt.Errorf("could not initialize speaker: %w", err),
)
}
yj.player.SetContext(ctx)
// Wire queue (created in NewYellowJacketApp for Wails binding)
yj.queue.SetContext(ctx)
yj.queue.SetPlayer(yj.player)
yj.queue.RestoreState()
// Wire cross-cutting rescan hooks so the library can
// orchestrate queue clearing and playlist restoration
// without depending on those packages directly.
yj.library.SetRescanHooks(library.RescanHooks{
PreClear: yj.queue.Clear,
PostScan: yj.playlist.RestoreAllPlaylists,
})
// Register playback finished handler to drive queue auto-advance.
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
// Initialize OS media controls (MPRIS on Linux, no-op elsewhere).
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
OnPlay: yj.queue.Play,
OnPause: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Pause failed", "err", err)
}
},
OnPlayPause: func() {
if yj.player.IsPlaying() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err)
}
} else {
yj.queue.Play()
}
},
OnStop: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Stop failed", "err", err)
}
},
OnNext: yj.queue.Next,
OnPrevious: yj.queue.Previous,
OnSeek: func(positionSec int) {
if err := yj.player.Seek(positionSec); err != nil {
yj.logger.Warn("MPRIS Seek failed", "err", err)
}
},
OnVolume: func(vol float64) {
yj.player.SetVolume(
player.UserVolume(
vol * float64(player.MaxUserVol),
),
)
},
}); err != nil {
yj.logger.Error(
"Failed to initialize media controls",
"err", err,
)
}
yj.player.SetMediaControls(yj.mediaControls)
}
// OnBeforeClose captures window state while the window is still alive.
func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
w, h := wailsruntime.WindowGetSize(ctx)
yj.appConfig.Window.Width = w
yj.appConfig.Window.Height = h
if err := yj.appConfig.Save(); err != nil {
yj.logger.Error(
"Failed to save window state",
"err", err,
)
}
return false
}
// OnShutdown saves player state and cleans up resources before the application exits.
func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
if yj.player != nil {
yj.player.SaveState()
}
if yj.queue != nil {
yj.queue.SaveState()
}
if yj.mediaControls != nil {
yj.mediaControls.Close()
}
}
// OnDomReady handles post-DOM initialization and startup error reporting.
// State synchronisation (player volume, track info, queue contents) is
// driven by the frontend: once its stores have registered their event
// listeners, index.ts calls Player.EmitCurrentState() and
// Queue.EmitCurrentState() via Wails bindings.
func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
if yj.startupErr != nil {
yj.logger.Error("startup error", "err", yj.startupErr.Error())
wailsruntime.Quit(ctx)
}
}