mirror of
https://github.com/Felitendo/spotifast.git
synced 2026-11-01 17:33:26 +01:00
The marquee drew through the skin's bitmap font, which folds what it does not have to a question mark, so a Japanese title was a row of them (#104). Text the font cannot say is rasterised in the pixel face the playlist already uses, scaled to the marquee's height, tinted in the playlist's colour, and slid past the window a cell at a time.
448 lines
16 KiB
Rust
448 lines
16 KiB
Rust
//! The Winamp window's state: the skin it wears, where it sits, and what
|
|
//! its display is up to. The drawing is in `ui::winamp`.
|
|
//!
|
|
//! Skins live in a folder of the config directory and are read on a thread
|
|
//! of their own, since a museum skin is a megabyte of bitmaps to inflate
|
|
//! and decode; the built-in skin is ready without reading anything. The
|
|
//! settings name the skin to wear and `App::tick` keeps the window in step
|
|
//! with them, so choosing a skin is a matter of writing the setting.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::sync::mpsc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use crate::settings::Settings;
|
|
use crate::skin::{Sheet, Skin, SkinError};
|
|
use crate::vis::{Analyser, AudioTap};
|
|
|
|
/// The most screen pixels a skin pixel may take.
|
|
pub const MAX_SCALE: u32 = 4;
|
|
/// How many characters the marquee shows: thirty whole ones and the edge
|
|
/// of a thirty-first.
|
|
pub const MARQUEE_CHARS: usize = 31;
|
|
/// Text this long fits the marquee without scrolling.
|
|
const MARQUEE_FITS: usize = 30;
|
|
/// How long the marquee waits between one-character steps.
|
|
const MARQUEE_STEP: Duration = Duration::from_millis(220);
|
|
/// What separates the end of a scrolling title from its start again.
|
|
const MARQUEE_GAP: &str = " *** ";
|
|
|
|
/// The text with the gap it scrolls through, for drawing it as pixels.
|
|
pub fn marquee_strip(text: &str) -> String {
|
|
format!("{text}{MARQUEE_GAP}")
|
|
}
|
|
/// How long a listing of the skins folder is trusted.
|
|
const CHOICES_FRESH: Duration = Duration::from_secs(5);
|
|
/// File extensions a skin archive may carry.
|
|
const ARCHIVE_EXTENSIONS: [&str; 2] = ["wsz", "zip"];
|
|
|
|
/// A skin in the skins folder.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct SkinChoice {
|
|
/// The file or folder name, which is what the settings store.
|
|
pub name: String,
|
|
pub path: PathBuf,
|
|
}
|
|
|
|
impl SkinChoice {
|
|
/// The name without its extension, for showing.
|
|
pub fn label(&self) -> &str {
|
|
label(&self.name)
|
|
}
|
|
}
|
|
|
|
/// A skin's name without the archive extension, for showing.
|
|
pub fn label(name: &str) -> &str {
|
|
name.rsplit_once('.')
|
|
.filter(|(_, extension)| is_archive_extension(extension))
|
|
.map_or(name, |(stem, _)| stem)
|
|
}
|
|
|
|
/// What a loading thread reports back.
|
|
pub struct Loaded {
|
|
/// The name the settings hold for this skin.
|
|
pub name: String,
|
|
pub result: Result<Skin, SkinError>,
|
|
/// Whether the skin was just copied into the folder.
|
|
pub installed: bool,
|
|
}
|
|
|
|
pub struct WinampState {
|
|
/// The skin on screen.
|
|
pub skin: Arc<Skin>,
|
|
/// The setting the worn skin answers to; `None` is the built-in.
|
|
pub worn: Option<String>,
|
|
/// The skin's sheets on the graphics card, made on demand and dropped
|
|
/// with the window, since textures belong to a window's context.
|
|
textures: HashMap<Sheet, egui::TextureHandle>,
|
|
loading: Option<mpsc::Receiver<Loaded>>,
|
|
/// The skins folder's contents, as last listed.
|
|
pub choices: Vec<SkinChoice>,
|
|
choices_listed: Option<Instant>,
|
|
/// Count down instead of up; clicking the time toggles it.
|
|
pub time_remaining: bool,
|
|
/// The balance while its thumb is held, for the marquee to report.
|
|
pub balance_preview: Option<f32>,
|
|
/// Open the equalizer's presets menu on the next frame; the demo's
|
|
/// `presets` surface asks for it.
|
|
pub open_presets: bool,
|
|
marquee_text: String,
|
|
marquee_offset: usize,
|
|
marquee_moved: Option<Instant>,
|
|
/// Where the window opens, from the session or from where it last was.
|
|
pub restore_pos: Option<[f32; 2]>,
|
|
/// Where the window is, as last seen.
|
|
pub last_pos: Option<[f32; 2]>,
|
|
/// The sound on its way out, for the visualiser.
|
|
pub tap: Arc<AudioTap>,
|
|
pub analyser: Analyser,
|
|
/// The equalizer as the player's thread reads it.
|
|
pub eq: crate::eq::SharedEq,
|
|
/// The playlist window: its first visible row, the row clicked, the
|
|
/// wheel's leftover, and the corner drag's leftover.
|
|
pub playlist_scroll: usize,
|
|
/// The rows selected, by URI; Ctrl-click adds and removes, SEL has
|
|
/// the rest.
|
|
pub playlist_selection: std::collections::HashSet<String>,
|
|
pub playlist_wheel: f32,
|
|
pub playlist_resize: f32,
|
|
/// The playlist's rows as Winamp drew them, kept once drawn.
|
|
pub playlist_text: crate::ui::winamp::PixelText,
|
|
}
|
|
|
|
impl WinampState {
|
|
pub fn new(restore_pos: Option<[f32; 2]>, tap: Arc<AudioTap>, eq: crate::eq::SharedEq) -> Self {
|
|
Self {
|
|
skin: Skin::builtin(),
|
|
worn: None,
|
|
textures: HashMap::new(),
|
|
loading: None,
|
|
choices: Vec::new(),
|
|
choices_listed: None,
|
|
time_remaining: false,
|
|
balance_preview: None,
|
|
open_presets: false,
|
|
marquee_text: String::new(),
|
|
marquee_offset: 0,
|
|
marquee_moved: None,
|
|
restore_pos,
|
|
last_pos: None,
|
|
tap,
|
|
analyser: Analyser::default(),
|
|
eq,
|
|
playlist_scroll: 0,
|
|
playlist_selection: std::collections::HashSet::new(),
|
|
playlist_wheel: 0.0,
|
|
playlist_resize: 0.0,
|
|
playlist_text: crate::ui::winamp::PixelText::default(),
|
|
}
|
|
}
|
|
|
|
/// Screen pixels per skin pixel: the setting, or else double size on
|
|
/// this display, which is the size people remember Winamp at.
|
|
pub fn scale(settings: &Settings, pixels_per_point: f32) -> u32 {
|
|
let chosen = settings
|
|
.skin_scale
|
|
.map(u32::from)
|
|
.unwrap_or_else(|| (2.0 * pixels_per_point).round() as u32);
|
|
chosen.clamp(1, MAX_SCALE)
|
|
}
|
|
|
|
pub fn is_loading(&self) -> bool {
|
|
self.loading.is_some()
|
|
}
|
|
|
|
/// Puts a skin on. The textures are remade from it at the next frame.
|
|
pub fn wear(&mut self, name: Option<String>, skin: Arc<Skin>) {
|
|
self.skin = skin;
|
|
self.worn = name;
|
|
self.textures.clear();
|
|
}
|
|
|
|
/// The window is gone (or about to be), and with it the textures.
|
|
pub fn forget_textures(&mut self) {
|
|
self.textures.clear();
|
|
self.playlist_text.clear();
|
|
}
|
|
|
|
/// Keeps where the window was, for the next time it opens.
|
|
pub fn remember_position(&mut self) {
|
|
if let Some(pos) = self.last_pos {
|
|
self.restore_pos = Some(pos);
|
|
}
|
|
}
|
|
|
|
/// The skin's sheets as textures, made now if they are not yet.
|
|
pub fn textures(&mut self, ctx: &egui::Context) -> HashMap<Sheet, egui::TextureId> {
|
|
for sheet in Sheet::ALL {
|
|
if self.textures.contains_key(&sheet) {
|
|
continue;
|
|
}
|
|
let bitmap = self.skin.sheet(sheet);
|
|
let image = egui::ColorImage::from_rgba_unmultiplied(
|
|
[bitmap.width as usize, bitmap.height as usize],
|
|
&bitmap.rgba,
|
|
);
|
|
let handle = ctx.load_texture(
|
|
format!("winamp-{}", sheet.file_stem()),
|
|
image,
|
|
egui::TextureOptions::NEAREST,
|
|
);
|
|
self.textures.insert(sheet, handle);
|
|
}
|
|
self.textures
|
|
.iter()
|
|
.map(|(sheet, handle)| (*sheet, handle.id()))
|
|
.collect()
|
|
}
|
|
|
|
/// Reads a skin from the folder on another thread.
|
|
pub fn load(&mut self, name: String, folder: &Path, ctx: &egui::Context) {
|
|
let path = folder.join(&name);
|
|
self.spawn(ctx, move || Loaded {
|
|
result: Skin::load(&path),
|
|
name,
|
|
installed: false,
|
|
});
|
|
}
|
|
|
|
/// Reads a skin from anywhere and, if it is one, copies it into the
|
|
/// folder, on another thread.
|
|
pub fn install(&mut self, file: PathBuf, folder: &Path, ctx: &egui::Context) {
|
|
let folder = folder.to_path_buf();
|
|
let name = file
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
self.spawn(ctx, move || {
|
|
let result = Skin::load(&file).and_then(|skin| {
|
|
let destination = folder.join(&name);
|
|
if destination != file {
|
|
std::fs::create_dir_all(&folder)?;
|
|
std::fs::copy(&file, &destination)?;
|
|
}
|
|
Ok(skin)
|
|
});
|
|
Loaded {
|
|
name,
|
|
result,
|
|
installed: true,
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Starts a job; one already running is left to finish unheard, so the
|
|
/// latest request is the one that counts.
|
|
fn spawn(&mut self, ctx: &egui::Context, job: impl FnOnce() -> Loaded + Send + 'static) {
|
|
let (sender, receiver) = mpsc::channel();
|
|
let ctx = ctx.clone();
|
|
let spawned = std::thread::Builder::new()
|
|
.name("skin-loader".into())
|
|
.spawn(move || {
|
|
let _ = sender.send(job());
|
|
ctx.request_repaint();
|
|
});
|
|
if let Err(error) = spawned {
|
|
log::warn!("could not start reading the skin: {error}");
|
|
}
|
|
self.loading = Some(receiver);
|
|
}
|
|
|
|
/// What a loading thread has finished with, if anything.
|
|
pub fn poll(&mut self) -> Option<Loaded> {
|
|
let receiver = self.loading.as_ref()?;
|
|
match receiver.try_recv() {
|
|
Ok(loaded) => {
|
|
self.loading = None;
|
|
Some(loaded)
|
|
}
|
|
Err(mpsc::TryRecvError::Empty) => None,
|
|
Err(mpsc::TryRecvError::Disconnected) => {
|
|
self.loading = None;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Lists the folder again if the listing is old.
|
|
pub fn refresh_choices(&mut self, folder: &Path) {
|
|
if self
|
|
.choices_listed
|
|
.is_none_or(|at| at.elapsed() > CHOICES_FRESH)
|
|
{
|
|
self.list_choices(folder);
|
|
}
|
|
}
|
|
|
|
/// Lists the folder: `.wsz` files and folders with a `main.bmp`.
|
|
pub fn list_choices(&mut self, folder: &Path) {
|
|
self.choices_listed = Some(Instant::now());
|
|
self.choices = list_skins(folder);
|
|
}
|
|
|
|
/// The characters the marquee shows now: the text itself when it
|
|
/// fits, otherwise a window onto it that moves a character at a time.
|
|
pub fn marquee(&mut self, text: &str, now: Instant) -> (String, usize) {
|
|
if text != self.marquee_text {
|
|
self.marquee_text = text.to_string();
|
|
self.marquee_offset = 0;
|
|
self.marquee_moved = Some(now);
|
|
}
|
|
let mut chars: Vec<char> = self.marquee_text.chars().collect();
|
|
if chars.len() <= MARQUEE_FITS {
|
|
return (self.marquee_text.clone(), 0);
|
|
}
|
|
chars.extend(MARQUEE_GAP.chars());
|
|
let moved = self.marquee_moved.get_or_insert(now);
|
|
let steps =
|
|
(now.saturating_duration_since(*moved).as_millis() / MARQUEE_STEP.as_millis()) as usize;
|
|
if steps > 0 {
|
|
self.marquee_offset = self.marquee_offset.wrapping_add(steps);
|
|
*moved += MARQUEE_STEP * steps as u32;
|
|
}
|
|
let offset = self.marquee_offset;
|
|
let shown = (0..MARQUEE_CHARS)
|
|
.map(|index| chars[(offset + index) % chars.len()])
|
|
.collect();
|
|
(shown, offset)
|
|
}
|
|
|
|
/// Whether the marquee is on the move, and so wants frames.
|
|
pub fn marquee_scrolling(&self) -> bool {
|
|
self.marquee_text.chars().count() > MARQUEE_FITS
|
|
}
|
|
}
|
|
|
|
fn is_archive_extension(extension: &str) -> bool {
|
|
ARCHIVE_EXTENSIONS
|
|
.iter()
|
|
.any(|known| extension.eq_ignore_ascii_case(known))
|
|
}
|
|
|
|
/// Whether a dropped file could be a skin, by its name.
|
|
pub fn is_skin_file(path: &Path) -> bool {
|
|
path.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.is_some_and(is_archive_extension)
|
|
}
|
|
|
|
/// The skins in a folder, by name.
|
|
pub fn list_skins(folder: &Path) -> Vec<SkinChoice> {
|
|
let Ok(entries) = std::fs::read_dir(folder) else {
|
|
return Vec::new();
|
|
};
|
|
let mut skins: Vec<SkinChoice> = entries
|
|
.flatten()
|
|
.filter_map(|entry| {
|
|
let path = entry.path();
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
let is_skin = if path.is_dir() {
|
|
has_main_bitmap(&path)
|
|
} else {
|
|
is_skin_file(&path)
|
|
};
|
|
is_skin.then_some(SkinChoice { name, path })
|
|
})
|
|
.collect();
|
|
skins.sort_by_key(|skin| skin.name.to_lowercase());
|
|
skins
|
|
}
|
|
|
|
fn has_main_bitmap(folder: &Path) -> bool {
|
|
std::fs::read_dir(folder).is_ok_and(|entries| {
|
|
entries.flatten().any(|entry| {
|
|
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
|
|
name == "main.bmp" || name == "main.png"
|
|
})
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn temp_dir(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("fastpotify-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn the_size_defaults_to_double_on_this_display() {
|
|
let mut settings = Settings::default();
|
|
assert_eq!(WinampState::scale(&settings, 1.0), 2);
|
|
assert_eq!(WinampState::scale(&settings, 2.0), 4);
|
|
assert_eq!(WinampState::scale(&settings, 1.2), 2);
|
|
assert_eq!(WinampState::scale(&settings, 1.5), 3);
|
|
settings.skin_scale = Some(1);
|
|
assert_eq!(WinampState::scale(&settings, 2.0), 1);
|
|
settings.skin_scale = Some(9);
|
|
assert_eq!(WinampState::scale(&settings, 1.0), MAX_SCALE);
|
|
}
|
|
|
|
#[test]
|
|
fn a_short_title_sits_still_and_a_long_one_scrolls() {
|
|
let mut state = WinampState::new(None, AudioTap::new(), crate::eq::shared());
|
|
let start = Instant::now();
|
|
assert_eq!(state.marquee("Fastpotify", start).0, "Fastpotify");
|
|
assert!(!state.marquee_scrolling());
|
|
|
|
let long = "Radiohead - Everything In Its Right Place (4:11)";
|
|
let (first, offset) = state.marquee(long, start);
|
|
assert_eq!(first.chars().count(), MARQUEE_CHARS);
|
|
assert_eq!(offset, 0);
|
|
assert!(long.starts_with(&first));
|
|
assert!(state.marquee_scrolling());
|
|
// Not yet time to move.
|
|
assert_eq!(
|
|
state.marquee(long, start + Duration::from_millis(100)).0,
|
|
first
|
|
);
|
|
let (later, stepped) = state.marquee(long, start + MARQUEE_STEP);
|
|
assert!(long[1..].starts_with(&later));
|
|
assert_eq!(stepped, 1);
|
|
// Eventually the start comes round again, after the gap.
|
|
let round = long.chars().count() + MARQUEE_GAP.len();
|
|
let again = state.marquee(long, start + MARQUEE_STEP * round as u32).0;
|
|
assert_eq!(again, first);
|
|
// A new title starts from its beginning.
|
|
let other = "Something else entirely, and just as long as before";
|
|
assert!(other.starts_with(&state.marquee(other, start + Duration::from_secs(9)).0));
|
|
}
|
|
|
|
#[test]
|
|
fn the_folder_lists_archives_and_unpacked_skins_by_name() {
|
|
let dir = temp_dir("skins");
|
|
std::fs::write(dir.join("Zaxon.WSZ"), b"").unwrap();
|
|
std::fs::write(dir.join("base.wsz"), b"").unwrap();
|
|
std::fs::write(dir.join("readme.txt"), b"").unwrap();
|
|
std::fs::create_dir_all(dir.join("Unpacked")).unwrap();
|
|
std::fs::write(dir.join("Unpacked").join("MAIN.BMP"), b"").unwrap();
|
|
std::fs::create_dir_all(dir.join("Empty folder")).unwrap();
|
|
let names: Vec<String> = list_skins(&dir).into_iter().map(|skin| skin.name).collect();
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
assert_eq!(names, ["base.wsz", "Unpacked", "Zaxon.WSZ"]);
|
|
}
|
|
|
|
#[test]
|
|
fn labels_drop_the_archive_extension_only() {
|
|
let choice = |name: &str| SkinChoice {
|
|
name: name.to_string(),
|
|
path: PathBuf::new(),
|
|
};
|
|
assert_eq!(choice("Zaxon Remake 1.0.wsz").label(), "Zaxon Remake 1.0");
|
|
assert_eq!(choice("Unpacked").label(), "Unpacked");
|
|
assert_eq!(choice("v2.5").label(), "v2.5");
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_folder_lists_nothing() {
|
|
assert!(list_skins(Path::new("/nonexistent/skins")).is_empty());
|
|
assert!(is_skin_file(Path::new("/tmp/x.WSZ")));
|
|
assert!(!is_skin_file(Path::new("/tmp/x.bmp")));
|
|
}
|
|
}
|