use std::fs;
use std::path::{Path, PathBuf};
use ym2149_arkos_replayer::load_aks;
use ym2149_ay_replayer::AyPlayer;
use ym2149_sndh_replayer::{SndhPlayer, is_sndh_data};
use ym2149_ym_replayer::load_song;
const SUPPORTED_EXTENSIONS: &[&str] = &["ym", "aks", "ay", "sndh"];
#[derive(Clone, Debug)]
pub struct PlaylistEntry {
pub path: PathBuf,
pub title: String,
pub author: String,
pub duration_secs: Option<f32>,
pub format: String,
}
impl PlaylistEntry {
pub fn display_string(&self) -> String {
let duration_str = self
.duration_secs
.filter(|d| d.is_finite() && *d >= 0.0)
.map(|d| {
let clamped = d.min(5999.0); let mins = (clamped / 60.0) as u32;
let secs = (clamped % 60.0) as u32;
format!(" ({mins:02}:{secs:02})")
})
.unwrap_or_default();
if self.title.is_empty() || self.title == "(unknown)" {
let filename = self
.path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("???");
format!("{filename}{duration_str}")
} else if self.author.is_empty() || self.author == "(unknown)" {
format!("{}{duration_str}", self.title)
} else {
format!("{} - {}{duration_str}", self.author, self.title)
}
}
}
#[derive(Default)]
pub struct Playlist {
pub entries: Vec<PlaylistEntry>,
pub selected: usize,
pub search_query: String,
}
impl Playlist {
pub fn scan_directory(path: &Path) -> std::io::Result<Self> {
let mut entries = Vec::new();
scan_directory_recursive(path, &mut entries)?;
entries.sort_by_key(|e| e.display_string().to_lowercase());
Ok(Self {
entries,
selected: 0,
search_query: String::new(),
})
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn select_previous(&mut self) {
if !self.entries.is_empty() {
if self.selected == 0 {
self.selected = self.entries.len() - 1;
} else {
self.selected -= 1;
}
}
}
pub fn select_next(&mut self) {
if !self.entries.is_empty() {
self.selected = (self.selected + 1) % self.entries.len();
}
}
pub fn page_up(&mut self) {
if !self.entries.is_empty() {
self.selected = self.selected.saturating_sub(10);
}
}
pub fn page_down(&mut self) {
if !self.entries.is_empty() {
self.selected = (self.selected + 10).min(self.entries.len() - 1);
}
}
pub fn selected_entry(&self) -> Option<&PlaylistEntry> {
self.entries.get(self.selected)
}
pub fn selected_path(&self) -> Option<&Path> {
self.selected_entry().map(|e| e.path.as_path())
}
pub fn search_append(&mut self, c: char) {
self.search_query.push(c);
self.jump_to_search_match();
}
pub fn search_backspace(&mut self) {
self.search_query.pop();
if !self.search_query.is_empty() {
self.jump_to_search_match();
}
}
pub fn search_clear(&mut self) {
self.search_query.clear();
}
pub fn is_searching(&self) -> bool {
!self.search_query.is_empty()
}
pub fn search_query(&self) -> &str {
&self.search_query
}
fn jump_to_search_match(&mut self) {
if self.search_query.is_empty() || self.entries.is_empty() {
return;
}
let query_lower = self.search_query.to_lowercase();
let is_single_char = self.search_query.len() == 1;
if is_single_char {
for (i, entry) in self.entries.iter().enumerate() {
if entry_starts_with(&query_lower, entry) {
self.selected = i;
return;
}
}
}
for (i, entry) in self.entries.iter().enumerate().skip(self.selected) {
if entry_matches(&query_lower, entry) {
self.selected = i;
return;
}
}
for (i, entry) in self.entries.iter().enumerate().take(self.selected) {
if entry_matches(&query_lower, entry) {
self.selected = i;
return;
}
}
}
pub fn search_next(&mut self) {
if self.search_query.is_empty() || self.entries.is_empty() {
return;
}
let query_lower = self.search_query.to_lowercase();
let start = (self.selected + 1) % self.entries.len();
for i in 0..self.entries.len() {
let idx = (start + i) % self.entries.len();
if entry_matches(&query_lower, &self.entries[idx]) {
self.selected = idx;
return;
}
}
}
pub fn search_previous(&mut self) {
if self.search_query.is_empty() || self.entries.is_empty() {
return;
}
let query_lower = self.search_query.to_lowercase();
let start = if self.selected == 0 {
self.entries.len() - 1
} else {
self.selected - 1
};
for i in 0..self.entries.len() {
let idx = if i <= start {
start - i
} else {
self.entries.len() - (i - start)
};
if entry_matches(&query_lower, &self.entries[idx]) {
self.selected = idx;
return;
}
}
}
}
fn entry_matches(query_lower: &str, entry: &PlaylistEntry) -> bool {
let title_lower = entry.title.to_lowercase();
let author_lower = entry.author.to_lowercase();
let filename_lower = entry
.path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_default();
title_lower.contains(query_lower)
|| author_lower.contains(query_lower)
|| filename_lower.contains(query_lower)
}
fn entry_starts_with(query_lower: &str, entry: &PlaylistEntry) -> bool {
let display = entry.display_string().to_lowercase();
display.starts_with(query_lower)
}
fn scan_directory_recursive(path: &Path, entries: &mut Vec<PlaylistEntry>) -> std::io::Result<()> {
if !path.is_dir() {
return Ok(());
}
for entry in fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
scan_directory_recursive(&path, entries)?;
} else if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_lower = ext.to_ascii_lowercase();
if SUPPORTED_EXTENSIONS.contains(&ext_lower.as_str()) {
if let Some(entry) = extract_metadata(&path) {
entries.push(entry);
}
}
}
}
}
Ok(())
}
fn extract_metadata(path: &Path) -> Option<PlaylistEntry> {
let file_data = fs::read(path).ok()?;
let extension = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
let (title, author, duration_secs, format) = match extension.as_str() {
"aks" => extract_aks_metadata(&file_data)?,
"ay" => extract_ay_metadata(&file_data)?,
"sndh" => extract_sndh_metadata(&file_data)?,
_ => {
if is_sndh_data(&file_data) {
extract_sndh_metadata(&file_data)?
} else {
extract_ym_metadata(&file_data)?
}
}
};
Some(PlaylistEntry {
path: path.to_path_buf(),
title,
author,
duration_secs,
format,
})
}
fn extract_aks_metadata(data: &[u8]) -> Option<(String, String, Option<f32>, String)> {
let song = load_aks(data).ok()?;
let title = if song.metadata.title.is_empty() {
"(unknown)".to_string()
} else {
song.metadata.title.clone()
};
let author = if song.metadata.author.is_empty() {
"(unknown)".to_string()
} else {
song.metadata.author.clone()
};
let duration = song
.subsongs
.first()
.map(|s| s.end_position as f32 / s.replay_frequency_hz);
Some((title, author, duration, "AKS".to_string()))
}
fn extract_ay_metadata(data: &[u8]) -> Option<(String, String, Option<f32>, String)> {
let (_, metadata) = AyPlayer::load_from_bytes(data, 0).ok()?;
let title = if metadata.song_name.is_empty() {
"(unknown)".to_string()
} else {
metadata.song_name.clone()
};
let author = if metadata.author.is_empty() {
"(unknown)".to_string()
} else {
metadata.author.clone()
};
let duration = metadata.frame_count.map(|frames| frames as f32 / 50.0);
Some((title, author, duration, "AY".to_string()))
}
fn extract_sndh_metadata(data: &[u8]) -> Option<(String, String, Option<f32>, String)> {
use ym2149_common::ChiptunePlayer;
let player = SndhPlayer::new(data, 44100).ok()?;
let metadata = ChiptunePlayer::metadata(&player);
let title = if metadata.title.is_empty() {
"(unknown)".to_string()
} else {
metadata.title.to_string()
};
let author = if metadata.author.is_empty() {
"(unknown)".to_string()
} else {
metadata.author.to_string()
};
Some((title, author, None, "SNDH".to_string()))
}
fn extract_ym_metadata(data: &[u8]) -> Option<(String, String, Option<f32>, String)> {
let (player, summary) = load_song(data).ok()?;
let (title, author) = if let Some(info) = player.info() {
(
if info.song_name.is_empty() {
"(unknown)".to_string()
} else {
info.song_name.clone()
},
if info.author.is_empty() {
"(unknown)".to_string()
} else {
info.author.clone()
},
)
} else {
("(unknown)".to_string(), "(unknown)".to_string())
};
let duration = Some(summary.total_samples() as f32 / 44100.0);
let format = summary.format.to_string();
Some((title, author, duration, format))
}