diff --git a/device/device.go b/device/device.go index c81dfa4..bb16815 100644 --- a/device/device.go +++ b/device/device.go @@ -699,7 +699,27 @@ func (d *VirtualDevice) listenStatus(ctx context.Context) { if d.mixerStatuses == nil { d.mixerStatuses = make(map[uint8]*proto.MixerStatus) } - _, alreadyLogged := d.mixerStatuses[mx.DeviceNumber] + prev, alreadyLogged := d.mixerStatuses[mx.DeviceNumber] + // A stripped 0x30 (newer DJMs) carries only presence — + // no channel or master state. Merge it over what the + // 0x03 channel listener has learned instead of + // clobbering, or the mixer views flip between "channel + // state" and "detected" at packet cadence as the two + // writers fight. A rich 0x29 carries everything and + // may replace the entry wholesale. + if prev != nil && !mx.ChannelStateKnown && prev.ChannelStateKnown { + mx.ChannelOnAir = prev.ChannelOnAir + mx.ChannelStateKnown = true + if mx.MasterBPM == 0 { + mx.MasterBPM = prev.MasterBPM + } + if mx.MasterDevice == 0 { + mx.MasterDevice = prev.MasterDevice + } + if mx.BeatInBar == 0 { + mx.BeatInBar = prev.BeatInBar + } + } d.mixerStatuses[mx.DeviceNumber] = mx d.mixerStatusesMu.Unlock() if !alreadyLogged { diff --git a/device/logring.go b/device/logring.go new file mode 100644 index 0000000..de28d37 --- /dev/null +++ b/device/logring.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package device + +import ( + "bytes" + "sync" +) + +// LogRing is an io.Writer holding the most recent log lines in a fixed-size +// ring, so the TUI's Logs tab can show live output while the full stream +// still goes to the log file (tee both via io.MultiWriter). Safe for +// concurrent writers — the stdlib log package serializes its own writes, but +// nothing stops other code from writing to the same tee. +type LogRing struct { + mu sync.Mutex + lines []string + head int // next slot to overwrite + full bool + partial bytes.Buffer // trailing bytes of an unterminated line +} + +// NewLogRing returns a ring holding up to capacity lines. +func NewLogRing(capacity int) *LogRing { + if capacity < 1 { + capacity = 1 + } + return &LogRing{lines: make([]string, capacity)} +} + +// Write splits p into lines and appends each to the ring. A write that does +// not end in a newline leaves its tail buffered until the newline arrives, +// so interleaved fmt-style writes still land as whole lines. Always returns +// len(p), nil — a log tee must never fail the other writer. +func (r *LogRing) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + n := len(p) + for len(p) > 0 { + i := bytes.IndexByte(p, '\n') + if i < 0 { + r.partial.Write(p) + break + } + r.partial.Write(p[:i]) + r.push(r.partial.String()) + r.partial.Reset() + p = p[i+1:] + } + return n, nil +} + +func (r *LogRing) push(line string) { + r.lines[r.head] = line + r.head++ + if r.head == len(r.lines) { + r.head = 0 + r.full = true + } +} + +// Lines returns the buffered lines, oldest first. +func (r *LogRing) Lines() []string { + r.mu.Lock() + defer r.mu.Unlock() + if !r.full { + out := make([]string, r.head) + copy(out, r.lines[:r.head]) + return out + } + out := make([]string, 0, len(r.lines)) + out = append(out, r.lines[r.head:]...) + out = append(out, r.lines[:r.head]...) + return out +} diff --git a/device/logring_test.go b/device/logring_test.go new file mode 100644 index 0000000..ea45b61 --- /dev/null +++ b/device/logring_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package device + +import ( + "fmt" + "log" + "reflect" + "testing" +) + +func TestLogRingBasic(t *testing.T) { + r := NewLogRing(3) + if got := r.Lines(); len(got) != 0 { + t.Fatalf("empty ring returned %v", got) + } + r.Write([]byte("a\nb\n")) + if got := r.Lines(); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("got %v", got) + } + // Overflow: oldest lines drop, order stays oldest-first. + r.Write([]byte("c\nd\n")) + if got := r.Lines(); !reflect.DeepEqual(got, []string{"b", "c", "d"}) { + t.Fatalf("after wrap got %v", got) + } +} + +func TestLogRingPartialWrites(t *testing.T) { + r := NewLogRing(8) + // A line split across writes must land as one line once terminated. + r.Write([]byte("hel")) + r.Write([]byte("lo wor")) + if got := r.Lines(); len(got) != 0 { + t.Fatalf("unterminated line surfaced early: %v", got) + } + r.Write([]byte("ld\nnext\n")) + if got := r.Lines(); !reflect.DeepEqual(got, []string{"hello world", "next"}) { + t.Fatalf("got %v", got) + } +} + +func TestLogRingAsStdlibLogSink(t *testing.T) { + r := NewLogRing(16) + l := log.New(r, "", log.LstdFlags) + for i := 0; i < 3; i++ { + l.Printf("line %d", i) + } + got := r.Lines() + if len(got) != 3 { + t.Fatalf("want 3 lines, got %v", got) + } + for i, line := range got { + want := fmt.Sprintf("line %d", i) + if len(line) < len(want) || line[len(line)-len(want):] != want { + t.Errorf("line %d = %q, want suffix %q", i, line, want) + } + } +} diff --git a/device/peers.go b/device/peers.go index a30a829..3214aa2 100644 --- a/device/peers.go +++ b/device/peers.go @@ -12,7 +12,13 @@ import ( "github.com/vynulldev/vynull/proto" ) -const peerTimeout = 5 * time.Second +// peerTimeout is how long a peer stays "active" after its last keep-alive. +// Devices broadcast keep-alives every 2s (measured on a DJM-A9; CDJs are +// similar), so the old 5s window meant two consecutive lost broadcasts — +// easy on a busy link-local segment — made the device vanish from the +// players/mixer views until the next packet. 10s tolerates four lost +// packets while still clearing genuinely departed devices quickly. +const peerTimeout = 10 * time.Second // Peer represents a device seen on the Pro DJ Link network. type Peer struct { diff --git a/device/tui.go b/device/tui.go index 1e80e05..0216c4a 100644 --- a/device/tui.go +++ b/device/tui.go @@ -55,17 +55,19 @@ const ( tabPlayers tuiTab = iota tabLibrary tabSettings + tabLogs ) -var tabNames = []string{"Players", "Library", "Settings"} +var tabNames = []string{"Players", "Library", "Settings", "Logs"} type tuiModel struct { monitor *PlayerMonitor lib *library.Library settings *CDJSettings - peers *PeerTracker + peersFn func() *PeerTracker // resolved at render time — see NewTUI mixersFn func() map[uint8]proto.MixerStatus apiAddr string + logs *LogRing // nil → Logs tab shows a hint instead active tuiTab width int @@ -79,6 +81,12 @@ type tuiModel struct { // Settings tab state settingsCursor int settingsOffset int + + // Logs tab state. logFollow pins the view to the tail (the default); + // scrolling up detaches it, G/end re-attach. logOffset is the index of + // the first visible line while detached. + logOffset int + logFollow bool } // NewTUI returns a bubbletea program rendering the player monitor + @@ -86,14 +94,24 @@ type tuiModel struct { // the user presses 'q' or sends SIGINT/SIGTERM. // mixersFn (optional) returns the latest per-device mixer status snapshot, // used to fill in channel/master state on the PLAYERS-view mixer strip. -func NewTUI(monitor *PlayerMonitor, lib *library.Library, settings *CDJSettings, peers *PeerTracker, mixersFn func() map[uint8]proto.MixerStatus, apiAddr string) *tea.Program { +// logs (optional) is the in-memory tail of the log stream for the Logs tab; +// pass the LogRing that tees the log file writer. +// +// peersFn returns the live PeerTracker and is called at render time, NOT +// captured once here: the tracker is created inside VirtualDevice.Start, +// which races TUI construction at startup. Passing dev.Peers by value made +// the mixer strip a per-session coin flip — whichever goroutine won, the TUI +// either had the tracker or held nil for the whole run. +func NewTUI(monitor *PlayerMonitor, lib *library.Library, settings *CDJSettings, peersFn func() *PeerTracker, mixersFn func() map[uint8]proto.MixerStatus, apiAddr string, logs *LogRing) *tea.Program { m := tuiModel{ - monitor: monitor, - lib: lib, - settings: settings, - peers: peers, - mixersFn: mixersFn, - apiAddr: apiAddr, + monitor: monitor, + lib: lib, + settings: settings, + peersFn: peersFn, + mixersFn: mixersFn, + apiAddr: apiAddr, + logs: logs, + logFollow: true, } return tea.NewProgram(m, tea.WithAltScreen()) } @@ -168,6 +186,33 @@ func (m *tuiModel) moveCursor(delta int) { if m.settingsCursor < 0 { m.settingsCursor = 0 } + if m.settingsCursor > settingsRowCount-1 { + m.settingsCursor = settingsRowCount - 1 + } + case tabLogs: + if m.logs == nil { + return + } + total := len(m.logs.Lines()) + rows := m.logViewRows() + maxOffset := total - rows + if maxOffset < 0 { + maxOffset = 0 + } + if m.logFollow { + // Detach from the tail at the current position. + m.logOffset = maxOffset + } + m.logOffset += delta + if m.logOffset < 0 { + m.logOffset = 0 + } + if m.logOffset >= maxOffset { + m.logOffset = maxOffset + m.logFollow = true // scrolled back to the tail → re-follow + } else { + m.logFollow = false + } } } @@ -183,10 +228,20 @@ func (m *tuiModel) setCursor(pos int) { } m.libCursor = pos case tabSettings: + if pos > settingsRowCount-1 { + pos = settingsRowCount - 1 + } if pos < 0 { pos = 0 } m.settingsCursor = pos + case tabLogs: + if pos == 0 { // g → jump to the oldest buffered line + m.logOffset = 0 + m.logFollow = false + } else { // G → back to following the tail + m.logFollow = true + } } } @@ -213,6 +268,8 @@ func (m tuiModel) View() string { top.WriteString(m.renderLibrary()) case tabSettings: top.WriteString(m.renderSettings()) + case tabLogs: + top.WriteString(m.renderLogs()) } top.WriteString("\n") top.WriteString(divider) @@ -288,6 +345,8 @@ func (m tuiModel) renderHints() string { switch m.active { case tabLibrary: return hintStyle.Render(common + " • g/G: top/bottom") + case tabLogs: + return hintStyle.Render(common + " • ↑↓/pgup/pgdn: scroll • g: oldest • G: follow") default: return hintStyle.Render(common) } @@ -303,12 +362,12 @@ func (m tuiModel) renderPlayers() string { // When we've parsed a status broadcast for it (0x29 rich, or the // 0x03 channel packet), show master tempo and which channels are // on-air; otherwise fall back to a "detected" hint. - if m.peers != nil { + if peers := m.resolvePeers(); peers != nil { var mixers map[uint8]proto.MixerStatus if m.mixersFn != nil { mixers = m.mixersFn() } - for _, p := range m.peers.Peers() { + for _, p := range peers.Peers() { if p.DeviceType != proto.DeviceMixer { continue } @@ -479,6 +538,10 @@ func (m tuiModel) renderLibrary() string { // ---------- Settings tab ---------- +// settingsRowCount mirrors the row list in renderSettings so cursor +// clamping can know the bound without building the rows. +const settingsRowCount = 25 + func (m tuiModel) renderSettings() string { if m.settings == nil { return dimStyle.Render("\n Settings not available.") @@ -513,22 +576,136 @@ func (m tuiModel) renderSettings() string { {"wave_position", cfg.DevSetting.WavePosition}, } + // Scroll to the viewport like the Library tab: the full list is taller + // than a typical terminal, and rendering every row pushed the tab bar + // off the top of the screen. The cursor row stays in view; section + // headers scroll with their rows. + view := m.viewportRows() - 3 // hint line + 2 padding + if view < 1 { + view = 1 + } + cursor := m.settingsCursor + if cursor >= len(rows) { + cursor = len(rows) - 1 + } + offset := m.settingsOffset + if cursor < offset { + offset = cursor + } + if cursor >= offset+view { + offset = cursor - view + 1 + } + if offset > len(rows)-view { + offset = len(rows) - view + } + if offset < 0 { + offset = 0 + } + end := offset + view + if end > len(rows) { + end = len(rows) + } + var b strings.Builder - b.WriteString("\n " + dimStyle.Render("(read-only view — edit settings.json to change)") + "\n\n") - for i, r := range rows { + b.WriteByte('\n') + b.WriteString(" ") + b.WriteString(dimStyle.Render("(read-only view — edit settings.json to change)")) + if len(rows) > view { + b.WriteString(dimStyle.Render(fmt.Sprintf(" · %d-%d of %d", offset+1, end, len(rows)))) + } + b.WriteByte('\n') + for i := offset; i < end; i++ { + r := rows[i] + var line string if r.value == "" { - b.WriteString(" " + titleStyle.Render(r.label) + "\n") - continue + line = " " + titleStyle.Render(r.label) + } else { + line = fmt.Sprintf(" %-26s %s", r.label, r.value) + } + if i == cursor { + // Rebuild unstyled so the cursor background spans the row. + plain := fmt.Sprintf(" %-26s %s", r.label, r.value) + line = cursorStyle.Render(plain) } - line := fmt.Sprintf(" %-26s %s", r.label, r.value) - _ = i - b.WriteString(line + "\n") + b.WriteString(line) + b.WriteByte('\n') } - return b.String() + return strings.TrimRight(b.String(), "\n") +} + +// ---------- Logs tab ---------- + +// logViewRows is the line budget for the Logs tab: the viewport minus the +// tab's own header line. +func (m tuiModel) logViewRows() int { + rows := m.viewportRows() - 2 + if rows < 1 { + rows = 1 + } + return rows +} + +func (m tuiModel) renderLogs() string { + if m.logs == nil { + return dimStyle.Render("\n Log buffer not available (--log-file only captures to disk).") + } + lines := m.logs.Lines() + rows := m.logViewRows() + offset := m.logOffset + if m.logFollow || offset > len(lines)-rows { + offset = len(lines) - rows + } + if offset < 0 { + offset = 0 + } + end := offset + rows + if end > len(lines) { + end = len(lines) + } + + var b strings.Builder + b.WriteByte('\n') + b.WriteString(" ") + mode := accentStyle.Render("● FOLLOWING") + if !m.logFollow { + mode = dimStyle.Render("⏸ PAUSED (G to follow)") + } + b.WriteString(titleStyle.Render(fmt.Sprintf("%d lines buffered", len(lines)))) + b.WriteString(" ") + b.WriteString(mode) + b.WriteByte('\n') + if len(lines) == 0 { + b.WriteString(dimStyle.Render(" (no log output yet)")) + return b.String() + } + // Trim the stdlib log timestamp to the clock (drop the date — the date + // column is dead width on a live view) and clip to the terminal. + width := m.width - 4 + if width < 10 { + width = 10 + } + for _, line := range lines[offset:end] { + if len(line) > 20 && line[4] == '/' && line[7] == '/' && line[10] == ' ' { + line = line[11:] // "2026/07/23 12:04:05 msg" → "12:04:05 msg" + } + b.WriteString(" ") + b.WriteString(truncate(line, width)) + b.WriteByte('\n') + } + return strings.TrimRight(b.String(), "\n") } // ---------- helpers ---------- +// resolvePeers fetches the live PeerTracker (nil until the device loop has +// created it). +func (m tuiModel) resolvePeers() *PeerTracker { + if m.peersFn == nil { + return nil + } + return m.peersFn() +} + func (m tuiModel) viewportRows() int { // reserve lines for header (2), divider (2), status (1), hints (2) return m.height - 7 diff --git a/main.go b/main.go index 1acff38..bc3f33e 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ package main import ( "context" "fmt" + "io" "log" "net" "net/http" @@ -106,6 +107,9 @@ func main() { // --log-file PATH → append to that file (TUI or headless) // TUI (default) → an auto temp file, since the TUI owns the terminal // headless → stdout (no redirect) + // In TUI mode the last few thousand log lines are also kept in memory + // for the Logs tab, teed alongside the file writer. + var logRing *device.LogRing if cfg.GenerateDir == "" { var logFile *os.File var err error @@ -118,7 +122,12 @@ func main() { if err != nil { fmt.Fprintf(os.Stderr, "warning: could not open log file: %v (logging to stdout)\n", err) } else if logFile != nil { - log.SetOutput(logFile) + if cfg.TUI { + logRing = device.NewLogRing(2000) + log.SetOutput(io.MultiWriter(logFile, logRing)) + } else { + log.SetOutput(logFile) + } defer logFile.Close() fmt.Printf("Logging to %s\n", logFile.Name()) // Mirror runtime crash output (panics from any goroutine @@ -643,7 +652,9 @@ func main() { // Launch the TUI on the main goroutine. It owns the terminal until // the user presses 'q' or ctx is cancelled from elsewhere (SIGINT, // service error). Bridge ctx → program.Quit so SIGINT unwinds cleanly. - tuiProgram := device.NewTUI(monitor, lib, cdjSettings, dev.Peers, dev.MixerSnapshot, displayAddr(cfg.Listen)) + tuiProgram := device.NewTUI(monitor, lib, cdjSettings, + func() *device.PeerTracker { return dev.Peers }, // created inside dev.Start — resolve at render time + dev.MixerSnapshot, displayAddr(cfg.Listen), logRing) go func() { <-ctx.Done() tuiProgram.Quit()