598 lines
17 KiB
Go
598 lines
17 KiB
Go
//go:build gtk
|
|||
|
|
|
||
|
|
// Package gui implements Captioneer's GTK4 desktop application.
|
||
|
|
package gui
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
_ "embed"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"os/signal"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/diamondburned/gotk4/pkg/gdk/v4"
|
||
|
|
"github.com/diamondburned/gotk4/pkg/gio/v2"
|
||
|
|
"github.com/diamondburned/gotk4/pkg/glib/v2"
|
||
|
|
"github.com/diamondburned/gotk4/pkg/gtk/v4"
|
||
|
|
"tea.chunkbyte.com/kato/captioneer/src/app"
|
||
|
|
"tea.chunkbyte.com/kato/captioneer/src/config"
|
||
|
|
"tea.chunkbyte.com/kato/captioneer/src/diagnostics"
|
||
|
|
"tea.chunkbyte.com/kato/captioneer/src/output"
|
||
|
|
"tea.chunkbyte.com/kato/captioneer/src/output/history"
|
||
|
|
"tea.chunkbyte.com/kato/captioneer/src/output/overlay"
|
||
|
|
"tea.chunkbyte.com/kato/captioneer/src/output/terminal"
|
||
|
|
"tea.chunkbyte.com/kato/captioneer/src/output/transcript"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
applicationID = "com.chunkbyte.captioneer"
|
||
|
|
historyCapacity = 500
|
||
|
|
consoleCapacity = 2000
|
||
|
|
)
|
||
|
|
|
||
|
|
//go:embed style.css
|
||
|
|
var applicationCSS string
|
||
|
|
|
||
|
|
type Application struct {
|
||
|
|
launch config.LaunchConfig
|
||
|
|
config config.AppConfig
|
||
|
|
applied config.AppConfig
|
||
|
|
stdout io.Writer
|
||
|
|
stderr io.Writer
|
||
|
|
|
||
|
|
gtkApp *gtk.Application
|
||
|
|
window *gtk.ApplicationWindow
|
||
|
|
stack *gtk.Stack
|
||
|
|
|
||
|
|
router *output.Router
|
||
|
|
history *history.Sink
|
||
|
|
log *diagnostics.Log
|
||
|
|
controller *app.Controller
|
||
|
|
overlay *overlay.Sink
|
||
|
|
transcript *transcript.Sink
|
||
|
|
|
||
|
|
statusLabel *gtk.Label
|
||
|
|
statusDot *gtk.Label
|
||
|
|
startStopButton *gtk.Button
|
||
|
|
statusbarLabel *gtk.Label
|
||
|
|
captionBuffer *gtk.TextBuffer
|
||
|
|
captionView *gtk.TextView
|
||
|
|
captionScroll *gtk.ScrolledWindow
|
||
|
|
provisionalLabel *gtk.Label
|
||
|
|
consoleBuffer *gtk.TextBuffer
|
||
|
|
consoleView *gtk.TextView
|
||
|
|
consoleScroll *gtk.ScrolledWindow
|
||
|
|
settings settingsControls
|
||
|
|
settingsError *gtk.Label
|
||
|
|
applyButton *gtk.Button
|
||
|
|
lastShownError string
|
||
|
|
|
||
|
|
statusUpdates <-chan app.Status
|
||
|
|
stopStatus func()
|
||
|
|
historyUpdates <-chan history.Snapshot
|
||
|
|
stopHistory func()
|
||
|
|
logUpdates <-chan diagnostics.Entry
|
||
|
|
stopLog func()
|
||
|
|
uiDone chan struct{}
|
||
|
|
|
||
|
|
closingMu sync.Mutex
|
||
|
|
closing bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func Run(launch config.LaunchConfig, stdout, stderr io.Writer) int {
|
||
|
|
application := newApplication(launch, stdout, stderr)
|
||
|
|
application.gtkApp.ConnectActivate(application.activate)
|
||
|
|
signals := make(chan os.Signal, 1)
|
||
|
|
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||
|
|
defer signal.Stop(signals)
|
||
|
|
go func() {
|
||
|
|
select {
|
||
|
|
case <-signals:
|
||
|
|
glib.IdleAdd(application.shutdown)
|
||
|
|
case <-application.uiDone:
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
return application.gtkApp.Run([]string{os.Args[0]})
|
||
|
|
}
|
||
|
|
|
||
|
|
func newApplication(launch config.LaunchConfig, stdout, stderr io.Writer) *Application {
|
||
|
|
mirror := io.Discard
|
||
|
|
if launch.Config.Outputs.StderrDiagnostics {
|
||
|
|
mirror = stderr
|
||
|
|
}
|
||
|
|
log := diagnostics.New(consoleCapacity, mirror)
|
||
|
|
router := output.NewRouter()
|
||
|
|
historySink := history.New(historyCapacity, launch.Config.Outputs.History)
|
||
|
|
_ = router.Set("history", historySink)
|
||
|
|
controller := app.NewController(router, log.Writer(diagnostics.Info, "engine"))
|
||
|
|
return &Application{
|
||
|
|
launch: launch,
|
||
|
|
config: launch.Config,
|
||
|
|
applied: launch.Config,
|
||
|
|
stdout: stdout,
|
||
|
|
stderr: stderr,
|
||
|
|
gtkApp: gtk.NewApplication(applicationID, gio.ApplicationFlagsNone),
|
||
|
|
router: router,
|
||
|
|
history: historySink,
|
||
|
|
log: log,
|
||
|
|
controller: controller,
|
||
|
|
uiDone: make(chan struct{}),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) activate() {
|
||
|
|
if a.window != nil {
|
||
|
|
a.window.Present()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
a.installStyle()
|
||
|
|
a.buildWindow()
|
||
|
|
a.installActions()
|
||
|
|
a.subscribeModels()
|
||
|
|
if a.launch.Warning != nil {
|
||
|
|
a.log.Add(diagnostics.Warning, "configuration", fmt.Sprintf("%v; using built-in defaults", a.launch.Warning))
|
||
|
|
}
|
||
|
|
if err := a.configureOutputs(a.config, true); err != nil {
|
||
|
|
a.log.Add(diagnostics.Error, "outputs", err.Error())
|
||
|
|
}
|
||
|
|
a.window.Present()
|
||
|
|
a.applyAlwaysOnTop(a.config.GUI.MainWindowAlwaysOnTop)
|
||
|
|
if a.config.GUI.StartCaptions {
|
||
|
|
a.startCaptions()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) installStyle() {
|
||
|
|
provider := gtk.NewCSSProvider()
|
||
|
|
provider.LoadFromString(applicationCSS)
|
||
|
|
gtk.StyleContextAddProviderForDisplay(
|
||
|
|
gdk.DisplayGetDefault(),
|
||
|
|
provider,
|
||
|
|
gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) buildWindow() {
|
||
|
|
a.window = gtk.NewApplicationWindow(a.gtkApp)
|
||
|
|
a.window.SetTitle("Captioneer")
|
||
|
|
a.window.SetDefaultSize(980, 680)
|
||
|
|
a.window.SetSizeRequest(720, 480)
|
||
|
|
a.window.AddCSSClass("captioneer-main")
|
||
|
|
a.window.ConnectCloseRequest(func() bool {
|
||
|
|
a.shutdown()
|
||
|
|
return true
|
||
|
|
})
|
||
|
|
|
||
|
|
header := gtk.NewHeaderBar()
|
||
|
|
header.AddCSSClass("captioneer-header")
|
||
|
|
header.SetShowTitleButtons(true)
|
||
|
|
|
||
|
|
brand := gtk.NewBox(gtk.OrientationHorizontal, 7)
|
||
|
|
if iconPath := findIconPath(); iconPath != "" {
|
||
|
|
icon := gtk.NewImageFromFile(iconPath)
|
||
|
|
icon.SetPixelSize(27)
|
||
|
|
brand.Append(icon)
|
||
|
|
}
|
||
|
|
title := gtk.NewLabel("CAPTIONEER")
|
||
|
|
title.AddCSSClass("captioneer-brand")
|
||
|
|
brand.Append(title)
|
||
|
|
header.PackStart(brand)
|
||
|
|
|
||
|
|
statusBox := gtk.NewBox(gtk.OrientationHorizontal, 6)
|
||
|
|
statusBox.SetHAlign(gtk.AlignCenter)
|
||
|
|
a.statusDot = gtk.NewLabel("●")
|
||
|
|
a.statusDot.AddCSSClass("status-dot")
|
||
|
|
a.statusLabel = gtk.NewLabel("STOPPED · VAD")
|
||
|
|
a.statusLabel.AddCSSClass("status-text")
|
||
|
|
statusBox.Append(a.statusDot)
|
||
|
|
statusBox.Append(a.statusLabel)
|
||
|
|
header.SetTitleWidget(statusBox)
|
||
|
|
|
||
|
|
a.startStopButton = gtk.NewButtonWithLabel("START")
|
||
|
|
a.startStopButton.AddCSSClass("primary-action")
|
||
|
|
a.startStopButton.SetTooltipText("Start or stop caption processing")
|
||
|
|
a.startStopButton.ConnectClicked(a.toggleCaptions)
|
||
|
|
header.PackEnd(a.startStopButton)
|
||
|
|
a.window.SetTitlebar(header)
|
||
|
|
|
||
|
|
root := gtk.NewBox(gtk.OrientationVertical, 0)
|
||
|
|
root.Append(a.buildMenuBar())
|
||
|
|
a.stack = gtk.NewStack()
|
||
|
|
a.stack.SetTransitionType(gtk.StackTransitionTypeNone)
|
||
|
|
a.stack.SetHExpand(true)
|
||
|
|
a.stack.SetVExpand(true)
|
||
|
|
|
||
|
|
tabs := gtk.NewStackSwitcher()
|
||
|
|
tabs.SetStack(a.stack)
|
||
|
|
tabs.AddCSSClass("captioneer-tabs")
|
||
|
|
tabs.SetHAlign(gtk.AlignFill)
|
||
|
|
root.Append(tabs)
|
||
|
|
|
||
|
|
a.stack.AddTitled(a.buildCaptionsPage(), "captions", "CAPTIONS")
|
||
|
|
a.stack.AddTitled(a.buildConsolePage(), "console", "CONSOLE")
|
||
|
|
a.stack.AddTitled(a.buildSettingsPage(), "settings", "SETTINGS")
|
||
|
|
root.Append(a.stack)
|
||
|
|
|
||
|
|
statusbar := gtk.NewBox(gtk.OrientationHorizontal, 8)
|
||
|
|
statusbar.AddCSSClass("statusbar")
|
||
|
|
a.statusbarLabel = gtk.NewLabel("Source: not connected · Speech: quiet · Outputs: preparing")
|
||
|
|
a.statusbarLabel.SetXAlign(0)
|
||
|
|
a.statusbarLabel.SetEllipsize(3)
|
||
|
|
statusbar.Append(a.statusbarLabel)
|
||
|
|
root.Append(statusbar)
|
||
|
|
a.window.SetChild(root)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) buildMenuBar() *gtk.PopoverMenuBar {
|
||
|
|
root := gio.NewMenu()
|
||
|
|
file := gio.NewMenu()
|
||
|
|
file.Append("Start Captions", "app.start")
|
||
|
|
file.Append("Stop Captions", "app.stop")
|
||
|
|
file.Append("Restart Captions", "app.restart")
|
||
|
|
file.Append("Clear Caption History", "app.clear-history")
|
||
|
|
file.Append("Exit", "app.exit")
|
||
|
|
root.AppendSubmenu("File", file)
|
||
|
|
|
||
|
|
view := gio.NewMenu()
|
||
|
|
view.Append("Captions", "app.view-captions")
|
||
|
|
view.Append("Console", "app.view-console")
|
||
|
|
view.Append("Settings", "app.view-settings")
|
||
|
|
root.AppendSubmenu("View", view)
|
||
|
|
|
||
|
|
help := gio.NewMenu()
|
||
|
|
help.Append("Copy Diagnostics", "app.copy-diagnostics")
|
||
|
|
help.Append("Open Configuration Folder", "app.open-config-folder")
|
||
|
|
help.Append("About Captioneer", "app.about")
|
||
|
|
root.AppendSubmenu("Help", help)
|
||
|
|
bar := gtk.NewPopoverMenuBarFromModel(root)
|
||
|
|
bar.AddCSSClass("captioneer-menu")
|
||
|
|
return bar
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) installActions() {
|
||
|
|
a.addAction("start", a.startCaptions)
|
||
|
|
a.addAction("stop", a.stopCaptions)
|
||
|
|
a.addAction("restart", a.restartCaptions)
|
||
|
|
a.addAction("clear-history", a.history.Clear)
|
||
|
|
a.addAction("exit", a.shutdown)
|
||
|
|
a.addAction("view-captions", func() { a.stack.SetVisibleChildName("captions") })
|
||
|
|
a.addAction("view-console", func() { a.stack.SetVisibleChildName("console") })
|
||
|
|
a.addAction("view-settings", func() { a.stack.SetVisibleChildName("settings") })
|
||
|
|
a.addAction("copy-diagnostics", a.copyDiagnostics)
|
||
|
|
a.addAction("open-config-folder", a.openConfigFolder)
|
||
|
|
a.addAction("about", a.showAbout)
|
||
|
|
a.gtkApp.SetAccelsForAction("app.start", []string{"F5"})
|
||
|
|
a.gtkApp.SetAccelsForAction("app.stop", []string{"<Shift>F5"})
|
||
|
|
a.gtkApp.SetAccelsForAction("app.restart", []string{"<Primary>r"})
|
||
|
|
a.gtkApp.SetAccelsForAction("app.view-captions", []string{"<Alt>1"})
|
||
|
|
a.gtkApp.SetAccelsForAction("app.view-console", []string{"<Alt>2"})
|
||
|
|
a.gtkApp.SetAccelsForAction("app.view-settings", []string{"<Alt>3"})
|
||
|
|
a.gtkApp.SetAccelsForAction("app.exit", []string{"<Primary>q"})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) addAction(name string, callback func()) {
|
||
|
|
action := gio.NewSimpleAction(name, nil)
|
||
|
|
action.ConnectActivate(func(*glib.Variant) { callback() })
|
||
|
|
a.gtkApp.AddAction(action)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) subscribeModels() {
|
||
|
|
a.statusUpdates, a.stopStatus = a.controller.Subscribe(32)
|
||
|
|
a.historyUpdates, a.stopHistory = a.history.Subscribe(8)
|
||
|
|
a.logUpdates, a.stopLog = a.log.Subscribe(128)
|
||
|
|
go a.forwardStatus()
|
||
|
|
go a.forwardHistory()
|
||
|
|
go a.forwardLog()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) forwardStatus() {
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-a.uiDone:
|
||
|
|
return
|
||
|
|
case status, ok := <-a.statusUpdates:
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
copy := status
|
||
|
|
glib.IdleAdd(func() { a.renderStatus(copy) })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) forwardHistory() {
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-a.uiDone:
|
||
|
|
return
|
||
|
|
case snapshot, ok := <-a.historyUpdates:
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
copy := snapshot
|
||
|
|
glib.IdleAdd(func() { a.renderHistory(copy) })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) forwardLog() {
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-a.uiDone:
|
||
|
|
return
|
||
|
|
case _, ok := <-a.logUpdates:
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
glib.IdleAdd(a.renderConsole)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) toggleCaptions() {
|
||
|
|
status := a.controller.Status()
|
||
|
|
switch status.State {
|
||
|
|
case app.StateStopped, app.StateError:
|
||
|
|
a.startCaptions()
|
||
|
|
default:
|
||
|
|
a.stopCaptions()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) startCaptions() {
|
||
|
|
if err := a.controller.Start(a.applied.Settings); err != nil {
|
||
|
|
a.log.Add(diagnostics.Error, "lifecycle", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
a.log.Add(diagnostics.Info, "lifecycle", "Starting caption processing")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) stopCaptions() {
|
||
|
|
go func() {
|
||
|
|
timeout := a.applied.Settings.ModelShutdownTimeout + time.Second
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
|
|
defer cancel()
|
||
|
|
if err := a.controller.Stop(ctx); err != nil {
|
||
|
|
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Stop captions: %v", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
a.log.Add(diagnostics.Info, "lifecycle", "Caption processing stopped")
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) restartCaptions() {
|
||
|
|
go func() {
|
||
|
|
timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
|
|
defer cancel()
|
||
|
|
if err := a.controller.Restart(ctx, a.applied.Settings); err != nil {
|
||
|
|
a.log.Add(diagnostics.Error, "lifecycle", fmt.Sprintf("Restart captions: %v", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
a.log.Add(diagnostics.Info, "lifecycle", "Caption processing restarted")
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) configureOutputs(next config.AppConfig, initial bool) error {
|
||
|
|
var outputErr error
|
||
|
|
a.history.SetEnabled(next.Outputs.History)
|
||
|
|
if next.Outputs.StdoutCaptions {
|
||
|
|
outputErr = joinError(outputErr, a.router.Set("stdout", terminal.New(a.stdout)))
|
||
|
|
} else {
|
||
|
|
outputErr = joinError(outputErr, a.router.Remove("stdout"))
|
||
|
|
}
|
||
|
|
|
||
|
|
if next.TranscriptLog.Enabled {
|
||
|
|
if initial || a.transcript == nil || next.TranscriptLog != a.applied.TranscriptLog {
|
||
|
|
sink, err := transcript.New(next.TranscriptLog.Directory)
|
||
|
|
if err != nil {
|
||
|
|
outputErr = joinError(outputErr, err)
|
||
|
|
} else {
|
||
|
|
a.transcript = sink
|
||
|
|
outputErr = joinError(outputErr, a.router.Set("transcript", sink))
|
||
|
|
a.log.Add(diagnostics.Info, "transcript", "Saving captions to "+sink.Path())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
a.transcript = nil
|
||
|
|
outputErr = joinError(outputErr, a.router.Remove("transcript"))
|
||
|
|
}
|
||
|
|
|
||
|
|
if next.Outputs.Overlay {
|
||
|
|
settings := next.Overlay
|
||
|
|
if !initial && settings.Backend != a.applied.Overlay.Backend {
|
||
|
|
settings.Backend = a.applied.Overlay.Backend
|
||
|
|
}
|
||
|
|
if initial || a.overlay == nil || settings != a.applied.Overlay {
|
||
|
|
sink, warning, err := overlay.New(settings)
|
||
|
|
if warning != "" {
|
||
|
|
a.log.Add(diagnostics.Warning, "overlay", warning)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
outputErr = joinError(outputErr, err)
|
||
|
|
} else {
|
||
|
|
a.overlay = sink
|
||
|
|
outputErr = joinError(outputErr, a.router.Set("overlay", sink))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
a.overlay = nil
|
||
|
|
outputErr = joinError(outputErr, a.router.Remove("overlay"))
|
||
|
|
}
|
||
|
|
|
||
|
|
if next.Outputs.StderrDiagnostics {
|
||
|
|
a.log.SetMirror(a.stderr)
|
||
|
|
} else {
|
||
|
|
a.log.SetMirror(io.Discard)
|
||
|
|
}
|
||
|
|
return outputErr
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) renderStatus(status app.Status) {
|
||
|
|
a.updateApplyButton()
|
||
|
|
state := strings.ToUpper(string(status.State))
|
||
|
|
overloaded := !status.OverloadedAt.IsZero() && time.Since(status.OverloadedAt) < 5*time.Second
|
||
|
|
if overloaded {
|
||
|
|
state = "OVERLOADED"
|
||
|
|
}
|
||
|
|
mode := strings.ToUpper(string(status.Mode))
|
||
|
|
if mode == "" {
|
||
|
|
mode = strings.ToUpper(string(a.applied.Settings.Mode))
|
||
|
|
}
|
||
|
|
a.statusLabel.SetText(state + " · " + mode)
|
||
|
|
a.statusDot.RemoveCSSClass("status-stopped")
|
||
|
|
a.statusDot.RemoveCSSClass("status-running")
|
||
|
|
a.statusDot.RemoveCSSClass("status-warning")
|
||
|
|
a.statusDot.RemoveCSSClass("status-error")
|
||
|
|
active := status.State != app.StateStopped && status.State != app.StateError
|
||
|
|
switch {
|
||
|
|
case status.State == app.StateError:
|
||
|
|
a.statusDot.AddCSSClass("status-error")
|
||
|
|
case overloaded:
|
||
|
|
a.statusDot.AddCSSClass("status-warning")
|
||
|
|
case active:
|
||
|
|
a.statusDot.AddCSSClass("status-running")
|
||
|
|
default:
|
||
|
|
a.statusDot.AddCSSClass("status-stopped")
|
||
|
|
}
|
||
|
|
if active {
|
||
|
|
a.startStopButton.SetLabel("STOP")
|
||
|
|
a.startStopButton.RemoveCSSClass("primary-action")
|
||
|
|
a.startStopButton.AddCSSClass("danger-action")
|
||
|
|
} else {
|
||
|
|
a.startStopButton.SetLabel("START")
|
||
|
|
a.startStopButton.RemoveCSSClass("danger-action")
|
||
|
|
a.startStopButton.AddCSSClass("primary-action")
|
||
|
|
}
|
||
|
|
speech := "quiet"
|
||
|
|
if status.SpeechActive {
|
||
|
|
speech = "detected"
|
||
|
|
}
|
||
|
|
source := status.Source
|
||
|
|
if source == "" {
|
||
|
|
source = "not connected"
|
||
|
|
}
|
||
|
|
overload := ""
|
||
|
|
if overloaded {
|
||
|
|
overload = " · OVERLOADED"
|
||
|
|
}
|
||
|
|
a.statusbarLabel.SetText(fmt.Sprintf(
|
||
|
|
"Source: %s · Speech: %s · Outputs: %s%s",
|
||
|
|
source,
|
||
|
|
speech,
|
||
|
|
a.outputSummary(),
|
||
|
|
overload,
|
||
|
|
))
|
||
|
|
if status.LastError != "" && status.State == app.StateError && status.LastError != a.lastShownError {
|
||
|
|
a.log.Add(diagnostics.Error, "engine", status.LastError)
|
||
|
|
a.lastShownError = status.LastError
|
||
|
|
} else if status.LastError == "" {
|
||
|
|
a.lastShownError = ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) outputSummary() string {
|
||
|
|
var active []string
|
||
|
|
if a.config.Outputs.Overlay {
|
||
|
|
active = append(active, "overlay")
|
||
|
|
}
|
||
|
|
if a.config.Outputs.History {
|
||
|
|
active = append(active, "history")
|
||
|
|
}
|
||
|
|
if a.config.Outputs.StdoutCaptions {
|
||
|
|
active = append(active, "stdout")
|
||
|
|
}
|
||
|
|
if a.config.TranscriptLog.Enabled {
|
||
|
|
active = append(active, "log file")
|
||
|
|
}
|
||
|
|
if len(active) == 0 {
|
||
|
|
return "none"
|
||
|
|
}
|
||
|
|
return strings.Join(active, ", ")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) copyDiagnostics() {
|
||
|
|
var text strings.Builder
|
||
|
|
status := a.controller.Status()
|
||
|
|
fmt.Fprintf(&text, "Captioneer diagnostics\nState: %s\nMode: %s\nSource: %s\nConfig: %s\n\n", status.State, status.Mode, status.Source, a.launch.Path)
|
||
|
|
for _, entry := range a.log.Entries() {
|
||
|
|
fmt.Fprintf(&text, "%s %-7s [%s] %s\n", entry.At.Format(time.RFC3339), strings.ToUpper(string(entry.Severity)), entry.Category, entry.Message)
|
||
|
|
}
|
||
|
|
gdk.DisplayGetDefault().Clipboard().SetText(text.String())
|
||
|
|
a.log.Add(diagnostics.Info, "gui", "Diagnostics copied to clipboard")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) openConfigFolder() {
|
||
|
|
if err := exec.Command("xdg-open", filepath.Dir(a.launch.Path)).Start(); err != nil {
|
||
|
|
a.log.Add(diagnostics.Error, "gui", fmt.Sprintf("Open configuration folder: %v", err))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) showAbout() {
|
||
|
|
dialog := gtk.NewAboutDialog()
|
||
|
|
dialog.SetTransientFor(&a.window.Window)
|
||
|
|
dialog.SetModal(true)
|
||
|
|
dialog.SetProgramName("Captioneer")
|
||
|
|
dialog.SetComments("Local live captions for Linux")
|
||
|
|
dialog.SetLicenseType(gtk.LicenseApache20)
|
||
|
|
dialog.SetWebsite("https://tea.chunkbyte.com/kato/captioneer")
|
||
|
|
dialog.Present()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Application) shutdown() {
|
||
|
|
a.closingMu.Lock()
|
||
|
|
if a.closing {
|
||
|
|
a.closingMu.Unlock()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
a.closing = true
|
||
|
|
a.closingMu.Unlock()
|
||
|
|
a.log.Add(diagnostics.Info, "lifecycle", "Shutting down Captioneer")
|
||
|
|
go func() {
|
||
|
|
timeout := a.applied.Settings.ModelShutdownTimeout + 2*time.Second
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
|
|
_ = a.controller.Stop(ctx)
|
||
|
|
cancel()
|
||
|
|
glib.IdleAdd(func() {
|
||
|
|
close(a.uiDone)
|
||
|
|
if a.stopStatus != nil {
|
||
|
|
a.stopStatus()
|
||
|
|
a.stopHistory()
|
||
|
|
a.stopLog()
|
||
|
|
}
|
||
|
|
_ = a.router.Close()
|
||
|
|
a.gtkApp.Quit()
|
||
|
|
})
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
|
||
|
|
func joinError(current, next error) error {
|
||
|
|
if current == nil {
|
||
|
|
return next
|
||
|
|
}
|
||
|
|
if next == nil {
|
||
|
|
return current
|
||
|
|
}
|
||
|
|
return fmt.Errorf("%v; %w", current, next)
|
||
|
|
}
|
||
|
|
|
||
|
|
func findIconPath() string {
|
||
|
|
candidates := []string{filepath.Join("assets", "icon", "icon.png")}
|
||
|
|
if executable, err := os.Executable(); err == nil {
|
||
|
|
candidates = append(candidates, filepath.Join(filepath.Dir(executable), "assets", "icon", "icon.png"))
|
||
|
|
}
|
||
|
|
for _, candidate := range candidates {
|
||
|
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||
|
|
return candidate
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|