crates
fin
fin-config
fin-media
fin-player
fin-subsonic
fin-tui
···
60
60
/// 16000 Hz); Q is 7.0 across the board; every gain is 0 dB, so the DSP
61
61
/// output is bit-identical to bypass until the user starts tweaking.
62
62
pub fn default_eq_band_settings() -> Vec<EqBand> {
63
63
-
const CUTOFFS_HZ: [i32; 10] = [
64
64
-
32, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000,
65
65
-
];
63
63
+
const CUTOFFS_HZ: [i32; 10] = [32, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
66
64
CUTOFFS_HZ
67
65
.iter()
68
66
.map(|&hz| EqBand {
···
13
13
use async_trait::async_trait;
14
14
15
15
pub use fin_config::ServerKind;
16
16
-
pub use fin_jellyfin::{AuthResult, BaseItem, ItemKind, StreamFormat};
17
16
pub use fin_jellyfin::JellyfinClient;
17
17
+
pub use fin_jellyfin::{AuthResult, BaseItem, ItemKind, StreamFormat};
18
18
pub use fin_subsonic::SubsonicClient;
19
19
20
20
/// Common browse / search / stream surface. Both backends model the same
···
265
265
}
266
266
// If the winner failed, run the other to completion so we can report
267
267
// its result too.
268
268
-
let (jf, ss) = tokio::join!(
269
269
-
probe_jellyfin(&http, base),
270
270
-
probe_subsonic(&http, base),
271
271
-
);
268
268
+
let (jf, ss) = tokio::join!(probe_jellyfin(&http, base), probe_subsonic(&http, base),);
272
269
if jf.is_ok() {
273
270
return Ok(ServerKind::Jellyfin);
274
271
}
···
323
320
324
321
/// One-shot auth wrapper: probe the server, log in with the right client,
325
322
/// and return everything wired up as `Arc<dyn MediaClient>`.
326
326
-
pub async fn login_any(
327
327
-
url: &str,
328
328
-
username: &str,
329
329
-
password: &str,
330
330
-
) -> Result<LoggedIn> {
323
323
+
pub async fn login_any(url: &str, username: &str, password: &str) -> Result<LoggedIn> {
331
324
let kind = probe_server(url).await?;
332
325
match kind {
333
326
ServerKind::Jellyfin => {
···
121
121
// Unique-per-call so parallel tests don't step on each other.
122
122
static COUNTER: AtomicU64 = AtomicU64::new(0);
123
123
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
124
124
-
std::env::temp_dir()
125
125
-
.join(format!("fin-persist-test-{name}-{}-{n}.json", std::process::id()))
124
124
+
std::env::temp_dir().join(format!(
125
125
+
"fin-persist-test-{name}-{}-{n}.json",
126
126
+
std::process::id()
127
127
+
))
126
128
}
127
129
128
130
// ------------------------------------------------------------------
···
1167
1167
// only the biquad delay lines differ. Skip both configs
1168
1168
// when no stage is doing anything to avoid the f32↔i16
1169
1169
// conversion hops.
1170
1170
-
let dsp_active =
1171
1171
-
eq_enabled || tone_bass_db != 0 || tone_treble_db != 0;
1170
1170
+
let dsp_active = eq_enabled || tone_bass_db != 0 || tone_treble_db != 0;
1172
1171
let dsp_arg: Option<&mut dyn DspProcess> = if dsp_active {
1173
1172
dsp.as_mut().map(|d| d as &mut dyn DspProcess)
1174
1173
} else {
···
1198
1197
if let Some(ref mut nt) = next {
1199
1198
let free = nt.ring_free_slots();
1200
1199
if free >= 8192 && !nt.ended {
1201
1201
-
let dsp_active =
1202
1202
-
eq_enabled || tone_bass_db != 0 || tone_treble_db != 0;
1200
1200
+
let dsp_active = eq_enabled || tone_bass_db != 0 || tone_treble_db != 0;
1203
1201
let dsp_arg: Option<&mut dyn DspProcess> = if dsp_active {
1204
1202
voice_dsp.as_mut().map(|d| d as &mut dyn DspProcess)
1205
1203
} else {
···
1296
1294
_ => false,
1297
1295
};
1298
1296
if should_promote {
1299
1299
-
debug!(advance = promote_advances_queue, "crossfade complete, promoting next → current");
1297
1297
+
debug!(
1298
1298
+
advance = promote_advances_queue,
1299
1299
+
"crossfade complete, promoting next → current"
1300
1300
+
);
1300
1301
stop_current(&mut track);
1301
1302
if let Some(mut nt) = next.take() {
1302
1303
nt.overlap_incoming = None;
···
1430
1431
// f32 → i16, saturating.
1431
1432
let mut input = Vec::with_capacity(samples.len());
1432
1433
for &s in samples {
1433
1433
-
let v = (s * 32767.0).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
1434
1434
+
let v = (s * 32767.0)
1435
1435
+
.round()
1436
1436
+
.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
1434
1437
input.push(v);
1435
1438
}
1436
1439
let mut out_i16: Vec<i16> = Vec::with_capacity(input.len());
···
1470
1473
}
1471
1474
dsp.eq_enable(enabled);
1472
1475
}
1473
1473
-
1474
1476
1475
1477
fn push_samples_with_volume(t: &Track, samples: &[f32]) {
1476
1478
// Effective scale = user volume × ReplayGain linear multiplier ×
···
154
154
Ok(album.song.into_iter().map(song_to_base_item).collect())
155
155
}
156
156
157
157
-
pub async fn search(
158
158
-
&self,
159
159
-
query: &str,
160
160
-
_kinds: &[&str],
161
161
-
limit: u32,
162
162
-
) -> Result<Vec<BaseItem>> {
157
157
+
pub async fn search(&self, query: &str, _kinds: &[&str], limit: u32) -> Result<Vec<BaseItem>> {
163
158
let per = limit.min(50);
164
159
let resp: SearchResp = self
165
160
.get_json(
···
204
199
let p = resp
205
200
.playlist()
206
201
.ok_or_else(|| anyhow!("playlist {} not found", id))?;
207
207
-
Ok(p.entry.unwrap_or_default().into_iter().map(song_to_base_item).collect())
202
202
+
Ok(p.entry
203
203
+
.unwrap_or_default()
204
204
+
.into_iter()
205
205
+
.map(song_to_base_item)
206
206
+
.collect())
208
207
}
209
208
210
209
/// Everything the user has starred — albums first, then songs, matching
···
228
227
/// Star (`star`) or unstar (`unstar`) a song / album / artist by id.
229
228
pub async fn set_star(&self, id: &str, star: bool) -> Result<()> {
230
229
let endpoint = if star { "star" } else { "unstar" };
231
231
-
let resp: PingResp = self
232
232
-
.get_json(endpoint, &[("id", id.to_string())])
233
233
-
.await?;
230
230
+
let resp: PingResp = self.get_json(endpoint, &[("id", id.to_string())]).await?;
234
231
resp.check()
235
232
}
236
233
···
342
339
.error_for_status()?
343
340
.text()
344
341
.await?;
345
345
-
serde_json::from_str(&text)
346
346
-
.with_context(|| format!("parsing {} response", endpoint))
342
342
+
serde_json::from_str(&text).with_context(|| format!("parsing {} response", endpoint))
347
343
}
348
344
}
349
345
···
404
400
return Ok(());
405
401
}
406
402
let (code, msg) = error
407
407
-
.map(|e| (e.code.unwrap_or(-1), e.message.as_deref().unwrap_or("unknown")))
403
403
+
.map(|e| {
404
404
+
(
405
405
+
e.code.unwrap_or(-1),
406
406
+
e.message.as_deref().unwrap_or("unknown"),
407
407
+
)
408
408
+
})
408
409
.unwrap_or((-1, "unknown"));
409
410
Err(anyhow!("subsonic error {}: {}", code, msg))
410
411
}
···
520
520
self.set_status("No EQ bands — add [[eq_band_settings]] to config.toml");
521
521
return;
522
522
}
523
523
-
let idx = self
524
524
-
.eq_selected_band
525
525
-
.min(cfg.eq_band_settings.len() - 1);
523
523
+
let idx = self.eq_selected_band.min(cfg.eq_band_settings.len() - 1);
526
524
let band = &mut cfg.eq_band_settings[idx];
527
525
band.gain = (band.gain + delta_tenths).clamp(-240, 240);
528
526
let hz = band.cutoff;
529
527
let g = band.gain as f32 / 10.0;
530
528
drop(cfg);
531
531
-
self.set_status(format!(
532
532
-
"band {}: {} Hz → {:+.1} dB",
533
533
-
idx + 1,
534
534
-
hz,
535
535
-
g
536
536
-
));
529
529
+
self.set_status(format!("band {}: {} Hz → {:+.1} dB", idx + 1, hz, g));
537
530
let cfg = self.config.lock();
538
531
let _ = cfg.save();
539
532
(cfg.eq_enabled, cfg.eq_band_settings.clone())
···
629
622
}
630
623
}
631
624
Err(e) => {
632
632
-
*status.lock() =
633
633
-
Some((format!("favorite failed: {}", e), Instant::now()))
625
625
+
*status.lock() = Some((format!("favorite failed: {}", e), Instant::now()))
634
626
}
635
627
}
636
628
});
···
1135
1127
BaseItem {
1136
1128
id: q.id.clone(),
1137
1129
name: q.title.clone(),
1138
1138
-
type_: if q.is_video { "Video".into() } else { "Audio".into() },
1130
1130
+
type_: if q.is_video {
1131
1131
+
"Video".into()
1132
1132
+
} else {
1133
1133
+
"Audio".into()
1134
1134
+
},
1139
1135
album: None,
1140
1136
album_id: None,
1141
1137
album_artist: None,
···
1147
1143
series_name: None,
1148
1144
production_year: None,
1149
1145
run_time_ticks: q.duration_secs.map(|s| (s * 10_000_000) as i64),
1150
1150
-
media_type: Some(if q.is_video { "Video".into() } else { "Audio".into() }),
1146
1146
+
media_type: Some(if q.is_video {
1147
1147
+
"Video".into()
1148
1148
+
} else {
1149
1149
+
"Audio".into()
1150
1150
+
}),
1151
1151
container: None,
1152
1152
index_number: None,
1153
1153
parent_index_number: None,
···
1521
1521
(KeyCode::Char('['), _) if app.screen == Screen::Settings => {
1522
1522
let n = app.config.lock().eq_band_settings.len();
1523
1523
if n > 0 {
1524
1524
-
app.eq_selected_band =
1525
1525
-
(app.eq_selected_band + n - 1) % n;
1524
1524
+
app.eq_selected_band = (app.eq_selected_band + n - 1) % n;
1526
1525
}
1527
1526
}
1528
1527
(KeyCode::Char(']'), _) if app.screen == Screen::Settings => {
···
1861
1860
if tracks.is_empty() {
1862
1861
f.render_widget(block, area);
1863
1862
f.render_widget(
1864
1864
-
Paragraph::new(Line::from(Span::styled(
1865
1865
-
" Loading tracks…",
1866
1866
-
muted_style(),
1867
1867
-
)))
1868
1868
-
.alignment(Alignment::Center),
1863
1863
+
Paragraph::new(Line::from(Span::styled(" Loading tracks…", muted_style())))
1864
1864
+
.alignment(Alignment::Center),
1869
1865
inner.inner(Margin::new(2, 1)),
1870
1866
);
1871
1867
return;
···
1906
1902
let disc = track.parent_index_number.unwrap_or(1);
1907
1903
if show_disc_headers && current_disc != Some(disc) {
1908
1904
header_visual_indices.push(items.len());
1909
1909
-
items.push(ListItem::new(Line::from(vec![
1910
1910
-
Span::styled(
1911
1911
-
format!(" ▤ Disc {}", disc),
1912
1912
-
Style::default()
1913
1913
-
.fg(Palette::ACCENT)
1914
1914
-
.add_modifier(Modifier::BOLD),
1915
1915
-
),
1916
1916
-
])));
1905
1905
+
items.push(ListItem::new(Line::from(vec![Span::styled(
1906
1906
+
format!(" ▤ Disc {}", disc),
1907
1907
+
Style::default()
1908
1908
+
.fg(Palette::ACCENT)
1909
1909
+
.add_modifier(Modifier::BOLD),
1910
1910
+
)])));
1917
1911
current_disc = Some(disc);
1918
1912
}
1919
1913
track_to_visual.push(items.len());
···
1988
1982
} else {
1989
1983
String::new()
1990
1984
};
1991
1991
-
let time = track
1992
1992
-
.duration_secs()
1993
1993
-
.map(fmt_dur_local)
1994
1994
-
.unwrap_or_default();
1985
1985
+
let time = track.duration_secs().map(fmt_dur_local).unwrap_or_default();
1995
1986
let time_pad = layout
1996
1987
.time_col
1997
1988
.saturating_sub(UnicodeWidthStr::width(time.as_str()));
···
2249
2240
]),
2250
2241
Line::from(vec![
2251
2242
Span::styled(" Tone ", title_style()),
2252
2252
-
Span::styled(
2253
2253
-
format!("bass {:+} dB", cfg_snapshot.bass),
2254
2254
-
accent_style(),
2255
2255
-
),
2243
2243
+
Span::styled(format!("bass {:+} dB", cfg_snapshot.bass), accent_style()),
2256
2244
Span::styled(" ", muted_style()),
2257
2245
Span::styled(
2258
2246
format!("treble {:+} dB", cfg_snapshot.treble),
2259
2247
accent_style(),
2260
2248
),
2261
2261
-
Span::styled(
2262
2262
-
" (b/B: bass, y/Y: treble; 1 dB steps)",
2263
2263
-
muted_style(),
2264
2264
-
),
2249
2249
+
Span::styled(" (b/B: bass, y/Y: treble; 1 dB steps)", muted_style()),
2265
2250
]),
2266
2251
Line::from(vec![
2267
2252
Span::styled(" Config File ", title_style()),
···
2284
2269
let sel_band = if cfg_snapshot.eq_band_settings.is_empty() {
2285
2270
None
2286
2271
} else {
2287
2287
-
Some(app.eq_selected_band.min(cfg_snapshot.eq_band_settings.len() - 1))
2272
2272
+
Some(
2273
2273
+
app.eq_selected_band
2274
2274
+
.min(cfg_snapshot.eq_band_settings.len() - 1),
2275
2275
+
)
2288
2276
};
2289
2277
let eq_title = if cfg_snapshot.eq_enabled {
2290
2278
format!(
···
2326
2314
b.gain as f32 / 10.0,
2327
2315
)
2328
2316
})
2329
2329
-
.unwrap_or_else(|| " no bands configured — add [[eq_band_settings]] to config.toml ".to_string());
2330
2330
-
let hint = format!(
2331
2331
-
"{}E: on/off [ / ]: band Shift+↑/↓: ±1 dB",
2332
2332
-
sel_hint
2333
2333
-
);
2317
2317
+
.unwrap_or_else(|| {
2318
2318
+
" no bands configured — add [[eq_band_settings]] to config.toml ".to_string()
2319
2319
+
});
2320
2320
+
let hint = format!("{}E: on/off [ / ]: band Shift+↑/↓: ±1 dB", sel_hint);
2334
2321
f.render_widget(
2335
2322
Paragraph::new(Span::styled(hint, muted_style())),
2336
2323
eq_rows[1],
···
2511
2498
track("d2-t2", Some(2), Some(2)),
2512
2499
];
2513
2500
sort_album_tracks(&mut v);
2514
2514
-
assert_eq!(
2515
2515
-
names(&v),
2516
2516
-
vec!["d1-t1", "d1-t2", "d2-t1", "d2-t2"]
2517
2517
-
);
2501
2501
+
assert_eq!(names(&v), vec!["d1-t1", "d1-t2", "d2-t1", "d2-t2"]);
2518
2502
}
2519
2503
2520
2504
#[test]
···
58
58
let ratio = clamped / self.range_db as f32; // −1..=+1
59
59
let half_h = bar_h / 2;
60
60
let offset = ((ratio * half_h as f32).round()) as i32;
61
61
-
let bar_end_row = (center_row as i32 - offset).clamp(bar_top as i32, bar_bot as i32) as u16;
61
61
+
let bar_end_row =
62
62
+
(center_row as i32 - offset).clamp(bar_top as i32, bar_bot as i32) as u16;
62
63
63
64
// Bar style — muted when EQ off, highlighted on the selected band.
64
65
let is_sel = self.selected == Some(i);
···
95
96
for r in from..=to {
96
97
if let Some(cell) = buf.cell_mut((bar_x, r)) {
97
98
cell.set_char('█');
98
98
-
cell.set_style(
99
99
-
Style::default()
100
100
-
.fg(bar_color)
101
101
-
.add_modifier(Modifier::BOLD),
102
102
-
);
99
99
+
cell.set_style(Style::default().fg(bar_color).add_modifier(Modifier::BOLD));
103
100
}
104
101
}
105
102
···
134
134
// Split into as many horizontal rows as the widget occupies, one
135
135
// Line per row. We rely on Paragraph's wrapping for width overflow.
136
136
let lines = build_lines(inner.width as usize);
137
137
-
let footer = Line::from(vec![Span::styled(
138
138
-
" ? or Esc to close",
139
139
-
muted_style(),
140
140
-
)]);
137
137
+
let footer = Line::from(vec![Span::styled(" ? or Esc to close", muted_style())]);
141
138
142
139
let rows = Layout::default()
143
140
.direction(Direction::Vertical)
···
176
173
if idx > 0 {
177
174
lines.push(Line::from(""));
178
175
}
179
179
-
lines.push(Line::from(vec![
180
180
-
Span::styled(
181
181
-
format!(" {}", section.title),
182
182
-
Style::default()
183
183
-
.fg(Palette::ACCENT)
184
184
-
.add_modifier(Modifier::BOLD),
185
185
-
),
186
186
-
]));
176
176
+
lines.push(Line::from(vec![Span::styled(
177
177
+
format!(" {}", section.title),
178
178
+
Style::default()
179
179
+
.fg(Palette::ACCENT)
180
180
+
.add_modifier(Modifier::BOLD),
181
181
+
)]));
187
182
for entry in section.entries {
188
188
-
let key_pad = key_col_max
189
189
-
.saturating_sub(unicode_width::UnicodeWidthStr::width(entry.key));
183
183
+
let key_pad =
184
184
+
key_col_max.saturating_sub(unicode_width::UnicodeWidthStr::width(entry.key));
190
185
lines.push(Line::from(vec![
191
186
Span::raw(" "),
192
187
Span::styled(
···
223
218
fn every_entry_has_non_empty_key_and_description() {
224
219
for s in HELP_SECTIONS {
225
220
for e in s.entries {
226
226
-
assert!(!e.key.trim().is_empty(), "empty key in section '{}'", s.title);
221
221
+
assert!(
222
222
+
!e.key.trim().is_empty(),
223
223
+
"empty key in section '{}'",
224
224
+
s.title
225
225
+
);
227
226
assert!(
228
227
!e.description.trim().is_empty(),
229
228
"empty description for '{}'",
···
165
165
};
166
166
let tone_span = if self.state.bass_db != 0 || self.state.treble_db != 0 {
167
167
Span::styled(
168
168
-
format!(
169
169
-
"B{:+}/T{:+} ",
170
170
-
self.state.bass_db, self.state.treble_db
171
171
-
),
168
168
+
format!("B{:+}/T{:+} ", self.state.bass_db, self.state.treble_db),
172
169
Style::default()
173
170
.fg(Palette::HIGHLIGHT)
174
171
.add_modifier(Modifier::BOLD),
···
127
127
.clone()
128
128
.or_else(|| existing.as_ref().map(|s| s.device_id.clone()))
129
129
.unwrap_or_default(),
130
130
-
server_kind: existing
131
131
-
.as_ref()
132
132
-
.map(|s| s.server_kind)
133
133
-
.unwrap_or_default(),
130
130
+
server_kind: existing.as_ref().map(|s| s.server_kind).unwrap_or_default(),
134
131
};
135
132
cfg.add_or_update_server(merged);
136
133
} else {
···
203
200
if let Err(e) = r.set_crossfade(cfg.crossfade).await {
204
201
tracing::warn!(?e, "crossfade apply failed");
205
202
}
206
206
-
if let Err(e) = r
207
207
-
.set_eq(cfg.eq_enabled, cfg.eq_band_settings.clone())
208
208
-
.await
209
209
-
{
203
203
+
if let Err(e) = r.set_eq(cfg.eq_enabled, cfg.eq_band_settings.clone()).await {
210
204
tracing::warn!(?e, "eq apply failed");
211
205
}
212
206
if let Err(e) = r