1package main23import (4 "context"5 "errors"6 "log"7 "os"8 "sync"9 "time"1011 "github.com/wailsapp/wails/v2/pkg/runtime"1213 "airwaves/internal/api"14 "airwaves/internal/dvr"15 "airwaves/internal/podcast"16 "airwaves/internal/service"17 "airwaves/internal/store"18 "airwaves/internal/stream"19 "airwaves/internal/weather"20)2122const appName = "airwaves"2324// errNoServer is returned until a server address is set.25var errNoServer = errors.New("no Airwaves server set")2627// App is the desktop remote for an airwavesd server, which owns the tuner28// (an HDHomeRun), the lineup, the guide and recordings.29type App struct {30 ctx context.Context3132 mu sync.Mutex33 settings store.Settings34 client *api.Client35}3637// NewApp loads settings.38func NewApp() *App {39 s, err := store.LoadSettings(appName)40 if err != nil {41 log.Printf("settings: %v", err)42 }43 // Persist a generated client ID so the server sees a stable client.44 if s, err = store.SaveSettings(appName, s); err != nil {45 log.Printf("save settings: %v", err)46 }47 a := &App{settings: s}48 a.connectLocked()49 return a50}5152func (a *App) startup(ctx context.Context) { a.ctx = ctx }5354func (a *App) shutdown(context.Context) {55 c, s, err := a.server()56 if err != nil {57 return58 }59 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)60 defer cancel()61 _ = c.Stop(ctx, s.ClientID)62}6364func (a *App) connectLocked() {65 a.client = nil66 if a.settings.Server != "" {67 a.client = api.NewClient(a.settings.Server, a.settings.Token)68 }69}7071func (a *App) server() (*api.Client, store.Settings, error) {72 a.mu.Lock()73 defer a.mu.Unlock()74 if a.client == nil {75 return nil, a.settings, errNoServer76 }77 return a.client, a.settings, nil78}7980// Boot is the frontend's first call.81type Boot struct {82 Settings store.Settings `json:"settings"`83 Info service.Info `json:"info"`84 Config service.Config `json:"config"`85 Error string `json:"error,omitempty"` // server unset or unreachable86 // ServerURL is the server's base URL, for images and the WX channel.87 ServerURL string `json:"serverUrl,omitempty"`88 View string `json:"view"` // initial view override89 // Lite is AIRWAVES_LITE: "1" or "0" turns the frontend's lite mode (no90 // blurs or animations, the default on Linux) on or off.91 Lite string `json:"lite,omitempty"`92 // AutoScale is the interface size Auto stands for, in percent.93 AutoScale int `json:"autoScale"`94 // NativeZoom: SetZoom sizes the interface (WebKitGTK's page zoom);95 // otherwise the frontend uses CSS zoom.96 NativeZoom bool `json:"nativeZoom,omitempty"`97 // Version is the app's.98 Version string `json:"version"`99 // Platform is which of the frontend's three experiences this is:100 // "desktop", or "console" under gamescope; AIRWAVES_PLATFORM (desktop,101 // console or tv) says otherwise.102 Platform string `json:"platform"`103}104105// Boot returns settings and the server's description. AIRWAVES_VIEW opens a106// specific view at launch (tv, info, guide, antenna, recordings, settings).107func (a *App) Boot() Boot {108 c, s, err := a.server()109 out := Boot{Settings: s, View: os.Getenv("AIRWAVES_VIEW"), Lite: os.Getenv("AIRWAVES_LITE"),110 AutoScale: autoScale(), NativeZoom: nativeZoom, Version: version, Platform: platform()}111 if err != nil {112 out.Error = err.Error()113 return out114 }115 out.ServerURL = c.Base116 ctx, cancel := context.WithTimeout(a.ctx, 10*time.Second)117 defer cancel()118 if out.Info, err = c.Info(ctx); err != nil {119 out.Error = err.Error()120 return out121 }122 if out.Config, err = c.Config(ctx); err != nil {123 out.Error = err.Error()124 }125 return out126}127128// SaveSettings persists app settings, reconnecting when the server129// changed, and returns a fresh Boot.130func (a *App) SaveSettings(s store.Settings) (Boot, error) {131 a.mu.Lock()132 prev := a.settings133 saved, err := store.SaveSettings(appName, s)134 if err != nil {135 a.mu.Unlock()136 return Boot{}, err137 }138 a.settings = saved139 old := a.client140 if saved.Server != prev.Server || saved.Token != prev.Token || a.client == nil {141 a.connectLocked()142 }143 a.mu.Unlock()144 if old != nil && old != a.client {145 _ = old.Stop(a.ctx, prev.ClientID)146 }147 return a.Boot(), nil148}149150// SetZoom sizes the interface where the page is zoomed natively (Linux):151// 1.25 is 125%.152func (a *App) SetZoom(zoom float64) {153 if zoom >= 0.5 && zoom <= 3 {154 setPageZoom(zoom)155 }156}157158// scale is the interface size in percent: the setting, or Auto's.159func (a *App) scale() int {160 a.mu.Lock()161 defer a.mu.Unlock()162 if a.settings.Scale > 0 {163 return a.settings.Scale164 }165 return autoScale()166}167168// SetConfig changes server settings the app exposes (hours of listings).169func (a *App) SetConfig(c service.Config) (service.Config, error) {170 cl, _, err := a.server()171 if err != nil {172 return c, err173 }174 return cl.SetConfig(a.ctx, c)175}176177// TestServer checks a server address without switching to it.178func (a *App) TestServer(server, token string) (service.Info, error) {179 ctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)180 defer cancel()181 return api.NewClient(server, token).Info(ctx)182}183184// Scan returns the lineup and listings. With refresh the server ignores185// cached FCC, ATSC 3.0 and listings data.186func (a *App) Scan(refresh bool) (*service.Snapshot, error) {187 c, s, err := a.server()188 if err != nil {189 return nil, err190 }191 runtime.EventsEmit(a.ctx, "progress", "Loading lineup and listings from "+s.Server)192 return c.Snapshot(a.ctx, refresh)193}194195// Signal returns what the server's tuners measured, by RF channel and by196// channel, and how Measure now is going.197func (a *App) Signal() (*service.SignalReport, error) {198 c, _, err := a.server()199 if err != nil {200 return nil, err201 }202 return c.Signal(a.ctx)203}204205// Measure starts measuring every RF channel on an idle tuner.206func (a *App) Measure() (*service.SweepStatus, error) {207 c, _, err := a.server()208 if err != nil {209 return nil, err210 }211 return c.Measure(a.ctx)212}213214// ScanChannels has the server's tuner scan for channels again.215func (a *App) ScanChannels() (service.TunerInfo, error) {216 c, _, err := a.server()217 if err != nil {218 return service.TunerInfo{}, err219 }220 return c.ScanChannels(a.ctx)221}222223// Weather returns the local weather report from the server.224func (a *App) Weather() (*weather.Report, error) {225 c, _, err := a.server()226 if err != nil {227 return nil, err228 }229 return c.Weather(a.ctx)230}231232// CopyText puts text on the clipboard.233func (a *App) CopyText(text string) error {234 return runtime.ClipboardSetText(a.ctx, text)235}236237// Tune starts playback of a virtual channel.238func (a *App) Tune(number string) (*stream.Playback, error) {239 c, s, err := a.server()240 if err != nil {241 return nil, err242 }243 pb, err := c.Tune(a.ctx, s.ClientID, number)244 if err != nil {245 return nil, err246 }247 a.mu.Lock()248 a.settings.LastChannel = number249 saved := a.settings250 a.mu.Unlock()251 if _, err := store.SaveSettings(appName, saved); err != nil {252 log.Printf("save last channel: %v", err)253 }254 return pb, nil255}256257// StopTV ends playback.258func (a *App) StopTV() {259 if c, s, err := a.server(); err == nil {260 _ = c.Stop(a.ctx, s.ClientID)261 }262}263264// Heartbeat keeps this app's stream from being stopped as idle while it265// holds it (paused), and reports whether the server still has it.266func (a *App) Heartbeat() (bool, error) {267 c, s, err := a.server()268 if err != nil {269 return false, err270 }271 return c.Heartbeat(a.ctx, s.ClientID)272}273274// DVR returns recording rules, schedule and library.275func (a *App) DVR() (*dvr.State, error) {276 c, _, err := a.server()277 if err != nil {278 return nil, err279 }280 return c.DVR(a.ctx)281}282283// Record adds a recording rule for an airing picked in the guide.284func (a *App) Record(req dvr.Request) (*dvr.Rule, error) {285 c, _, err := a.server()286 if err != nil {287 return nil, err288 }289 return c.Record(a.ctx, req)290}291292// DeleteRule removes a recording rule.293func (a *App) DeleteRule(id string) error {294 c, _, err := a.server()295 if err != nil {296 return err297 }298 return c.DeleteRule(a.ctx, id)299}300301// DeleteRecording skips, cancels or deletes one recording.302func (a *App) DeleteRecording(id string) error {303 c, _, err := a.server()304 if err != nil {305 return err306 }307 return c.DeleteRecording(a.ctx, id)308}309310// StopRecording ends a recording in progress early, keeping it.311func (a *App) StopRecording(id string) error {312 c, _, err := a.server()313 if err != nil {314 return err315 }316 return c.StopRecording(a.ctx, id)317}318319// ExtendRecording makes a recording in progress run minutes longer, and320// says until when.321func (a *App) ExtendRecording(id string, minutes int) (time.Time, error) {322 c, _, err := a.server()323 if err != nil {324 return time.Time{}, err325 }326 return c.ExtendRecording(a.ctx, id, minutes)327}328329// PlayRecording streams a recording from about from seconds in.330func (a *App) PlayRecording(id string, from float64) (*stream.Playback, error) {331 c, s, err := a.server()332 if err != nil {333 return nil, err334 }335 if id == "" {336 return nil, errors.New("no recording selected")337 }338 return c.PlayRecording(a.ctx, s.ClientID, id, from)339}340341// Podcasts returns the Podcasts app's shows and their episodes.342func (a *App) Podcasts() (*podcast.State, error) {343 c, _, err := a.server()344 if err != nil {345 return nil, err346 }347 return c.Podcasts(a.ctx)348}349350// RefreshPodcasts lists every show again now, for the newest episodes.351func (a *App) RefreshPodcasts() (*podcast.State, error) {352 c, _, err := a.server()353 if err != nil {354 return nil, err355 }356 return c.RefreshPodcasts(a.ctx)357}358359// SearchPodcasts finds YouTube channels to add as shows.360func (a *App) SearchPodcasts(query string) ([]podcast.Found, error) {361 c, _, err := a.server()362 if err != nil {363 return nil, err364 }365 return c.SearchPodcasts(a.ctx, query)366}367368// AddPodcast adds a show: a YouTube channel, its live streams' tab or a369// playlist; minMinutes is the shortest video that's an episode, 0 for the370// default.371func (a *App) AddPodcast(url string, minMinutes float64) (*podcast.Show, error) {372 c, _, err := a.server()373 if err != nil {374 return nil, err375 }376 return c.AddPodcast(a.ctx, url, minMinutes)377}378379// RemovePodcast takes a show out of the library.380func (a *App) RemovePodcast(id string) error {381 c, _, err := a.server()382 if err != nil {383 return err384 }385 return c.RemovePodcast(a.ctx, id)386}387388// PlayEpisode streams an episode from from seconds in, in place of TV.389func (a *App) PlayEpisode(id string, from float64) (*stream.Playback, error) {390 c, s, err := a.server()391 if err != nil {392 return nil, err393 }394 if id == "" {395 return nil, errors.New("no episode selected")396 }397 return c.PlayEpisode(a.ctx, s.ClientID, id, from)398}399400// EpisodeProgress records how far an episode has been watched, and401// whether it has.402func (a *App) EpisodeProgress(id string, position, duration float64, watched bool) error {403 c, _, err := a.server()404 if err != nil {405 return err406 }407 return c.EpisodeProgress(a.ctx, id, position, duration, watched)408}409410// SaveProgress records how far a recording has been watched.411func (a *App) SaveProgress(id string, position, duration float64) error {412 c, _, err := a.server()413 if err != nil {414 return err415 }416 return c.SaveProgress(a.ctx, id, position, duration)417}418419// MarkWatched sets a recording's watched state.420func (a *App) MarkWatched(id string, watched bool) error {421 c, _, err := a.server()422 if err != nil {423 return err424 }425 return c.MarkWatched(a.ctx, id, watched)426}427428// UpdateRule changes a series rule's keep count and new-only setting.429func (a *App) UpdateRule(id string, u dvr.RuleUpdate) (*dvr.Rule, error) {430 c, _, err := a.server()431 if err != nil {432 return nil, err433 }434 return c.UpdateRule(a.ctx, id, u)435}436437// SetDVRPrefs changes library-wide recording settings.438func (a *App) SetDVRPrefs(p dvr.Prefs) (dvr.Prefs, error) {439 c, _, err := a.server()440 if err != nil {441 return p, err442 }443 return c.SetDVRPrefs(a.ctx, p)444}445446// OpenURL opens a link in the system browser.447func (a *App) OpenURL(u string) { runtime.BrowserOpenURL(a.ctx, u) }448449// Log records a frontend message on stderr.450func (a *App) Log(level, msg string) { log.Printf("ui %s: %s", level, msg) }