#![doc = include_str!("../README.md")]
use std::{
cell::RefCell,
collections::HashSet,
env, fs,
path::{Path, PathBuf},
rc::Rc,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result};
use gst::prelude::*;
use gstreamer as gst;
use gtk::{gdk, gio, glib, prelude::*};
use serde::{Deserialize, Serialize};
mod playback;
use playback::{
AdvanceTrigger, QueueAction, RepeatMode, ShuffleOrder, sequential_advance, sequential_previous,
};
const APP_ID: &str = "io.github.tknott95.KnottMusic";
const CSS: &str = r#"
* {
font-family: "IBM Plex Sans", "Inter", "Noto Sans", sans-serif;
}
window, .app-shell {
background: #020608;
color: #e8fcff;
}
.topbar {
background-image: linear-gradient(90deg, #07161b, #03090c 72%);
border-bottom: 1px solid #16414b;
padding: 12px 16px;
}
.brand-mark {
color: #001014;
background: #00e5ff;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
font-weight: 900;
padding: 8px 9px;
}
.brand {
color: #f2feff;
font-size: 16px;
font-weight: 900;
letter-spacing: 3px;
}
.brand-subtitle {
color: #557f87;
font-family: monospace;
font-size: 9px;
letter-spacing: 1px;
}
.brand-accent, .eyebrow, .time-label {
color: #00e5ff;
}
.eyebrow {
font-family: monospace;
font-size: 10px;
font-weight: 900;
letter-spacing: 2px;
}
.muted {
color: #78a0a8;
}
.content {
background-image: linear-gradient(135deg, #020608, #061014);
padding: 16px;
}
.card {
background: #071216;
border: 1px solid #163740;
border-radius: 6px;
box-shadow: 0 10px 30px rgba(0, 0, 0, .35);
}
.queue-card {
border-top: 3px solid #16414b;
padding: 15px;
}
.queue-title {
color: #e8fcff;
font-size: 17px;
font-weight: 850;
}
.queue-count {
color: #00d8ef;
background: #061b21;
border: 1px solid #16414b;
border-radius: 3px;
font-family: monospace;
font-size: 9px;
font-weight: 800;
padding: 4px 7px;
}
.queue-action {
color: #6f9aa2;
background: #050d10;
border-color: #12313a;
font-family: monospace;
font-size: 9px;
font-weight: 800;
letter-spacing: 1px;
min-height: 28px;
padding: 2px 8px;
}
.queue-action:hover {
color: #8ff6ff;
}
.drop-active .card {
border-color: #00e5ff;
}
.queue-row {
color: #dffcff;
background: #050d10;
border-radius: 3px;
padding: 10px 9px;
margin: 2px 0;
}
.queue-row:hover {
background: #0b2027;
}
.queue-row:selected {
color: #ffffff;
background: #092a32;
border-left: 3px solid #00e5ff;
}
.queue-row.is-playing {
border-left: 3px solid #00e5ff;
background: #071c22;
}
.queue-row.is-playing .queue-index {
color: #79f6ff;
}
.queue-card scrolledwindow,
.queue-card viewport,
.queue-card list,
.queue-card listbox {
color: #dffcff;
background: #050d10;
}
.queue-card scrolledwindow {
border: 1px solid #12313a;
border-radius: 3px;
}
.queue-index {
color: #4f7b84;
font-family: monospace;
font-size: 10px;
}
.stage {
border-top: 3px solid #00d8ef;
padding: 22px;
}
.cover {
background: #020608;
border: 1px solid #1a4a55;
border-radius: 4px;
}
.hero {
min-height: 250px;
}
.artwork-frame {
background: #041014;
border: 1px solid #1a4a55;
border-radius: 4px;
box-shadow: 0 12px 28px rgba(0, 0, 0, .4);
}
.album-art {
border-radius: 3px;
}
.track-title {
color: #f2feff;
font-size: 29px;
font-weight: 900;
letter-spacing: -1px;
}
.track-detail {
color: #6f9aa2;
font-family: monospace;
font-size: 11px;
letter-spacing: 1px;
}
.transport {
background: #040b0e;
border: 1px solid #12313a;
border-radius: 4px;
padding: 8px 10px;
}
button {
color: #dffcff;
background: #091a20;
border: 1px solid #1a4651;
border-radius: 4px;
min-height: 38px;
min-width: 38px;
padding: 4px 12px;
box-shadow: none;
}
button:hover {
color: #ffffff;
background: #0e2b34;
border-color: #00b8cc;
}
button:focus-visible,
scale:focus-visible {
outline: 2px solid #65f3ff;
outline-offset: 2px;
}
button:active, button.active {
background: #00a7ba;
color: #001014;
}
.primary-button {
background: #00d8ef;
color: #001014;
border-color: #00e5ff;
font-weight: 800;
min-width: 48px;
min-height: 48px;
border-radius: 4px;
}
.primary-button:hover {
background: #65f3ff;
color: #001014;
}
.add-button {
background: #08262e;
border-color: #147185;
color: #8ff6ff;
font-weight: 700;
}
.mode-button {
color: #7fc4ce;
font-family: monospace;
font-size: 10px;
font-weight: 800;
letter-spacing: 1px;
}
.folder-button {
color: #73cbd5;
border-color: #16414b;
font-family: monospace;
font-size: 10px;
font-weight: 800;
letter-spacing: 1px;
}
scale trough {
background: #10252b;
border-radius: 4px;
min-height: 5px;
}
scale highlight {
background: #00d8ef;
border-radius: 4px;
}
scale slider {
background: #dfffff;
border: 2px solid #00d8ef;
min-width: 12px;
min-height: 12px;
margin: -5px;
}
.status-pill {
color: #72f4ff;
background: #072129;
border: 1px solid #10424d;
border-radius: 3px;
font-size: 10px;
font-weight: 800;
letter-spacing: 1px;
padding: 5px 10px;
}
.cue-menu-button {
color: #72f4ff;
border-color: #16414b;
min-height: 30px;
min-width: 34px;
padding: 2px 8px;
}
.cue-menu-button.has-cues {
color: #001014;
background: #00d8ef;
border-color: #65f3ff;
}
.cue-popover {
padding: 12px;
min-width: 250px;
}
.cue-summary {
color: #8abac2;
font-family: monospace;
font-size: 10px;
}
.cue-action {
font-family: monospace;
font-size: 9px;
font-weight: 800;
}
.repeat-one {
box-shadow: inset 0 0 0 2px #dfffff;
}
.compact-shell {
background-image:
radial-gradient(circle at 10% 20%, rgba(0, 216, 239, .12), transparent 38%),
linear-gradient(135deg, #061116, #020608 72%);
border: 1px solid #16414b;
padding: 12px 14px 14px;
}
.compact-topbar {
border-bottom: 1px solid #12313a;
padding-bottom: 8px;
}
.compact-kicker {
color: #61eaf5;
font-family: monospace;
font-size: 9px;
font-weight: 800;
letter-spacing: 1px;
}
.compact-status {
color: #73f4ff;
background: #072129;
border: 1px solid #10424d;
border-radius: 3px;
font-family: monospace;
font-size: 8px;
font-weight: 800;
letter-spacing: 1px;
padding: 4px 8px;
}
.compact-title {
color: #f4feff;
font-size: 20px;
font-weight: 900;
letter-spacing: -0.5px;
}
.compact-detail {
color: #739ca4;
font-family: monospace;
font-size: 9px;
}
.compact-controls {
background: rgba(3, 11, 14, .78);
border: 1px solid #12313a;
border-radius: 4px;
padding: 4px 6px;
}
.compact-controls button {
min-height: 32px;
min-width: 32px;
padding: 2px 7px;
}
.compact-play-button {
background: #00d8ef;
border-color: #65f3ff;
color: #001014;
min-height: 38px;
min-width: 42px;
}
scrollbar slider {
background: #17404a;
border-radius: 99px;
min-width: 5px;
}
window.widget-mode {
background: #020608;
}
"#;
#[derive(Clone)]
struct Track {
path: PathBuf,
title: String,
detail: String,
artist: Option<String>,
album: Option<String>,
codec: Option<String>,
bitrate: Option<u32>,
}
impl Track {
fn from_path(path: PathBuf) -> Self {
let title = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("Unknown track")
.replace(['_', '-'], " ");
let detail = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("AUDIO")
.to_uppercase();
Self {
path,
title,
detail,
artist: None,
album: None,
codec: None,
bitrate: None,
}
}
fn subtitle(&self) -> String {
match (&self.artist, &self.album) {
(Some(artist), Some(album)) => format!("{artist} / {album}"),
(Some(artist), None) => artist.clone(),
(None, Some(album)) => album.clone(),
(None, None) => format!("{} / {}", self.detail, parent_name(&self.path)),
}
}
fn technical_detail(&self) -> String {
let codec = self.codec.as_deref().unwrap_or(&self.detail);
match self.bitrate {
Some(bitrate) if bitrate > 0 => format!("{codec} / {} KBPS", bitrate / 1_000),
_ => codec.to_string(),
}
}
fn queue_subtitle(&self) -> String {
if self.artist.is_some() || self.album.is_some() {
format!("{} · {}", self.subtitle(), self.technical_detail())
} else {
self.subtitle()
}
}
}
#[derive(Deserialize, Serialize)]
#[serde(default)]
struct Session {
queue: Vec<PathBuf>,
current: Option<usize>,
current_path: Option<PathBuf>,
position_seconds: u64,
#[serde(rename = "repeat")]
repeat_mode: RepeatMode,
shuffle: bool,
volume: f64,
last_volume: f64,
muted: bool,
queue_ended: bool,
widget_mode: bool,
cue_points: Vec<CuePoint>,
}
impl Default for Session {
fn default() -> Self {
Self {
queue: Vec::new(),
current: None,
current_path: None,
position_seconds: 0,
repeat_mode: RepeatMode::Off,
shuffle: false,
volume: 0.8,
last_volume: 0.8,
muted: false,
queue_ended: false,
widget_mode: false,
cue_points: Vec::new(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct CuePoint {
path: PathBuf,
seconds: u64,
}
struct CueMenu {
button: gtk::MenuButton,
summary: gtk::Label,
previous: gtk::Button,
save: gtk::Button,
next: gtk::Button,
clear: gtk::Button,
}
struct CompactView {
root: gtk::Box,
expand_button: gtk::Button,
artwork: gtk::Picture,
title: gtk::Label,
detail: gtk::Label,
status: gtk::Label,
seek: gtk::Scale,
elapsed: gtk::Label,
total: gtk::Label,
previous: gtk::Button,
play: gtk::Button,
next: gtk::Button,
seek_back: gtk::Button,
seek_forward: gtk::Button,
shuffle: gtk::Button,
repeat: gtk::Button,
volume: gtk::Button,
cue_menu: CueMenu,
}
struct MusicApp {
playbin: gst::Element,
bus_watch: Option<gst::bus::BusWatchGuard>,
session_path: Option<PathBuf>,
queue: Vec<Track>,
current: Option<usize>,
playing: bool,
queue_ended: bool,
playback_error: bool,
stream_ready: bool,
repeat_mode: RepeatMode,
shuffle: bool,
shuffle_order: ShuffleOrder,
volume: f64,
last_volume: f64,
muted: bool,
volume_save_source: Option<glib::SourceId>,
pending_seek: Option<u64>,
widget_mode: bool,
cue_points: Vec<CuePoint>,
window: gtk::ApplicationWindow,
view_stack: gtk::Stack,
mode_button: gtk::Button,
artwork: gtk::Picture,
compact_artwork: gtk::Picture,
volume_button: gtk::Button,
compact_volume_button: gtk::Button,
volume_control: gtk::Scale,
list: gtk::ListBox,
queue_scroll: gtk::ScrolledWindow,
queue_count: gtk::Label,
empty_queue: gtk::Label,
title: gtk::Label,
detail: gtk::Label,
status: gtk::Label,
play_button: gtk::Button,
repeat_button: gtk::Button,
shuffle_button: gtk::Button,
compact_repeat_button: gtk::Button,
compact_shuffle_button: gtk::Button,
compact_title: gtk::Label,
compact_detail: gtk::Label,
compact_status: gtk::Label,
compact_play_button: gtk::Button,
compact_seek: gtk::Scale,
compact_elapsed: gtk::Label,
compact_total: gtk::Label,
cue_buttons: Vec<gtk::MenuButton>,
cue_summaries: Vec<gtk::Label>,
spectrum_bands: Rc<RefCell<Vec<f32>>>,
seek: gtk::Scale,
elapsed: gtk::Label,
total: gtk::Label,
}
impl MusicApp {
fn set_identity(&self, title: &str, detail: &str) {
self.title.set_text(title);
self.detail.set_text(detail);
self.compact_title.set_text(title);
self.compact_detail.set_text(detail);
}
fn set_status_text(&self, status: &str) {
self.status.set_text(status);
self.compact_status.set_text(status);
}
fn set_play_icon(&self, icon: &str) {
self.play_button.set_icon_name(icon);
self.compact_play_button.set_icon_name(icon);
}
fn reset_timeline(&self) {
for seek in [&self.seek, &self.compact_seek] {
seek.set_range(0.0, 1.0);
seek.set_value(0.0);
}
self.elapsed.set_text("0:00");
self.total.set_text("0:00");
self.compact_elapsed.set_text("0:00");
self.compact_total.set_text("0:00");
}
fn add_tracks(&mut self, paths: Vec<PathBuf>) {
let mut known: HashSet<PathBuf> =
self.queue.iter().map(|track| track.path.clone()).collect();
let previous_len = self.queue.len();
for path in expand_audio_paths(paths) {
if known.insert(path.clone()) {
self.queue.push(Track::from_path(path));
}
}
if self.queue.len() != previous_len {
self.shuffle_order.reset(self.queue.len(), self.current);
}
self.rebuild_queue();
if self.current.is_none() && !self.queue.is_empty() {
self.load_track(0, false);
} else {
self.save_session();
}
}
fn open_tracks(&mut self, paths: Vec<PathBuf>) {
let paths = expand_audio_paths(paths);
let requested = paths.first().cloned();
self.add_tracks(paths);
if let Some(index) = requested
.as_ref()
.and_then(|path| self.queue.iter().position(|track| &track.path == path))
{
self.play_track(index);
}
}
fn rebuild_queue(&self) {
let selected_index = self
.list
.selected_row()
.map(|row| row.index() as usize)
.filter(|index| *index < self.queue.len());
while let Some(child) = self.list.first_child() {
self.list.remove(&child);
}
self.empty_queue.set_visible(self.queue.is_empty());
self.queue_scroll.set_visible(!self.queue.is_empty());
let unit = if self.queue.len() == 1 {
"TRACK"
} else {
"TRACKS"
};
self.queue_count
.set_text(&format!("{:02} {unit}", self.queue.len()));
for (index, track) in self.queue.iter().enumerate() {
let row = gtk::ListBoxRow::new();
row.add_css_class("queue-row");
if self.current == Some(index) {
row.add_css_class("is-playing");
}
let content = gtk::Box::new(gtk::Orientation::Horizontal, 10);
let number = gtk::Label::new(Some(&format!("{:02}", index + 1)));
number.add_css_class("queue-index");
number.set_width_chars(2);
let labels = gtk::Box::new(gtk::Orientation::Vertical, 2);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(&track.title));
title.set_xalign(0.0);
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
let detail = gtk::Label::new(Some(&track.queue_subtitle()));
detail.set_xalign(0.0);
detail.add_css_class("muted");
labels.append(&title);
labels.append(&detail);
content.append(&number);
content.append(&labels);
row.set_child(Some(&content));
self.list.append(&row);
}
if let Some(index) = selected_index.or(self.current)
&& let Some(row) = self.list.row_at_index(index as i32)
{
self.list.select_row(Some(&row));
}
}
fn remove_selected(&mut self) {
let Some(row) = self.list.selected_row() else {
return;
};
let index = row.index() as usize;
if index >= self.queue.len() {
return;
}
let current = self.current;
let was_playing = self.playing;
self.queue.remove(index);
match current {
Some(_) if self.queue.is_empty() => self.reset_player(),
Some(current) if current == index => {
self.load_track(index.min(self.queue.len() - 1), was_playing)
}
Some(current) if index < current => self.current = Some(current - 1),
_ => {}
}
self.shuffle_order.reset(self.queue.len(), self.current);
self.rebuild_queue();
self.save_session();
}
fn clear_queue(&mut self) {
if self.queue.is_empty() {
return;
}
self.queue.clear();
self.shuffle_order.reset(0, None);
self.reset_player();
self.rebuild_queue();
self.save_session();
}
fn move_selected(&mut self, direction: isize) {
let Some(row) = self.list.selected_row() else {
return;
};
let index = row.index() as usize;
if index >= self.queue.len() {
return;
}
let target = index.saturating_add_signed(direction);
if target >= self.queue.len() || target == index {
return;
}
self.queue.swap(index, target);
self.current = match self.current {
Some(current) if current == index => Some(target),
Some(current) if current == target => Some(index),
other => other,
};
self.shuffle_order.reset(self.queue.len(), self.current);
self.rebuild_queue();
if let Some(row) = self.list.row_at_index(target as i32) {
self.list.select_row(Some(&row));
}
self.save_session();
}
fn reset_player(&mut self) {
let _ = self.playbin.set_state(gst::State::Null);
self.current = None;
self.playing = false;
self.queue_ended = false;
self.playback_error = false;
self.stream_ready = false;
self.pending_seek = None;
self.set_identity("No track selected", "Drop audio here or add local music");
self.set_status_text("STANDBY");
self.set_play_icon("media-playback-start-symbolic");
self.reset_timeline();
self.artwork.set_visible(false);
self.compact_artwork.set_visible(false);
self.sync_cue_controls();
}
fn play_track(&mut self, index: usize) {
self.load_track(index, true);
}
fn load_track(&mut self, index: usize, autoplay: bool) {
let Some(track) = self.queue.get(index).cloned() else {
return;
};
self.stream_ready = false;
self.playback_error = false;
let _ = self.playbin.set_state(gst::State::Ready);
if let Some(bus) = self.playbin.bus() {
while bus.pop().is_some() {}
}
let uri = gio::File::for_path(&track.path).uri();
self.playbin.set_property("uri", uri.as_str());
self.current = Some(index);
self.playing = autoplay;
self.queue_ended = false;
self.pending_seek = None;
let target_state = if autoplay {
gst::State::Playing
} else {
gst::State::Paused
};
if self.playbin.set_state(target_state).is_err() {
self.set_error("Could not start playback");
return;
}
if self.shuffle {
self.shuffle_order.record_selection(index, self.queue.len());
}
self.set_identity(&track.title, &track.subtitle());
self.set_status_text(if autoplay { "PLAYING" } else { "READY" });
self.set_play_icon(if autoplay {
"media-playback-pause-symbolic"
} else {
"media-playback-start-symbolic"
});
self.reset_timeline();
self.artwork.set_visible(false);
self.compact_artwork.set_visible(false);
self.sync_cue_controls();
self.rebuild_queue();
if let Some(row) = self.list.row_at_index(index as i32) {
self.list.select_row(Some(&row));
}
self.save_session();
}
fn toggle_playback(&mut self) {
if self.current.is_none() {
if !self.queue.is_empty() {
self.play_track(0);
}
return;
}
if self.playback_error {
if let Some(index) = self.current {
self.load_track(index, true);
}
return;
}
if self.queue_ended {
self.restart_current();
return;
}
let (state, label, icon) = if self.playing {
(
gst::State::Paused,
"PAUSED",
"media-playback-start-symbolic",
)
} else {
(
gst::State::Playing,
"PLAYING",
"media-playback-pause-symbolic",
)
};
if self.playbin.set_state(state).is_ok() {
self.playing = !self.playing;
self.set_status_text(label);
self.set_play_icon(icon);
self.save_session();
}
}
fn next(&mut self) {
if self.queue.is_empty() {
return;
}
self.advance(AdvanceTrigger::UserNext);
}
fn handle_end_of_stream(&mut self) {
self.advance(AdvanceTrigger::EndOfStream);
}
fn advance(&mut self, trigger: AdvanceTrigger) {
let action = if self.shuffle {
self.shuffle_order
.advance(self.current, self.queue.len(), self.repeat_mode, trigger)
} else {
sequential_advance(self.current, self.queue.len(), self.repeat_mode, trigger)
};
self.apply_queue_action(action);
}
fn previous(&mut self) {
if self.queue.is_empty() {
return;
}
let position = self
.playbin
.query_position::<gst::ClockTime>()
.map(|time| time.seconds())
.unwrap_or_default();
let restart_current = position > 4;
let action = if self.shuffle {
self.shuffle_order
.previous(self.current, self.queue.len(), restart_current)
} else {
sequential_previous(
self.current,
self.queue.len(),
self.repeat_mode,
restart_current,
)
};
self.apply_queue_action(action);
}
fn toggle_repeat(&mut self) {
self.repeat_mode = match self.repeat_mode {
RepeatMode::Off => RepeatMode::All,
RepeatMode::All => RepeatMode::One,
RepeatMode::One => RepeatMode::Off,
};
self.sync_repeat_button();
self.save_session();
}
fn sync_repeat_button(&self) {
for button in [&self.repeat_button, &self.compact_repeat_button] {
button.remove_css_class("active");
button.remove_css_class("repeat-one");
}
match self.repeat_mode {
RepeatMode::Off => {
for button in [&self.repeat_button, &self.compact_repeat_button] {
button.set_tooltip_text(Some("Repeat: off (R)"));
}
}
RepeatMode::All => {
for button in [&self.repeat_button, &self.compact_repeat_button] {
button.add_css_class("active");
button.set_tooltip_text(Some("Repeat queue: on (R)"));
}
}
RepeatMode::One => {
for button in [&self.repeat_button, &self.compact_repeat_button] {
button.add_css_class("active");
button.add_css_class("repeat-one");
button.set_tooltip_text(Some("Repeat current track: on (R)"));
}
}
}
}
fn toggle_shuffle(&mut self) {
self.shuffle = !self.shuffle;
if self.shuffle {
self.shuffle_order.reset(self.queue.len(), self.current);
self.shuffle_button.add_css_class("active");
self.compact_shuffle_button.add_css_class("active");
self.shuffle_button
.set_tooltip_text(Some("Shuffle queue: on"));
self.compact_shuffle_button
.set_tooltip_text(Some("Shuffle queue: on"));
} else {
self.shuffle_button.remove_css_class("active");
self.compact_shuffle_button.remove_css_class("active");
self.shuffle_button
.set_tooltip_text(Some("Shuffle queue: off"));
self.compact_shuffle_button
.set_tooltip_text(Some("Shuffle queue: off"));
}
self.save_session();
}
fn apply_queue_action(&mut self, action: QueueAction) {
match action {
QueueAction::Play(index) => self.play_track(index),
QueueAction::Restart(_) => self.restart_current(),
QueueAction::Stop => {
self.playing = false;
self.queue_ended = true;
let _ = self.playbin.set_state(gst::State::Paused);
self.set_status_text("QUEUE COMPLETE");
self.set_play_icon("media-playback-start-symbolic");
self.save_session();
}
}
}
fn restart_current(&mut self) {
let Some(index) = self.current else {
return;
};
if self
.playbin
.seek_simple(
gst::SeekFlags::FLUSH | gst::SeekFlags::KEY_UNIT,
gst::ClockTime::ZERO,
)
.is_err()
{
self.load_track(index, true);
return;
}
if self.playbin.set_state(gst::State::Playing).is_ok() {
self.playing = true;
self.queue_ended = false;
self.playback_error = false;
self.set_status_text("PLAYING");
self.set_play_icon("media-playback-pause-symbolic");
} else {
self.set_error("Could not restart this track");
}
}
fn seek_relative(&mut self, delta_seconds: i64) {
let current = self
.playbin
.query_position::<gst::ClockTime>()
.map(|time| time.seconds() as i64)
.unwrap_or_default();
let duration = self
.playbin
.query_duration::<gst::ClockTime>()
.map(|time| time.seconds() as i64)
.unwrap_or(i64::MAX);
self.seek_to((current + delta_seconds).clamp(0, duration.max(0)) as u64);
}
fn seek_to(&mut self, seconds: u64) {
if self.current.is_none() {
return;
}
if self
.playbin
.seek_simple(
gst::SeekFlags::FLUSH | gst::SeekFlags::KEY_UNIT,
gst::ClockTime::from_seconds(seconds),
)
.is_ok()
{
let was_ended = self.queue_ended;
self.queue_ended = false;
self.pending_seek = None;
if was_ended {
self.set_status_text("PAUSED");
self.set_play_icon("media-playback-start-symbolic");
self.save_session();
}
}
}
fn current_cue_times(&self) -> Vec<u64> {
let Some(path) = self
.current
.and_then(|index| self.queue.get(index))
.map(|track| &track.path)
else {
return Vec::new();
};
let mut times: Vec<u64> = self
.cue_points
.iter()
.filter(|point| &point.path == path)
.map(|point| point.seconds)
.collect();
times.sort_unstable();
times.dedup();
times
}
fn toggle_cue_here(&mut self) {
let Some(path) = self
.current
.and_then(|index| self.queue.get(index))
.map(|track| track.path.clone())
else {
return;
};
let seconds = self
.playbin
.query_position::<gst::ClockTime>()
.map(|position| position.seconds())
.unwrap_or_default();
if let Some(index) = self
.cue_points
.iter()
.position(|point| point.path == path && point.seconds.abs_diff(seconds) <= 2)
{
self.cue_points.remove(index);
} else {
self.cue_points.push(CuePoint { path, seconds });
self.cue_points.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then(left.seconds.cmp(&right.seconds))
});
}
self.sync_cue_controls();
self.save_session();
}
fn clear_current_cues(&mut self) {
let Some(path) = self
.current
.and_then(|index| self.queue.get(index))
.map(|track| track.path.clone())
else {
return;
};
self.cue_points.retain(|point| point.path != path);
self.sync_cue_controls();
self.save_session();
}
fn jump_to_cue(&mut self, direction: i8) {
let times = self.current_cue_times();
let position = self
.playbin
.query_position::<gst::ClockTime>()
.map(|position| position.seconds())
.unwrap_or_default();
if let Some(target) = cue_target(×, position, direction) {
self.seek_to(target);
}
}
fn sync_cue_controls(&self) {
let has_track = self.current.is_some();
let times = self.current_cue_times();
let count = times.len();
let summary = if !has_track {
"SELECT A TRACK".to_string()
} else if times.is_empty() {
"NO SAVED POINTS FOR THIS TRACK".to_string()
} else {
let values = times
.iter()
.map(|seconds| format_time(*seconds))
.collect::<Vec<_>>()
.join(" · ");
format!("{count} SAVED / {values}")
};
let tooltip = if count == 0 {
"Saved points (B to save, [ or ] to jump)".to_string()
} else {
format!("{count} saved point(s) — B to save, [ or ] to jump")
};
for button in &self.cue_buttons {
button.set_sensitive(has_track);
button.set_tooltip_text(Some(&tooltip));
if count > 0 {
button.add_css_class("has-cues");
} else {
button.remove_css_class("has-cues");
}
}
for label in &self.cue_summaries {
label.set_text(&summary);
}
}
fn set_volume(&mut self, volume: f64) {
self.volume = volume.clamp(0.0, 1.0);
if self.volume > 0.001 {
self.last_volume = self.volume;
self.muted = false;
} else {
self.muted = true;
}
self.playbin
.set_property("volume", if self.muted { 0.0 } else { self.volume });
self.sync_volume_icon();
}
fn toggle_mute(&mut self) -> Option<f64> {
let restore_volume = if self.muted || self.volume <= 0.001 {
self.muted = false;
if self.volume <= 0.001 {
self.volume = self.last_volume.clamp(0.05, 1.0);
Some(self.volume)
} else {
None
}
} else {
self.muted = true;
None
};
self.playbin
.set_property("volume", if self.muted { 0.0 } else { self.volume });
self.sync_volume_icon();
self.save_session();
restore_volume
}
fn sync_volume_icon(&self) {
let silent = self.muted || self.volume <= 0.001;
let icon = if silent {
"audio-volume-muted-symbolic"
} else if self.volume < 0.38 {
"audio-volume-low-symbolic"
} else if self.volume < 0.72 {
"audio-volume-medium-symbolic"
} else {
"audio-volume-high-symbolic"
};
for button in [&self.volume_button, &self.compact_volume_button] {
button.set_icon_name(icon);
button.set_tooltip_text(Some(if silent { "Unmute (M)" } else { "Mute (M)" }));
if silent {
button.add_css_class("active");
} else {
button.remove_css_class("active");
}
}
}
fn toggle_widget_mode(&mut self) {
self.set_widget_mode(!self.widget_mode);
self.save_session();
}
fn set_widget_mode(&mut self, enabled: bool) {
self.widget_mode = enabled;
if enabled {
self.window.add_css_class("widget-mode");
self.view_stack.set_visible_child_name("compact");
self.window.set_resizable(false);
self.window.set_default_size(560, 180);
self.mode_button.set_label("EXPAND");
self.mode_button
.set_tooltip_text(Some("Expand to the full player (Ctrl+M)"));
} else {
self.window.remove_css_class("widget-mode");
self.view_stack.set_visible_child_name("full");
self.window.set_resizable(true);
self.window.set_default_size(980, 680);
self.mode_button.set_label("WIDGET");
self.mode_button
.set_tooltip_text(Some("Switch to compact widget mode (Ctrl+M)"));
}
self.window.queue_resize();
}
fn restore_session(&mut self) {
self.rebuild_queue();
self.sync_repeat_button();
self.sync_volume_icon();
self.sync_cue_controls();
if self.shuffle {
self.shuffle_button.add_css_class("active");
self.compact_shuffle_button.add_css_class("active");
self.shuffle_button
.set_tooltip_text(Some("Shuffle queue: on"));
self.compact_shuffle_button
.set_tooltip_text(Some("Shuffle queue: on"));
}
let Some(index) = self.current else {
return;
};
let Some(track) = self.queue.get(index).cloned() else {
self.current = None;
return;
};
let _ = self.playbin.set_state(gst::State::Ready);
self.stream_ready = false;
self.playback_error = false;
let uri = gio::File::for_path(&track.path).uri();
self.playbin.set_property("uri", uri.as_str());
let _ = self.playbin.set_state(gst::State::Paused);
self.set_identity(&track.title, &track.subtitle());
self.set_status_text(if self.queue_ended {
"QUEUE COMPLETE"
} else {
"SESSION READY"
});
if let Some(row) = self.list.row_at_index(index as i32) {
self.list.select_row(Some(&row));
}
}
fn save_session(&self) {
let Some(path) = &self.session_path else {
return;
};
let session = Session {
queue: self.queue.iter().map(|track| track.path.clone()).collect(),
current: self.current,
current_path: self
.current
.and_then(|index| self.queue.get(index))
.map(|track| track.path.clone()),
position_seconds: self
.playbin
.query_position::<gst::ClockTime>()
.map(|position| position.seconds())
.unwrap_or_default(),
repeat_mode: self.repeat_mode,
shuffle: self.shuffle,
volume: self.volume,
last_volume: self.last_volume,
muted: self.muted,
queue_ended: self.queue_ended,
widget_mode: self.widget_mode,
cue_points: self.cue_points.clone(),
};
if let Err(error) = write_session(path, &session) {
eprintln!("Ceol could not save its session: {error}");
}
}
fn sync_progress(&self) {
let Some(position) = self.playbin.query_position::<gst::ClockTime>() else {
return;
};
let duration = self
.playbin
.query_duration::<gst::ClockTime>()
.unwrap_or(gst::ClockTime::ZERO);
let position_seconds = position.seconds();
let duration_seconds = duration.seconds();
if duration_seconds > 0 {
for seek in [&self.seek, &self.compact_seek] {
seek.set_range(0.0, duration_seconds as f64);
seek.set_value(position_seconds.min(duration_seconds) as f64);
}
}
self.elapsed.set_text(&format_time(position_seconds));
self.total.set_text(&format_time(duration_seconds));
self.compact_elapsed
.set_text(&format_time(position_seconds));
self.compact_total.set_text(&format_time(duration_seconds));
}
fn update_spectrum(&self, magnitudes: &[f32]) {
if magnitudes.is_empty() {
return;
}
let mut bands = self.spectrum_bands.borrow_mut();
let target_count = bands.len().saturating_sub(1).max(1);
let source_count = magnitudes.len().saturating_sub(1);
for (index, target) in bands.iter_mut().enumerate() {
let position = index as f32 / target_count as f32;
let source = (position.powf(2.2) * source_count as f32).round() as usize;
let normalized = normalize_magnitude(magnitudes[source.min(source_count)]);
*target = *target * 0.48 + normalized * 0.52;
}
}
fn decay_spectrum(&self) {
if self.playing {
return;
}
for band in self.spectrum_bands.borrow_mut().iter_mut() {
*band *= 0.86;
}
}
fn handle_tags(&mut self, tags: &gst::TagListRef) {
let artwork = artwork_texture_from_tags(tags);
let Some(index) = self.current else {
return;
};
let Some(track) = self.queue.get_mut(index) else {
return;
};
if let Some(value) = tags.get::<gst::tags::Title>() {
let value = value.get().trim();
if !value.is_empty() {
track.title = value.to_string();
}
}
if let Some(value) = tags.get::<gst::tags::Artist>() {
let value = value.get().trim();
if !value.is_empty() {
track.artist = Some(value.to_string());
}
}
if let Some(value) = tags.get::<gst::tags::Album>() {
let value = value.get().trim();
if !value.is_empty() {
track.album = Some(value.to_string());
}
}
if let Some(value) = tags.get::<gst::tags::AudioCodec>() {
let value = value.get().trim();
if !value.is_empty() {
track.codec = Some(value.to_string());
}
}
if let Some(value) = tags
.get::<gst::tags::NominalBitrate>()
.or_else(|| tags.get::<gst::tags::Bitrate>())
{
track.bitrate = Some(value.get());
}
let title = track.title.clone();
let detail = track.subtitle();
self.set_identity(&title, &detail);
if let Some(texture) = artwork {
self.artwork.set_paintable(Some(&texture));
self.compact_artwork.set_paintable(Some(&texture));
self.artwork.set_visible(true);
self.compact_artwork.set_visible(true);
}
self.rebuild_queue();
}
fn set_error(&mut self, message: &str) {
self.playing = false;
self.playback_error = true;
self.queue_ended = false;
self.set_status_text("PLAYBACK ERROR");
self.detail.set_text(message);
self.compact_detail.set_text(message);
self.set_play_icon("media-playback-start-symbolic");
}
}
fn main() -> Result<()> {
gst::init().context("failed to initialize GStreamer")?;
let app = gtk::Application::builder()
.application_id(APP_ID)
.flags(gio::ApplicationFlags::HANDLES_OPEN)
.build();
let app_state: Rc<RefCell<Option<Rc<RefCell<MusicApp>>>>> = Rc::new(RefCell::new(None));
let activate_state = app_state.clone();
app.connect_activate(move |app| {
if let Err(error) = ensure_ui(app, &activate_state) {
eprintln!("Ceol failed to start: {error:#}");
}
});
let open_state = app_state.clone();
app.connect_open(move |app, files, _| match ensure_ui(app, &open_state) {
Ok(state) => {
let paths = files.iter().filter_map(gio::File::path).collect();
state.borrow_mut().open_tracks(paths);
}
Err(error) => eprintln!("Ceol failed to open files: {error:#}"),
});
app.run();
Ok(())
}
fn ensure_ui(
application: >k::Application,
app_state: &Rc<RefCell<Option<Rc<RefCell<MusicApp>>>>>,
) -> Result<Rc<RefCell<MusicApp>>> {
if let Some(state) = app_state.borrow().clone() {
if let Some(window) = application.active_window() {
window.present();
}
return Ok(state);
}
let state = build_ui(application)?;
app_state.borrow_mut().replace(state.clone());
Ok(state)
}
fn build_ui(application: >k::Application) -> Result<Rc<RefCell<MusicApp>>> {
install_css();
let session_path = session_path();
let session = session_path
.as_deref()
.map(read_session)
.unwrap_or_default();
let volume_value = session.volume.clamp(0.0, 1.0);
let last_volume_value = if volume_value > 0.001 {
volume_value
} else {
session.last_volume.clamp(0.05, 1.0)
};
let muted_value = session.muted || volume_value <= 0.001;
let saved_current_path = session.current_path.clone().or_else(|| {
session
.current
.and_then(|index| session.queue.get(index).cloned())
});
let queue: Vec<Track> = session
.queue
.iter()
.filter(|path| is_supported_audio(path))
.cloned()
.map(Track::from_path)
.collect();
let current = saved_current_path
.as_ref()
.and_then(|path| queue.iter().position(|track| &track.path == path));
let playbin = gst::ElementFactory::make("playbin")
.build()
.context("GStreamer playbin is not installed")?;
let fake_video = gst::ElementFactory::make("fakesink")
.build()
.context("GStreamer fakesink is not installed")?;
let spectrum = gst::ElementFactory::make("spectrum")
.property("bands", 128_u32)
.property("interval", 50_000_000_u64)
.property("threshold", -80_i32)
.property("post-messages", true)
.property("message-magnitude", true)
.property("message-phase", false)
.build()
.context("GStreamer spectrum analyzer is not installed")?;
playbin.set_property("video-sink", &fake_video);
playbin.set_property("audio-filter", &spectrum);
playbin.set_property("volume", if muted_value { 0.0 } else { volume_value });
let window = gtk::ApplicationWindow::builder()
.application(application)
.title("Ceol — KnottMusic")
.default_width(980)
.default_height(680)
.build();
window.add_css_class("app-shell");
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let (topbar, open_button, folder_button, mode_button) = build_topbar();
root.append(&topbar);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 16);
content.add_css_class("content");
content.set_vexpand(true);
let (
queue_card,
list,
queue_scroll,
empty_queue,
queue_count,
move_up_button,
move_down_button,
remove_button,
clear_button,
) = build_queue();
content.append(&queue_card);
let spectrum_bands = Rc::new(RefCell::new(vec![0.0; 56]));
let (
stage,
title,
detail,
status,
_status_row,
visualizer,
artwork,
seek,
elapsed,
total,
previous,
play_button,
next,
seek_back,
seek_forward,
shuffle_button,
repeat_button,
volume_button,
volume,
cue_menu,
) = build_stage(&spectrum_bands);
volume.set_value(volume_value);
content.append(&stage);
root.append(&content);
let compact = build_compact();
let view_stack = gtk::Stack::new();
view_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
view_stack.set_hhomogeneous(false);
view_stack.set_vhomogeneous(false);
view_stack.add_named(&root, Some("full"));
view_stack.add_named(&compact.root, Some("compact"));
window.set_child(Some(&view_stack));
let state = Rc::new(RefCell::new(MusicApp {
playbin: playbin.clone(),
bus_watch: None,
session_path,
queue,
current,
playing: false,
queue_ended: session.queue_ended,
playback_error: false,
stream_ready: false,
repeat_mode: session.repeat_mode,
shuffle: session.shuffle,
shuffle_order: ShuffleOrder::new(shuffle_seed()),
volume: volume_value,
last_volume: last_volume_value,
muted: muted_value,
volume_save_source: None,
pending_seek: (current.is_some() && session.position_seconds > 0)
.then_some(session.position_seconds),
widget_mode: false,
cue_points: session.cue_points.clone(),
window: window.clone(),
view_stack: view_stack.clone(),
mode_button: mode_button.clone(),
artwork,
compact_artwork: compact.artwork.clone(),
volume_button: volume_button.clone(),
compact_volume_button: compact.volume.clone(),
volume_control: volume.clone(),
list: list.clone(),
queue_scroll,
queue_count,
empty_queue,
title,
detail,
status,
play_button: play_button.clone(),
repeat_button: repeat_button.clone(),
shuffle_button: shuffle_button.clone(),
compact_repeat_button: compact.repeat.clone(),
compact_shuffle_button: compact.shuffle.clone(),
compact_title: compact.title.clone(),
compact_detail: compact.detail.clone(),
compact_status: compact.status.clone(),
compact_play_button: compact.play.clone(),
compact_seek: compact.seek.clone(),
compact_elapsed: compact.elapsed.clone(),
compact_total: compact.total.clone(),
cue_buttons: vec![cue_menu.button.clone(), compact.cue_menu.button.clone()],
cue_summaries: vec![cue_menu.summary.clone(), compact.cue_menu.summary.clone()],
spectrum_bands,
seek: seek.clone(),
elapsed,
total,
}));
let queue_len = state.borrow().queue.len();
state.borrow_mut().shuffle_order.reset(queue_len, current);
state.borrow_mut().restore_session();
state.borrow_mut().set_widget_mode(session.widget_mode);
connect_controls(
&window,
&open_button,
&folder_button,
&mode_button,
&compact.expand_button,
&move_up_button,
&move_down_button,
&remove_button,
&clear_button,
&list,
&previous,
&play_button,
&next,
&seek_back,
&seek_forward,
&shuffle_button,
&repeat_button,
&volume_button,
&seek,
&volume,
&compact.previous,
&compact.play,
&compact.next,
&compact.seek_back,
&compact.seek_forward,
&compact.shuffle,
&compact.repeat,
&compact.volume,
&compact.seek,
&state,
);
connect_cue_menu(&cue_menu, &state);
connect_cue_menu(&compact.cue_menu, &state);
state.borrow_mut().bus_watch = Some(connect_bus(&playbin, &state)?);
connect_updates(&visualizer, &state);
connect_shortcuts(&window, &open_button, &folder_button, &state);
connect_drop_target(&window, &state);
let pipeline = playbin.clone();
let weak_state = Rc::downgrade(&state);
window.connect_close_request(move |_| {
if let Some(state) = weak_state.upgrade() {
let mut state = state.borrow_mut();
if let Some(source) = state.volume_save_source.take() {
source.remove();
}
state.save_session();
}
let _ = pipeline.set_state(gst::State::Null);
glib::Propagation::Proceed
});
window.present();
Ok(state)
}
fn build_topbar() -> (gtk::Box, gtk::Button, gtk::Button, gtk::Button) {
let bar = gtk::Box::new(gtk::Orientation::Horizontal, 10);
bar.add_css_class("topbar");
let mark = gtk::Label::new(Some("KM"));
mark.add_css_class("brand-mark");
let identity = gtk::Box::new(gtk::Orientation::Vertical, 1);
let brand_line = gtk::Box::new(gtk::Orientation::Horizontal, 5);
let brand = gtk::Label::new(Some("KNOTT"));
brand.add_css_class("brand");
brand.set_xalign(0.0);
let accent = gtk::Label::new(Some("/ MUSIC"));
accent.add_css_class("brand");
accent.add_css_class("brand-accent");
let subtitle = gtk::Label::new(Some("LOCAL AUDIO SIGNAL CONSOLE"));
subtitle.add_css_class("brand-subtitle");
subtitle.set_xalign(0.0);
brand_line.append(&brand);
brand_line.append(&accent);
identity.append(&brand_line);
identity.append(&subtitle);
let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
let mode = gtk::Button::with_label("WIDGET");
mode.add_css_class("mode-button");
mode.set_tooltip_text(Some("Switch to compact widget mode (Ctrl+M)"));
let open = gtk::Button::new();
let open_content = gtk::Box::new(gtk::Orientation::Horizontal, 7);
open_content.append(>k::Image::from_icon_name("list-add-symbolic"));
open_content.append(>k::Label::new(Some("ADD MUSIC")));
open.set_child(Some(&open_content));
open.set_tooltip_text(Some("Add audio files (Ctrl+O)"));
open.add_css_class("add-button");
let folder = gtk::Button::new();
let folder_content = gtk::Box::new(gtk::Orientation::Horizontal, 7);
folder_content.append(>k::Image::from_icon_name("folder-open-symbolic"));
folder_content.append(>k::Label::new(Some("ADD FOLDER")));
folder.set_child(Some(&folder_content));
folder.set_tooltip_text(Some("Add a music folder recursively (Ctrl+Shift+O)"));
folder.add_css_class("folder-button");
bar.append(&mark);
bar.append(&identity);
bar.append(&spacer);
bar.append(&mode);
bar.append(&folder);
bar.append(&open);
(bar, open, folder, mode)
}
fn build_queue() -> (
gtk::Box,
gtk::ListBox,
gtk::ScrolledWindow,
gtk::Label,
gtk::Label,
gtk::Button,
gtk::Button,
gtk::Button,
gtk::Button,
) {
let card = gtk::Box::new(gtk::Orientation::Vertical, 12);
card.add_css_class("card");
card.add_css_class("queue-card");
card.set_width_request(300);
let eyebrow = gtk::Label::new(Some("YOUR SIGNAL"));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
let heading = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let title = gtk::Label::new(Some("Playback queue"));
title.add_css_class("queue-title");
title.set_xalign(0.0);
title.set_hexpand(true);
let count = gtk::Label::new(Some("00 TRACKS"));
count.add_css_class("queue-count");
heading.append(&title);
heading.append(&count);
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let move_up = icon_button("go-up-symbolic", "Move selected track up (Alt+Up)");
move_up.add_css_class("queue-action");
let move_down = icon_button("go-down-symbolic", "Move selected track down (Alt+Down)");
move_down.add_css_class("queue-action");
let remove = gtk::Button::with_label("REMOVE");
remove.add_css_class("queue-action");
remove.set_hexpand(true);
remove.set_tooltip_text(Some("Remove the selected track (Delete)"));
let clear = gtk::Button::with_label("CLEAR");
clear.add_css_class("queue-action");
clear.set_hexpand(true);
clear.set_tooltip_text(Some("Clear the playback queue"));
actions.append(&move_up);
actions.append(&move_down);
actions.append(&remove);
actions.append(&clear);
let list = gtk::ListBox::new();
list.set_selection_mode(gtk::SelectionMode::Single);
list.set_activate_on_single_click(false);
let empty = gtk::Label::new(Some("Add music to begin.\nYour queue will appear here."));
empty.add_css_class("muted");
empty.set_justify(gtk::Justification::Center);
empty.set_vexpand(true);
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&list)
.build();
card.append(&eyebrow);
card.append(&heading);
card.append(&actions);
card.append(&empty);
card.append(&scroll);
(
card, list, scroll, empty, count, move_up, move_down, remove, clear,
)
}
fn build_artwork(size: i32, class_name: &str) -> (gtk::Overlay, gtk::Picture) {
let frame = gtk::Overlay::new();
frame.add_css_class("artwork-frame");
frame.add_css_class(class_name);
frame.set_size_request(size, size);
frame.set_halign(gtk::Align::Start);
frame.set_valign(gtk::Align::Center);
let placeholder = gtk::DrawingArea::new();
placeholder.set_content_width(size);
placeholder.set_content_height(size);
placeholder.set_draw_func(|_, context, width, height| {
let width = f64::from(width);
let height = f64::from(height);
context.set_source_rgb(0.018, 0.055, 0.062);
let _ = context.paint();
context.set_source_rgba(0.0, 0.9, 1.0, 0.1);
context.set_line_width(1.0);
for ring in 1..5 {
let radius = f64::from(ring) * width.min(height) / 11.0;
context.arc(
width * 0.5,
height * 0.5,
radius,
0.0,
std::f64::consts::TAU,
);
}
let _ = context.stroke();
context.set_source_rgba(0.0, 0.92, 1.0, 0.75);
context.set_line_width(2.0);
for index in 0..7 {
let x = width * 0.33 + f64::from(index) * width * 0.055;
let amplitude = if index % 2 == 0 { 0.19 } else { 0.28 };
context.move_to(x, height * (0.5 - amplitude));
context.line_to(x, height * (0.5 + amplitude));
}
let _ = context.stroke();
context.select_font_face(
"monospace",
gtk::cairo::FontSlant::Normal,
gtk::cairo::FontWeight::Bold,
);
context.set_font_size((width * 0.055).clamp(8.0, 11.0));
context.set_source_rgba(0.45, 0.86, 0.89, 0.58);
context.move_to(width * 0.1, height - height * 0.1);
let _ = context.show_text("KNOTT / LOCAL SIGNAL");
});
frame.set_child(Some(&placeholder));
let picture = gtk::Picture::new();
picture.add_css_class("album-art");
picture.set_keep_aspect_ratio(true);
picture.set_hexpand(true);
picture.set_vexpand(true);
picture.set_can_shrink(true);
picture.set_visible(false);
frame.add_overlay(&picture);
(frame, picture)
}
fn build_cue_menu(compact: bool) -> CueMenu {
let button = gtk::MenuButton::new();
button.set_icon_name("bookmark-new-symbolic");
button.add_css_class("cue-menu-button");
button.set_sensitive(false);
button.set_tooltip_text(Some("Saved points (B to save, [ or ] to jump)"));
if compact {
button.add_css_class("compact-cue-button");
}
let popover = gtk::Popover::new();
popover.set_autohide(true);
let content = gtk::Box::new(gtk::Orientation::Vertical, 9);
content.add_css_class("cue-popover");
let eyebrow = gtk::Label::new(Some("SAVED POINTS"));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
let summary = gtk::Label::new(Some("SELECT A TRACK"));
summary.add_css_class("cue-summary");
summary.set_xalign(0.0);
summary.set_wrap(true);
summary.set_max_width_chars(34);
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let previous = icon_button("go-previous-symbolic", "Previous saved point ([)");
previous.add_css_class("cue-action");
let save = gtk::Button::with_label("SAVE / REMOVE HERE");
save.add_css_class("cue-action");
save.set_hexpand(true);
save.set_tooltip_text(Some("Toggle a point at the current time (B)"));
let next = icon_button("go-next-symbolic", "Next saved point (])");
next.add_css_class("cue-action");
actions.append(&previous);
actions.append(&save);
actions.append(&next);
let clear = gtk::Button::with_label("CLEAR THIS TRACK'S POINTS");
clear.add_css_class("cue-action");
clear.set_tooltip_text(Some("Remove every saved point for the current track"));
content.append(&eyebrow);
content.append(&summary);
content.append(&actions);
content.append(&clear);
popover.set_child(Some(&content));
button.set_popover(Some(&popover));
CueMenu {
button,
summary,
previous,
save,
next,
clear,
}
}
fn build_compact() -> CompactView {
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
root.add_css_class("compact-shell");
let top = gtk::Box::new(gtk::Orientation::Horizontal, 8);
top.add_css_class("compact-topbar");
let live = gtk::Label::new(Some("● KNOTT / NOW PLAYING"));
live.add_css_class("compact-kicker");
live.set_xalign(0.0);
live.set_hexpand(true);
let status = gtk::Label::new(Some("STANDBY"));
status.add_css_class("compact-status");
let cue_menu = build_cue_menu(true);
let expand_button = gtk::Button::with_label("EXPAND");
expand_button.add_css_class("mode-button");
expand_button.set_tooltip_text(Some("Expand the full player (Ctrl+M)"));
top.append(&live);
top.append(&cue_menu.button);
top.append(&status);
top.append(&expand_button);
let body = gtk::Box::new(gtk::Orientation::Horizontal, 12);
let (artwork_frame, artwork) = build_artwork(104, "artwork-compact");
body.append(&artwork_frame);
let center = gtk::Box::new(gtk::Orientation::Vertical, 4);
center.set_hexpand(true);
let title = gtk::Label::new(Some("No track selected"));
title.add_css_class("compact-title");
title.set_xalign(0.0);
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
let detail = gtk::Label::new(Some("Drop audio here or add local music"));
detail.add_css_class("compact-detail");
detail.set_xalign(0.0);
detail.set_ellipsize(gtk::pango::EllipsizeMode::End);
let timeline = gtk::Box::new(gtk::Orientation::Horizontal, 7);
let elapsed = gtk::Label::new(Some("0:00"));
elapsed.add_css_class("time-label");
let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
seek.set_draw_value(false);
seek.set_hexpand(true);
let total = gtk::Label::new(Some("0:00"));
total.add_css_class("muted");
timeline.append(&elapsed);
timeline.append(&seek);
timeline.append(&total);
let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
controls.add_css_class("compact-controls");
let shuffle = icon_button("media-playlist-shuffle-symbolic", "Shuffle queue");
let repeat = icon_button("media-playlist-repeat-symbolic", "Repeat mode");
let seek_back = icon_button("media-seek-backward-symbolic", "Back 10 seconds (Left)");
let previous = icon_button("media-skip-backward-symbolic", "Previous track");
let play = icon_button("media-playback-start-symbolic", "Play or pause");
play.add_css_class("compact-play-button");
let next = icon_button("media-skip-forward-symbolic", "Next track");
let seek_forward = icon_button("media-seek-forward-symbolic", "Forward 10 seconds (Right)");
let volume = icon_button("audio-volume-high-symbolic", "Mute (M)");
controls.append(&shuffle);
controls.append(&repeat);
controls.append(&seek_back);
controls.append(&previous);
controls.append(&play);
controls.append(&next);
controls.append(&seek_forward);
controls.append(&volume);
center.append(&title);
center.append(&detail);
center.append(&timeline);
center.append(&controls);
body.append(¢er);
root.append(&top);
root.append(&body);
CompactView {
root,
expand_button,
artwork,
title,
detail,
status,
seek,
elapsed,
total,
previous,
play,
next,
seek_back,
seek_forward,
shuffle,
repeat,
volume,
cue_menu,
}
}
#[allow(clippy::type_complexity)]
fn build_stage(
spectrum_bands: &Rc<RefCell<Vec<f32>>>,
) -> (
gtk::Box,
gtk::Label,
gtk::Label,
gtk::Label,
gtk::Box,
gtk::DrawingArea,
gtk::Picture,
gtk::Scale,
gtk::Label,
gtk::Label,
gtk::Button,
gtk::Button,
gtk::Button,
gtk::Button,
gtk::Button,
gtk::Button,
gtk::Button,
gtk::Button,
gtk::Scale,
CueMenu,
) {
let stage = gtk::Box::new(gtk::Orientation::Vertical, 14);
stage.add_css_class("card");
stage.add_css_class("stage");
stage.set_hexpand(true);
let status_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let eyebrow = gtk::Label::new(Some("NOW PLAYING"));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
eyebrow.set_hexpand(true);
let status = gtk::Label::new(Some("STANDBY"));
status.add_css_class("status-pill");
let cue_menu = build_cue_menu(false);
status_row.append(&eyebrow);
status_row.append(&cue_menu.button);
status_row.append(&status);
let visualizer = gtk::DrawingArea::new();
visualizer.add_css_class("cover");
visualizer.set_content_width(540);
visualizer.set_content_height(280);
visualizer.set_hexpand(true);
visualizer.set_vexpand(true);
install_visualizer(&visualizer, spectrum_bands.clone());
let (artwork_frame, artwork) = build_artwork(168, "artwork-large");
let hero = gtk::Box::new(gtk::Orientation::Horizontal, 14);
hero.add_css_class("hero");
hero.append(&artwork_frame);
hero.append(&visualizer);
let title = gtk::Label::new(Some("No track selected"));
title.add_css_class("track-title");
title.set_xalign(0.0);
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
let detail = gtk::Label::new(Some("Add local music to the queue"));
detail.add_css_class("track-detail");
detail.set_xalign(0.0);
detail.set_ellipsize(gtk::pango::EllipsizeMode::End);
let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
seek.set_draw_value(false);
seek.set_hexpand(true);
let timeline = gtk::Box::new(gtk::Orientation::Horizontal, 10);
let elapsed = gtk::Label::new(Some("0:00"));
elapsed.add_css_class("time-label");
let total = gtk::Label::new(Some("0:00"));
total.add_css_class("muted");
timeline.append(&elapsed);
timeline.append(&seek);
timeline.append(&total);
let controls = gtk::Box::new(gtk::Orientation::Horizontal, 10);
controls.add_css_class("transport");
controls.set_halign(gtk::Align::Fill);
let shuffle = icon_button("media-playlist-shuffle-symbolic", "Shuffle queue");
let repeat = icon_button("media-playlist-repeat-symbolic", "Repeat queue");
let previous = icon_button("media-skip-backward-symbolic", "Previous track");
let seek_back = icon_button("media-seek-backward-symbolic", "Back 10 seconds (Left)");
let play = icon_button("media-playback-start-symbolic", "Play or pause");
play.add_css_class("primary-button");
let next = icon_button("media-skip-forward-symbolic", "Next track");
let seek_forward = icon_button("media-seek-forward-symbolic", "Forward 10 seconds (Right)");
let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
let volume_button = icon_button("audio-volume-high-symbolic", "Mute (M)");
volume_button.add_css_class("volume-button");
let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.01);
volume.set_value(0.8);
volume.set_draw_value(false);
volume.set_width_request(120);
controls.append(&shuffle);
controls.append(&repeat);
controls.append(&seek_back);
controls.append(&previous);
controls.append(&play);
controls.append(&next);
controls.append(&seek_forward);
controls.append(&spacer);
controls.append(&volume_button);
controls.append(&volume);
stage.append(&status_row);
stage.append(&hero);
stage.append(&title);
stage.append(&detail);
stage.append(&timeline);
stage.append(&controls);
(
stage,
title,
detail,
status,
status_row,
visualizer,
artwork,
seek,
elapsed,
total,
previous,
play,
next,
seek_back,
seek_forward,
shuffle,
repeat,
volume_button,
volume,
cue_menu,
)
}
#[allow(clippy::too_many_arguments)]
fn connect_controls(
window: >k::ApplicationWindow,
open: >k::Button,
folder: >k::Button,
mode: >k::Button,
compact_mode: >k::Button,
move_up: >k::Button,
move_down: >k::Button,
remove: >k::Button,
clear: >k::Button,
list: >k::ListBox,
previous: >k::Button,
play: >k::Button,
next: >k::Button,
seek_back: >k::Button,
seek_forward: >k::Button,
shuffle: >k::Button,
repeat: >k::Button,
volume_button: >k::Button,
seek: >k::Scale,
volume: >k::Scale,
compact_previous: >k::Button,
compact_play: >k::Button,
compact_next: >k::Button,
compact_seek_back: >k::Button,
compact_seek_forward: >k::Button,
compact_shuffle: >k::Button,
compact_repeat: >k::Button,
compact_volume: >k::Button,
compact_seek: >k::Scale,
state: &Rc<RefCell<MusicApp>>,
) {
let weak_state = Rc::downgrade(state);
let open_window = window.clone();
open.connect_clicked(move |_| {
let dialog = gtk::FileChooserNative::builder()
.title("Add music")
.transient_for(&open_window)
.action(gtk::FileChooserAction::Open)
.accept_label("Add to queue")
.cancel_label("Cancel")
.build();
dialog.set_select_multiple(true);
let filter = gtk::FileFilter::new();
filter.set_name(Some("Audio files"));
for mime in [
"audio/mpeg",
"audio/flac",
"audio/ogg",
"audio/opus",
"audio/x-wav",
"audio/mp4",
"audio/aac",
] {
filter.add_mime_type(mime);
}
dialog.add_filter(&filter);
let weak_state = weak_state.clone();
dialog.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept {
let files = dialog.files();
let paths = (0..files.n_items())
.filter_map(|index| files.item(index))
.filter_map(|item| item.downcast::<gio::File>().ok())
.filter_map(|file| file.path())
.collect();
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().add_tracks(paths);
}
}
});
dialog.show();
});
let weak_state = Rc::downgrade(state);
let folder_window = window.clone();
folder.connect_clicked(move |_| {
let dialog = gtk::FileChooserNative::builder()
.title("Add music folders")
.transient_for(&folder_window)
.action(gtk::FileChooserAction::SelectFolder)
.accept_label("Add to queue")
.cancel_label("Cancel")
.build();
dialog.set_select_multiple(true);
let weak_state = weak_state.clone();
dialog.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept {
let files = dialog.files();
let paths = (0..files.n_items())
.filter_map(|index| files.item(index))
.filter_map(|item| item.downcast::<gio::File>().ok())
.filter_map(|file| file.path())
.collect();
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().add_tracks(paths);
}
}
});
dialog.show();
});
let weak_state = Rc::downgrade(state);
mode.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_widget_mode();
}
});
let weak_state = Rc::downgrade(state);
compact_mode.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_widget_mode();
}
});
let weak_state = Rc::downgrade(state);
move_up.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().move_selected(-1);
}
});
let weak_state = Rc::downgrade(state);
move_down.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().move_selected(1);
}
});
let weak_state = Rc::downgrade(state);
remove.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().remove_selected();
}
});
let weak_state = Rc::downgrade(state);
clear.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().clear_queue();
}
});
let weak_state = Rc::downgrade(state);
list.connect_row_activated(move |_, row| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().play_track(row.index() as usize);
}
});
let weak_state = Rc::downgrade(state);
previous.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().previous();
}
});
let weak_state = Rc::downgrade(state);
play.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_playback();
}
});
let weak_state = Rc::downgrade(state);
next.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().next();
}
});
let weak_state = Rc::downgrade(state);
seek_back.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_relative(-10);
}
});
let weak_state = Rc::downgrade(state);
seek_forward.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_relative(10);
}
});
let weak_state = Rc::downgrade(state);
shuffle.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_shuffle();
}
});
let weak_state = Rc::downgrade(state);
repeat.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_repeat();
}
});
let weak_state = Rc::downgrade(state);
volume_button.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
toggle_mute_for_state(&state);
}
});
let weak_state = Rc::downgrade(state);
seek.connect_change_value(move |_, _, seconds| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_to(seconds.max(0.0) as u64);
}
glib::Propagation::Proceed
});
let weak_state = Rc::downgrade(state);
volume.connect_value_changed(move |scale| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().set_volume(scale.value());
queue_volume_save(&state);
}
});
let weak_state = Rc::downgrade(state);
compact_previous.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().previous();
}
});
let weak_state = Rc::downgrade(state);
compact_play.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_playback();
}
});
let weak_state = Rc::downgrade(state);
compact_next.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().next();
}
});
let weak_state = Rc::downgrade(state);
compact_seek_back.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_relative(-10);
}
});
let weak_state = Rc::downgrade(state);
compact_seek_forward.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_relative(10);
}
});
let weak_state = Rc::downgrade(state);
compact_shuffle.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_shuffle();
}
});
let weak_state = Rc::downgrade(state);
compact_repeat.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_repeat();
}
});
let weak_state = Rc::downgrade(state);
compact_volume.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
toggle_mute_for_state(&state);
}
});
let weak_state = Rc::downgrade(state);
compact_seek.connect_change_value(move |_, _, seconds| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_to(seconds.max(0.0) as u64);
}
glib::Propagation::Proceed
});
}
fn connect_cue_menu(menu: &CueMenu, state: &Rc<RefCell<MusicApp>>) {
let weak_state = Rc::downgrade(state);
menu.previous.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().jump_to_cue(-1);
}
});
let weak_state = Rc::downgrade(state);
menu.save.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_cue_here();
}
});
let weak_state = Rc::downgrade(state);
menu.next.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().jump_to_cue(1);
}
});
let weak_state = Rc::downgrade(state);
menu.clear.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().clear_current_cues();
}
});
}
fn connect_bus(
playbin: &gst::Element,
state: &Rc<RefCell<MusicApp>>,
) -> Result<gst::bus::BusWatchGuard> {
let bus = playbin.bus().context("playbin has no message bus")?;
let weak_state = Rc::downgrade(state);
let guard = bus.add_watch_local(move |_, message| {
let Some(state) = weak_state.upgrade() else {
return glib::ControlFlow::Break;
};
match message.view() {
gst::MessageView::StreamStart(..) => state.borrow_mut().stream_ready = true,
gst::MessageView::Eos(..) => {
if state.borrow().stream_ready {
state.borrow_mut().handle_end_of_stream();
}
}
gst::MessageView::Error(error) => {
let message = format!("{}", error.error());
state.borrow_mut().set_error(&message);
}
gst::MessageView::Tag(tag) => {
if state.borrow().stream_ready {
state.borrow_mut().handle_tags(&tag.tags());
}
}
gst::MessageView::AsyncDone(..) => {
let mut state = state.borrow_mut();
if let Some(seconds) = state.pending_seek.take() {
let _ = state.playbin.seek_simple(
gst::SeekFlags::FLUSH | gst::SeekFlags::KEY_UNIT,
gst::ClockTime::from_seconds(seconds),
);
}
}
gst::MessageView::Buffering(buffering) => {
let state = state.borrow();
let percent = buffering.percent();
if percent < 100 {
state.set_status_text(&format!("BUFFERING {percent}%"));
} else if state.playing {
state.set_status_text("PLAYING");
} else if state.current.is_some() {
state.set_status_text("PAUSED");
} else {
state.set_status_text("STANDBY");
}
}
gst::MessageView::Element(element) => {
if let Some(structure) = element.structure()
&& structure.name() == "spectrum"
&& let Ok(magnitudes) = structure.get::<gst::List>("magnitude")
{
let magnitudes: Vec<f32> = magnitudes
.as_slice()
.iter()
.filter_map(|value| value.get::<f32>().ok())
.collect();
state.borrow().update_spectrum(&magnitudes);
}
}
_ => {}
}
glib::ControlFlow::Continue
})?;
Ok(guard)
}
fn queue_volume_save(state: &Rc<RefCell<MusicApp>>) {
if let Some(source) = state.borrow_mut().volume_save_source.take() {
source.remove();
}
let weak_state = Rc::downgrade(state);
let source = glib::timeout_add_local_once(Duration::from_millis(300), move || {
if let Some(state) = weak_state.upgrade() {
let mut state = state.borrow_mut();
state.volume_save_source = None;
state.save_session();
}
});
state.borrow_mut().volume_save_source = Some(source);
}
fn toggle_mute_for_state(state: &Rc<RefCell<MusicApp>>) {
let restore_volume = state.borrow_mut().toggle_mute();
if let Some(volume) = restore_volume {
let control = state.borrow().volume_control.clone();
control.set_value(volume);
}
}
fn connect_updates(visualizer: >k::DrawingArea, state: &Rc<RefCell<MusicApp>>) {
let weak_state = Rc::downgrade(state);
let visualizer = visualizer.clone();
glib::timeout_add_local(Duration::from_millis(50), move || {
let Some(state) = weak_state.upgrade() else {
return glib::ControlFlow::Break;
};
let state = state.borrow();
state.sync_progress();
state.decay_spectrum();
visualizer.queue_draw();
glib::ControlFlow::Continue
});
}
fn connect_shortcuts(
window: >k::ApplicationWindow,
open: >k::Button,
folder: >k::Button,
state: &Rc<RefCell<MusicApp>>,
) {
let controller = gtk::EventControllerKey::new();
let weak_state = Rc::downgrade(state);
let open = open.clone();
let folder = folder.clone();
let shortcut_window = window.clone();
controller.connect_key_pressed(move |_, key, _, modifiers| {
let focused_control =
gtk::prelude::GtkWindowExt::focus(&shortcut_window).is_some_and(|widget| {
widget.is::<gtk::Button>()
|| widget.is::<gtk::Scale>()
|| widget.is::<gtk::ListBox>()
|| widget.is::<gtk::ListBoxRow>()
|| widget.ancestor(gtk::ListBox::static_type()).is_some()
});
if key == gdk::Key::space {
if focused_control {
return glib::Propagation::Proceed;
}
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_playback();
}
return glib::Propagation::Stop;
}
if key == gdk::Key::Delete
&& !modifiers.intersects(gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::ALT_MASK)
{
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().remove_selected();
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::ALT_MASK) {
match key {
gdk::Key::Up => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().move_selected(-1);
}
}
gdk::Key::Down => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().move_selected(1);
}
}
_ => return glib::Propagation::Proceed,
}
return glib::Propagation::Stop;
}
if !modifiers.intersects(
gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SUPER_MASK,
) && !focused_control
{
match key {
gdk::Key::Left => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_relative(-10);
}
}
gdk::Key::Right => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_relative(10);
}
}
gdk::Key::m | gdk::Key::M => {
if let Some(state) = weak_state.upgrade() {
toggle_mute_for_state(&state);
}
}
gdk::Key::r | gdk::Key::R => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_repeat();
}
}
gdk::Key::b | gdk::Key::B => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_cue_here();
}
}
gdk::Key::bracketleft => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().jump_to_cue(-1);
}
}
gdk::Key::bracketright => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().jump_to_cue(1);
}
}
gdk::Key::Up => {
if let Some(state) = weak_state.upgrade() {
let value = (state.borrow().volume + 0.05).min(1.0);
let control = state.borrow().volume_control.clone();
control.set_value(value);
}
}
gdk::Key::Down => {
if let Some(state) = weak_state.upgrade() {
let value = (state.borrow().volume - 0.05).max(0.0);
let control = state.borrow().volume_control.clone();
control.set_value(value);
}
}
_ => return glib::Propagation::Proceed,
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK) {
match key {
gdk::Key::o | gdk::Key::O if modifiers.contains(gdk::ModifierType::SHIFT_MASK) => {
folder.emit_clicked()
}
gdk::Key::o | gdk::Key::O => open.emit_clicked(),
gdk::Key::Right => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().next();
}
}
gdk::Key::Left => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().previous();
}
}
gdk::Key::m | gdk::Key::M => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_widget_mode();
}
}
gdk::Key::s | gdk::Key::S => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_shuffle();
}
}
gdk::Key::b | gdk::Key::B => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_cue_here();
}
}
gdk::Key::Delete => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().clear_queue();
}
}
_ => return glib::Propagation::Proceed,
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
window.add_controller(controller);
}
fn connect_drop_target(window: >k::ApplicationWindow, state: &Rc<RefCell<MusicApp>>) {
let target = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::COPY);
let drop_window = window.clone();
target.connect_enter(move |_, _, _| {
drop_window.add_css_class("drop-active");
gdk::DragAction::COPY
});
let drop_window = window.clone();
target.connect_leave(move |_| drop_window.remove_css_class("drop-active"));
let weak_state = Rc::downgrade(state);
let drop_window = window.clone();
target.connect_drop(move |_, value, _, _| {
drop_window.remove_css_class("drop-active");
let Ok(files) = value.get::<gdk::FileList>() else {
return false;
};
let paths: Vec<PathBuf> = files
.files()
.into_iter()
.filter_map(|file| file.path())
.collect();
if paths.is_empty() {
return false;
}
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().add_tracks(paths);
true
} else {
false
}
});
window.add_controller(target);
}
fn install_visualizer(area: >k::DrawingArea, spectrum_bands: Rc<RefCell<Vec<f32>>>) {
let started = Instant::now();
area.set_draw_func(move |_, context, width, height| {
let width = f64::from(width);
let height = f64::from(height);
context.set_source_rgb(0.005, 0.02, 0.027);
let _ = context.paint();
context.set_line_width(1.0);
context.set_source_rgba(0.0, 0.75, 0.85, 0.08);
for column in 1..12 {
let x = f64::from(column) * width / 12.0;
context.move_to(x, 0.0);
context.line_to(x, height);
}
for row in 1..6 {
let y = f64::from(row) * height / 6.0;
context.move_to(0.0, y);
context.line_to(width, y);
}
let _ = context.stroke();
context.select_font_face(
"monospace",
gtk::cairo::FontSlant::Normal,
gtk::cairo::FontWeight::Bold,
);
context.set_font_size(10.0);
context.set_source_rgba(0.28, 0.72, 0.78, 0.7);
context.move_to(18.0, 24.0);
let _ = context.show_text("KNOTT AUDIO / LIVE SPECTRUM");
context.move_to(width - 112.0, 24.0);
let _ = context.show_text("FFT / 56 BAND");
context.set_source_rgba(0.0, 0.9, 1.0, 0.45);
context.set_line_width(2.0);
for (x, y, dx, dy) in [
(12.0, 12.0, 20.0, 0.0),
(12.0, 12.0, 0.0, 20.0),
(width - 12.0, 12.0, -20.0, 0.0),
(width - 12.0, 12.0, 0.0, 20.0),
(12.0, height - 12.0, 20.0, 0.0),
(12.0, height - 12.0, 0.0, -20.0),
(width - 12.0, height - 12.0, -20.0, 0.0),
(width - 12.0, height - 12.0, 0.0, -20.0),
] {
context.move_to(x, y);
context.line_to(x + dx, y + dy);
}
let _ = context.stroke();
context.set_source_rgba(0.0, 0.9, 1.0, 0.14);
context.set_line_width(1.0);
context.move_to(18.0, height * 0.55);
context.line_to(width - 18.0, height * 0.55);
let _ = context.stroke();
let time = started.elapsed().as_secs_f64();
let bands = spectrum_bands.borrow();
let bars = bands.len().max(1);
let energy = bands.iter().copied().sum::<f32>() / bars as f32;
let gap = 3.0;
let bar_width = ((width - 48.0) / bars as f64 - gap).max(1.5);
for (index, magnitude) in bands.iter().copied().enumerate() {
let level = f64::from(magnitude.clamp(0.0, 1.0));
let bar_height = 3.0 + level * height * 0.47;
let x = 24.0 + index as f64 * (bar_width + gap);
let y = height * 0.55 - bar_height;
context.set_source_rgba(0.0, 0.86, 0.95, 0.26 + level * 0.72);
context.rectangle(x, y, bar_width, bar_height);
let _ = context.fill();
context.set_source_rgba(0.0, 0.55, 0.62, 0.12 + level * 0.28);
context.rectangle(x, height * 0.55 + 3.0, bar_width, bar_height * 0.35);
let _ = context.fill();
}
context.set_source_rgba(0.48, 0.97, 1.0, 0.92);
context.set_line_width(1.5);
context.move_to(20.0, height * 0.83);
for point in 0..140 {
let x = 20.0 + f64::from(point) * (width - 40.0) / 139.0;
let mixed = (time * 3.0 + f64::from(point) * 0.31).sin()
+ (time * 1.7 - f64::from(point) * 0.17).cos() * 0.45;
let y = height * 0.83 + mixed * height * 0.018 * (0.15 + f64::from(energy));
context.line_to(x, y);
}
let _ = context.stroke();
context.set_font_size(9.0);
context.set_source_rgba(0.25, 0.62, 0.68, 0.65);
for (label, x) in [
("60", 22.0),
("250", width * 0.28),
("1K", width * 0.53),
("8K", width - 54.0),
] {
context.move_to(x, height - 18.0);
let _ = context.show_text(label);
}
});
}
fn install_css() {
let provider = gtk::CssProvider::new();
provider.load_from_data(CSS);
if let Some(display) = gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}
fn icon_button(icon: &str, tooltip: &str) -> gtk::Button {
gtk::Button::builder()
.icon_name(icon)
.tooltip_text(tooltip)
.build()
}
fn artwork_texture_from_tags(tags: &gst::TagListRef) -> Option<gdk::Texture> {
let sample = tags
.get::<gst::tags::Image>()
.or_else(|| tags.get::<gst::tags::PreviewImage>())?
.get();
let buffer = sample.buffer()?;
let map = buffer.map_readable().ok()?;
let bytes = glib::Bytes::from(map.as_slice());
gdk::Texture::from_bytes(&bytes).ok()
}
fn format_time(seconds: u64) -> String {
let minutes = seconds / 60;
let seconds = seconds % 60;
format!("{minutes}:{seconds:02}")
}
fn cue_target(times: &[u64], position: u64, direction: i8) -> Option<u64> {
if direction < 0 {
times
.iter()
.rev()
.copied()
.find(|seconds| seconds.saturating_add(1) < position)
.or_else(|| times.last().copied())
} else {
times
.iter()
.copied()
.find(|seconds| *seconds > position.saturating_add(1))
.or_else(|| times.first().copied())
}
}
fn normalize_magnitude(decibels: f32) -> f32 {
((decibels + 80.0) / 80.0).clamp(0.0, 1.0)
}
fn shuffle_seed() -> u64 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
nanos ^ u64::from(std::process::id()).rotate_left(21)
}
fn parent_name(path: &Path) -> String {
path.parent()
.and_then(Path::file_name)
.and_then(|value| value.to_str())
.unwrap_or("LOCAL FILE")
.to_uppercase()
}
fn is_supported_audio(path: &Path) -> bool {
path.is_file() && has_supported_audio_extension(path)
}
fn expand_audio_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut pending: Vec<PathBuf> = paths.into_iter().rev().collect();
let mut visited_directories = HashSet::new();
let mut tracks = Vec::new();
while let Some(path) = pending.pop() {
if is_supported_audio(&path) {
tracks.push(fs::canonicalize(&path).unwrap_or(path));
continue;
}
if !path.is_dir() {
continue;
}
let directory = fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if !visited_directories.insert(directory) {
continue;
}
let Ok(entries) = fs::read_dir(path) else {
continue;
};
let mut children: Vec<PathBuf> = entries
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.collect();
children.sort_by_cached_key(|child| {
child
.file_name()
.map(|name| name.to_string_lossy().to_lowercase())
.unwrap_or_default()
});
pending.extend(children.into_iter().rev());
}
tracks
}
fn has_supported_audio_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
matches!(
extension.to_ascii_lowercase().as_str(),
"mp3" | "flac" | "ogg" | "oga" | "opus" | "wav" | "m4a" | "aac"
)
})
}
fn session_path() -> Option<PathBuf> {
let base = env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))?;
Some(base.join("ceol/session.json"))
}
fn read_session(path: &Path) -> Session {
fs::read(path)
.ok()
.and_then(|data| serde_json::from_slice(&data).ok())
.unwrap_or_default()
}
fn write_session(path: &Path, session: &Session) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(session).map_err(std::io::Error::other)?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, data)?;
fs::rename(temporary, path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_track_time() {
assert_eq!(format_time(0), "0:00");
assert_eq!(format_time(65), "1:05");
assert_eq!(format_time(3_661), "61:01");
}
#[test]
fn creates_readable_track_names() {
let track = Track::from_path(PathBuf::from("/music/neon_nights-live.flac"));
assert_eq!(track.title, "neon nights live");
assert_eq!(track.detail, "FLAC");
}
#[test]
fn session_json_round_trips() {
let session = Session {
queue: vec![PathBuf::from("/music/test.flac")],
current: Some(0),
current_path: Some(PathBuf::from("/music/test.flac")),
position_seconds: 47,
repeat_mode: RepeatMode::All,
shuffle: true,
volume: 0.42,
last_volume: 0.58,
muted: true,
queue_ended: true,
widget_mode: true,
cue_points: vec![CuePoint {
path: PathBuf::from("/music/test.flac"),
seconds: 73,
}],
};
let json = serde_json::to_vec(&session).expect("session should serialize");
let restored: Session = serde_json::from_slice(&json).expect("session should deserialize");
assert_eq!(restored.queue, session.queue);
assert_eq!(restored.current, session.current);
assert_eq!(restored.current_path, session.current_path);
assert_eq!(restored.position_seconds, 47);
assert_eq!(restored.repeat_mode, RepeatMode::All);
assert!(restored.shuffle);
assert_eq!(restored.volume, session.volume);
assert_eq!(restored.last_volume, session.last_volume);
assert!(restored.muted);
assert!(restored.queue_ended);
assert!(restored.widget_mode);
assert_eq!(restored.cue_points, session.cue_points);
}
#[test]
fn recognizes_supported_audio_extensions() {
assert!(has_supported_audio_extension(Path::new("track.FLAC")));
assert!(has_supported_audio_extension(Path::new("track.m4a")));
assert!(!has_supported_audio_extension(Path::new("cover.jpg")));
}
#[test]
fn expands_music_folders_recursively_in_stable_order() {
let root = env::temp_dir().join(format!(
"ceol-folder-test-{}-{}",
std::process::id(),
shuffle_seed()
));
let nested = root.join("B-side");
fs::create_dir_all(&nested).expect("test music folder should be created");
fs::write(root.join("01 opening.MP3"), []).expect("test track should be created");
fs::write(root.join("cover.jpg"), []).expect("test cover should be created");
fs::write(nested.join("02 closing.flac"), []).expect("nested track should be created");
let tracks = expand_audio_paths(vec![root.clone()]);
let names: Vec<_> = tracks
.iter()
.filter_map(|path| path.file_name().and_then(|name| name.to_str()))
.collect();
assert_eq!(names, ["01 opening.MP3", "02 closing.flac"]);
fs::remove_dir_all(root).expect("test music folder should be removed");
}
#[test]
fn normalizes_spectrum_magnitudes() {
assert_eq!(normalize_magnitude(-100.0), 0.0);
assert_eq!(normalize_magnitude(-80.0), 0.0);
assert_eq!(normalize_magnitude(-40.0), 0.5);
assert_eq!(normalize_magnitude(0.0), 1.0);
}
#[test]
fn saved_point_navigation_moves_and_wraps() {
let points = [10, 40, 90];
assert_eq!(cue_target(&points, 20, 1), Some(40));
assert_eq!(cue_target(&points, 90, 1), Some(10));
assert_eq!(cue_target(&points, 70, -1), Some(40));
assert_eq!(cue_target(&points, 10, -1), Some(90));
assert_eq!(cue_target(&[], 10, 1), None);
}
}