1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
//! Now-playing chip poller — drains the background `now_playing`
//! source (mixr quick.txt + macOS Music/Spotify via osascript) and
//! surfaces the result for the statusline `♪` chip.
//!
//! Extracted from `app/mod.rs` in the file-split refactor
//!. Pure non-destructive move.
use super::*;
impl App {
/// Per-event-loop housekeeping (cheap).
/// Start the background now-playing poller — call once, from the
/// real terminal loop only (`tui.rs`). Headless / e2e deliberately
/// skip it so no `osascript` subprocess spawns in tests; the
/// miniplayer chip just renders its idle form there.
pub fn start_now_playing_poller(&mut self) {
if self.now_playing_rx.is_none() {
let source = match self.config.ui.now_playing_source.as_str() {
"mixr" => crate::now_playing::Source::Mixr,
"macos" => crate::now_playing::Source::Macos,
_ => crate::now_playing::Source::Auto,
};
self.now_playing_rx = Some(crate::now_playing::spawn_poller(source));
}
}
/// Drain the now-playing poller channel into `now_playing` — the
/// latest snapshot wins, with a small stickiness layer that
/// keeps the previous mixr track on screen across the brief
/// "between songs" / `playing_active` flag dips mixr writes
/// every few cycles. Without this, the statusline `♪` chip
/// flickers back to the idle `♪ mixr` form for a poll cycle or
/// two even while the deck is producing audio (user-reported
/// 2026-06-17). After ~10 s of confirmed empty mixr reads, the
/// sticky cache lapses — that's a true queue-empty / deck-cleared
/// state and the chip should reflect it.
pub(super) fn drain_now_playing(&mut self) {
if self.now_playing_rx.is_none() {
return;
}
// Take + replace pattern so the immutable borrow on the
// receiver doesn't conflict with `&mut self` in
// `merge_now_playing`.
let mut drained: Vec<Option<crate::now_playing::NowPlaying>> = Vec::new();
if let Some(rx) = &self.now_playing_rx {
while let Ok(np) = rx.try_recv() {
drained.push(np);
}
}
for np in drained {
let merged = self.merge_now_playing(np);
self.now_playing = merged;
}
}
/// Stickiness rule for `drain_now_playing` — pulled out so the
/// merge logic is unit-testable without spinning up the channel.
fn merge_now_playing(
&mut self,
new: Option<crate::now_playing::NowPlaying>,
) -> Option<crate::now_playing::NowPlaying> {
let now = std::time::Instant::now();
const STICKY_TTL: std::time::Duration = std::time::Duration::from_secs(10);
let (new_is_mixr, new_track_empty) = new
.as_ref()
.map(|n| (n.source.eq_ignore_ascii_case("mixr"), n.track.is_empty()))
.unwrap_or((false, true));
if new_is_mixr && !new_track_empty {
// Real track read — refresh the sticky timestamp.
self.last_mixr_track_at = Some(now);
return new;
}
// Empty mixr read (or no mixr at all): if we recently had a
// mixr track and TTL hasn't lapsed, paper over the gap by
// keeping the previous snapshot's track. Don't extend the
// timestamp — only a fresh non-empty read does that, so a
// genuine queue-empty state still lapses after STICKY_TTL.
if new_is_mixr
&& let Some(ts) = self.last_mixr_track_at
&& now.duration_since(ts) <= STICKY_TTL
&& let Some(old) = self.now_playing.as_ref()
&& !old.track.is_empty()
{
let mut merged = new.clone().unwrap_or_else(|| old.clone());
merged.track = old.track.clone();
if merged.detail.is_empty() {
merged.detail = old.detail.clone();
}
return Some(merged);
}
new
}
/// #1151 (2026-08-23) — marquee/autoscroll for the now-
/// playing track label. When `[ui] now_playing_marquee`
/// is on and the current label overflows the 28-char
/// display window, advance the offset by 1 char every
/// 300ms (~3.3 chars/sec — comfortable read speed). The
/// statusline chip does the actual slice; this method
/// just paces the offset. No-op when the knob is off,
/// nothing is playing, or the label fits.
pub(super) fn tick_now_playing_marquee(&mut self) {
if !self.config.ui.now_playing_marquee {
self.now_playing_marquee_offset = 0;
return;
}
let Some(np) = self.now_playing.as_ref() else {
self.now_playing_marquee_offset = 0;
return;
};
if np.track.is_empty() {
self.now_playing_marquee_offset = 0;
return;
}
// Cheap change-detection first: source + track + detail
// is a superset of what composes `clean`, so a hash-equal
// triple can't yield a different composed label. Only
// rebuild the composed string when one of the inputs
// actually differs — reviewer catch on 894bd231 (avoids
// per-tick alloc on the hot path when the track hasn't
// changed).
let np_key = format!("{}\x1f{}\x1f{}", np.source, np.detail, np.track);
if np_key != self.now_playing_marquee_prev_text {
self.now_playing_marquee_prev_text = np_key;
self.now_playing_marquee_offset = 0;
self.now_playing_marquee_last_tick = None;
return;
}
// Same track as last tick — pace the offset off the
// 300ms wall-clock, no string work.
let now = std::time::Instant::now();
let due = match self.now_playing_marquee_last_tick {
Some(t) => now.duration_since(t) >= std::time::Duration::from_millis(300),
None => true,
};
if !due {
return;
}
// Due — now we DO need `clean` for the loop-len math.
// Rebuild + skip when the label fits in the 28-char
// window (nothing to scroll).
let mixr_is_source = np.source.eq_ignore_ascii_case("mixr");
let raw = if mixr_is_source || np.detail.is_empty() {
np.track.clone()
} else {
format!("{} - {}", np.detail, np.track)
};
let clean = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if clean.chars().count() <= 28 {
self.now_playing_marquee_offset = 0;
return;
}
// Loop length = text_chars + 3-char gap so the wrap
// shows a visual break instead of the tail-of-line
// colliding with head-of-line.
let loop_len = clean.chars().count() + 3;
self.now_playing_marquee_offset = (self.now_playing_marquee_offset + 1) % loop_len;
self.now_playing_marquee_last_tick = Some(now);
}
}