#![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";
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
enum Theme {
#[default]
Signal,
Ember,
Ultraviolet,
Daylight,
}
impl Theme {
const ALL: [Self; 4] = [Self::Signal, Self::Ember, Self::Ultraviolet, Self::Daylight];
fn label(self) -> &'static str {
match self {
Self::Signal => "SIGNAL",
Self::Ember => "EMBER",
Self::Ultraviolet => "ULTRAVIOLET",
Self::Daylight => "DAYLIGHT",
}
}
fn description(self) -> &'static str {
match self {
Self::Signal => "Electric cyan / deep ocean",
Self::Ember => "Warm gold / smoked copper",
Self::Ultraviolet => "Violet bloom / midnight ink",
Self::Daylight => "Clear sky / soft mineral",
}
}
fn summary(self) -> &'static str {
match self {
Self::Signal => "Electric cyan",
Self::Ember => "Warm gold",
Self::Ultraviolet => "Violet bloom",
Self::Daylight => "Clear sky",
}
}
fn css_class(self) -> &'static str {
match self {
Self::Signal => "theme-signal",
Self::Ember => "theme-ember",
Self::Ultraviolet => "theme-ultraviolet",
Self::Daylight => "theme-daylight",
}
}
fn option_css_class(self) -> &'static str {
match self {
Self::Signal => "theme-option-signal",
Self::Ember => "theme-option-ember",
Self::Ultraviolet => "theme-option-ultraviolet",
Self::Daylight => "theme-option-daylight",
}
}
fn next(self) -> Self {
match self {
Self::Signal => Self::Ember,
Self::Ember => Self::Ultraviolet,
Self::Ultraviolet => Self::Daylight,
Self::Daylight => Self::Signal,
}
}
fn palette(self) -> VisualPalette {
match self {
Self::Signal => VisualPalette {
background: [0.005, 0.020, 0.027],
surface: [0.020, 0.070, 0.082],
accent: [0.000, 0.900, 1.000],
accent_2: [0.400, 0.960, 1.000],
muted: [0.280, 0.720, 0.780],
},
Self::Ember => VisualPalette {
background: [0.032, 0.014, 0.010],
surface: [0.110, 0.050, 0.025],
accent: [1.000, 0.520, 0.160],
accent_2: [1.000, 0.820, 0.380],
muted: [0.820, 0.560, 0.390],
},
Self::Ultraviolet => VisualPalette {
background: [0.018, 0.010, 0.045],
surface: [0.070, 0.035, 0.145],
accent: [0.680, 0.390, 1.000],
accent_2: [1.000, 0.430, 0.820],
muted: [0.640, 0.510, 0.820],
},
Self::Daylight => VisualPalette {
background: [0.900, 0.935, 0.945],
surface: [0.790, 0.870, 0.890],
accent: [0.000, 0.470, 0.610],
accent_2: [0.020, 0.650, 0.720],
muted: [0.240, 0.450, 0.500],
},
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
enum MotionMode {
Still,
Calm,
#[default]
Alive,
}
impl MotionMode {
fn label(self) -> &'static str {
match self {
Self::Still => "STILL",
Self::Calm => "CALM",
Self::Alive => "ALIVE",
}
}
fn description(self) -> &'static str {
match self {
Self::Still => "Audio-reactive bars without ambient movement",
Self::Calm => "Gentle drift and a softer palette transition",
Self::Alive => "Full spectrum drift, shimmer, and eased transitions",
}
}
fn next(self) -> Self {
match self {
Self::Still => Self::Calm,
Self::Calm => Self::Alive,
Self::Alive => Self::Still,
}
}
fn transition_seconds(self) -> f64 {
match self {
Self::Still => 0.0,
Self::Calm => 0.14,
Self::Alive => 0.22,
}
}
}
#[derive(Clone, Copy)]
struct VisualPalette {
background: [f64; 3],
surface: [f64; 3],
accent: [f64; 3],
accent_2: [f64; 3],
muted: [f64; 3],
}
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(100deg, #081a20, #03090c 72%);
border-bottom: 1px solid #16414b;
box-shadow: 0 8px 28px rgba(0, 0, 0, .28);
padding: 14px 18px;
}
.brand-mark {
color: #001014;
background: #00e5ff;
border-radius: 9px;
font-family: monospace;
font-size: 13px;
font-weight: 900;
padding: 9px 10px;
box-shadow: 0 0 22px rgba(0, 229, 255, .24);
}
.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:
radial-gradient(circle at 78% 8%, rgba(0, 216, 239, .10), transparent 36%),
radial-gradient(circle at 12% 88%, rgba(0, 115, 135, .08), transparent 32%),
linear-gradient(135deg, #020608, #061014);
padding: 18px;
}
.card {
background: rgba(7, 18, 22, .96);
border: 1px solid #163740;
border-radius: 15px;
box-shadow: 0 14px 38px rgba(0, 0, 0, .38);
}
.queue-card {
border-top: 3px solid #16414b;
padding: 17px;
}
.queue-title {
color: #e8fcff;
font-size: 17px;
font-weight: 850;
}
.queue-count {
color: #00d8ef;
background: #061b21;
border: 1px solid #16414b;
border-radius: 99px;
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;
}
.queue-search {
color: #dffcff;
background: #030b0e;
border: 1px solid #12313a;
border-radius: 10px;
caret-color: #00d8ef;
min-height: 34px;
padding: 0 9px;
}
.queue-search:focus {
border-color: #00d8ef;
box-shadow: inset 0 0 0 1px #00d8ef, 0 4px 14px rgba(0, 216, 239, .08);
}
.queue-empty {
color: #668e96;
font-size: 11px;
padding: 18px 10px;
}
.drop-active .card {
border-color: #00e5ff;
}
.queue-row {
color: #dffcff;
background: #050d10;
border-radius: 9px;
padding: 11px 10px;
margin: 3px 2px;
}
.queue-row:hover {
background: #0b2027;
}
.queue-row:selected {
color: #ffffff;
background: #092a32;
box-shadow: inset 3px 0 #00e5ff, 0 5px 16px rgba(0, 0, 0, .2);
}
.queue-row.is-playing {
background: #071c22;
box-shadow: inset 3px 0 #00e5ff;
}
.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: 11px;
}
.queue-index {
color: #4f7b84;
font-family: monospace;
font-size: 10px;
}
.stage {
border-top: 3px solid #00d8ef;
padding: 24px;
}
.cover {
background: #020608;
border: 1px solid #1a4a55;
border-radius: 13px;
box-shadow: inset 0 0 42px rgba(0, 229, 255, .035);
}
.hero {
min-height: 250px;
}
.artwork-frame {
background: #041014;
border: 1px solid #1a4a55;
border-radius: 14px;
box-shadow: 0 15px 34px rgba(0, 0, 0, .44), 0 0 24px rgba(0, 229, 255, .07);
}
.album-art {
border-radius: 13px;
}
.track-title {
color: #f2feff;
font-size: 31px;
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: 13px;
padding: 9px 11px;
}
button {
color: #dffcff;
background: #091a20;
border: 1px solid #1a4651;
border-radius: 10px;
min-height: 38px;
min-width: 38px;
padding: 4px 12px;
box-shadow: none;
}
button:hover {
color: #ffffff;
background: #0e2b34;
border-color: #00b8cc;
box-shadow: 0 5px 16px rgba(0, 0, 0, .22);
}
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: 14px;
box-shadow: 0 7px 24px rgba(0, 216, 239, .20);
}
.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;
}
.config-shell {
background-image:
radial-gradient(circle at 82% 12%, rgba(0, 216, 239, .09), transparent 32%),
linear-gradient(145deg, #020608, #061014 62%, #020608);
}
.config-topbar {
background: #030b0e;
border-bottom: 1px solid #16414b;
box-shadow: 0 8px 28px rgba(0, 0, 0, .24);
padding: 14px 18px;
}
.config-content {
padding: 10px 20px 8px;
}
.config-page-title {
color: #f2feff;
font-size: 28px;
font-weight: 900;
letter-spacing: -1px;
}
.config-page-detail {
color: #6f9aa2;
font-family: monospace;
font-size: 10px;
letter-spacing: 1px;
}
.config-card {
background: rgba(7, 18, 22, .96);
border: 1px solid #163740;
border-top: 3px solid #16414b;
border-radius: 15px;
padding: 14px;
box-shadow: 0 14px 36px rgba(0, 0, 0, .28);
min-width: 360px;
}
.config-card.playlist-card {
border-top-color: #00d8ef;
}
.config-section-title {
color: #e8fcff;
font-size: 19px;
font-weight: 850;
}
.config-description {
color: #759da5;
font-size: 11px;
}
.config-list-frame {
background: #030b0e;
border: 1px solid #12313a;
border-radius: 11px;
padding: 4px;
}
.config-list-frame list,
.config-list-frame row {
color: #e8fcff;
background: #030b0e;
}
.config-row {
background: transparent;
border-bottom: 1px solid #102c33;
border-radius: 8px;
margin: 2px;
padding: 10px;
}
.config-row:hover {
background: #0a2026;
}
.config-row:selected {
background: #0b2b33;
box-shadow: inset 3px 0 #00d8ef, 0 5px 14px rgba(0, 0, 0, .18);
}
.config-row-icon {
color: #61eaf5;
}
.config-row-title {
color: #effdff;
font-size: 13px;
font-weight: 800;
}
.config-row-detail {
color: #668e96;
font-family: monospace;
font-size: 9px;
}
.config-tag {
color: #7ff5ff;
background: #072129;
border: 1px solid #16505d;
border-radius: 99px;
font-family: monospace;
font-size: 8px;
font-weight: 900;
padding: 3px 6px;
}
.config-empty {
color: #668e96;
padding: 30px 14px;
}
.config-actions button, .config-save-row button {
font-family: monospace;
font-size: 9px;
font-weight: 850;
letter-spacing: 1px;
}
.config-primary {
background: #00d8ef;
border-color: #65f3ff;
color: #001014;
}
.config-shell button:disabled {
color: #4e7076;
background: #07171b;
border-color: #16343b;
}
.queue-card button:disabled {
color: #3f646b;
background: #061014;
border-color: #102b32;
}
.config-danger {
color: #ff9aa7;
border-color: #63333a;
}
.config-notice {
color: #72f4ff;
background: #061b21;
border: 1px solid #16414b;
border-radius: 10px;
font-family: monospace;
font-size: 9px;
font-weight: 800;
letter-spacing: 1px;
padding: 8px 12px;
}
.config-shell entry {
color: #e8fcff;
background: #030b0e;
border: 1px solid #1a4651;
border-radius: 10px;
min-height: 38px;
padding: 0 10px;
}
.config-shell entry:focus {
border-color: #00d8ef;
box-shadow: inset 0 0 0 1px #00d8ef;
}
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: 99px;
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;
}
.queue-peek-popover {
background: #071216;
border: 1px solid #16414b;
border-radius: 13px;
min-width: 310px;
padding: 12px;
}
.queue-peek-list {
background: transparent;
}
.queue-peek-row {
color: #dffcff;
background: #050d10;
border-radius: 9px;
margin: 2px 0;
padding: 8px;
}
.queue-peek-row:hover {
background: #0b2027;
}
.queue-peek-row.is-playing {
background: #071c22;
box-shadow: inset 3px 0 #00e5ff;
}
.queue-peek-index {
color: #55c8d5;
font-family: monospace;
font-size: 9px;
font-weight: 900;
min-width: 32px;
}
.queue-peek-title {
font-size: 11px;
font-weight: 800;
}
.queue-peek-summary {
color: #78a0a8;
font-family: monospace;
font-size: 9px;
}
.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;
border-radius: 15px;
box-shadow: inset 0 1px rgba(255, 255, 255, .03), 0 18px 42px rgba(0, 0, 0, .44);
padding: 10px 12px 11px;
}
.compact-topbar {
border-bottom: 1px solid #12313a;
padding-bottom: 6px;
}
.compact-topbar button {
border-radius: 9px;
min-height: 30px;
min-width: 30px;
padding: 1px 7px;
}
.compact-topbar .mode-button {
font-size: 9px;
}
.compact-kicker {
color: #61eaf5;
font-family: monospace;
font-size: 8px;
font-weight: 800;
letter-spacing: 1px;
}
.compact-status {
color: #73f4ff;
background: #072129;
border: 1px solid #10424d;
border-radius: 99px;
font-family: monospace;
font-size: 8px;
font-weight: 800;
letter-spacing: 1px;
padding: 3px 7px;
}
.compact-title {
color: #f4feff;
font-size: 18px;
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: 11px;
padding: 3px 5px;
}
.compact-controls button {
min-height: 28px;
min-width: 28px;
padding: 2px 5px;
}
.compact-play-button {
background: #00d8ef;
border-color: #65f3ff;
color: #001014;
min-height: 34px;
min-width: 38px;
border-radius: 11px;
box-shadow: 0 5px 18px rgba(0, 216, 239, .18);
}
.theme-strip {
background: rgba(7, 18, 22, .92);
border: 1px solid #163740;
border-radius: 15px;
box-shadow: 0 12px 32px rgba(0, 0, 0, .24);
padding: 9px 13px;
}
.theme-copy {
min-width: 210px;
}
.appearance-title-row {
min-height: 30px;
}
.motion-button {
color: #83dce5;
background: #07191e;
border-color: #17434d;
border-radius: 99px;
font-family: monospace;
font-size: 8px;
font-weight: 900;
letter-spacing: 1px;
min-height: 28px;
min-width: 112px;
padding: 2px 9px;
}
.motion-button.motion-alive {
color: #001014;
background: #7ceff7;
border-color: #a8f8ff;
box-shadow: 0 4px 15px rgba(0, 216, 239, .16);
}
.motion-button.motion-calm {
color: #9feaf0;
background: #0b242b;
border-color: #28606a;
}
.theme-options {
background: rgba(2, 8, 10, .7);
border: 1px solid #12313a;
border-radius: 12px;
padding: 4px;
}
.theme-option {
background: transparent;
border-color: transparent;
border-radius: 9px;
font-family: monospace;
font-size: 9px;
font-weight: 900;
letter-spacing: 1px;
min-height: 39px;
min-width: 102px;
padding: 2px 10px;
}
.theme-option:hover {
background: #0c242b;
}
.theme-option.active {
color: #001014;
background: #00d8ef;
border-color: #65f3ff;
box-shadow: 0 5px 18px rgba(0, 216, 239, .19);
}
.theme-option-signal { box-shadow: inset 0 3px #00d8ef; }
.theme-option-ember { box-shadow: inset 0 3px #ff8529; }
.theme-option-ultraviolet { box-shadow: inset 0 3px #ad63ff; }
.theme-option-daylight { box-shadow: inset 0 3px #e9f7fa; }
/* Ember — warm, tactile, late-night listening. */
window.theme-ember, window.theme-ember .app-shell { background: #090402; color: #fff5e8; }
window.theme-ember .topbar, window.theme-ember .config-topbar {
background-image: linear-gradient(100deg, #2b1108, #0c0503 74%);
border-color: #66351d;
}
window.theme-ember .content, window.theme-ember .config-shell {
background-image:
radial-gradient(circle at 80% 8%, rgba(255, 133, 41, .14), transparent 36%),
radial-gradient(circle at 10% 90%, rgba(189, 57, 25, .10), transparent 34%),
linear-gradient(140deg, #0a0402, #170904 62%, #080302);
}
window.theme-ember .compact-shell {
background-image:
radial-gradient(circle at 10% 20%, rgba(255, 133, 41, .17), transparent 40%),
linear-gradient(135deg, #210d06, #090402 74%);
border-color: #68351d;
}
window.theme-ember .brand-mark,
window.theme-ember .primary-button,
window.theme-ember .compact-play-button,
window.theme-ember .config-primary,
window.theme-ember .cue-menu-button.has-cues,
window.theme-ember button:active,
window.theme-ember button.active {
color: #1d0901;
background: #ff922f;
border-color: #ffc05c;
}
window.theme-ember .brand-mark { box-shadow: 0 0 24px rgba(255, 133, 41, .28); }
window.theme-ember .brand-accent,
window.theme-ember .eyebrow,
window.theme-ember .time-label,
window.theme-ember .config-row-icon,
window.theme-ember .compact-kicker,
window.theme-ember .queue-peek-index { color: #ff9b3d; }
window.theme-ember .brand, window.theme-ember .queue-title,
window.theme-ember .track-title, window.theme-ember .compact-title,
window.theme-ember .config-page-title, window.theme-ember .config-section-title,
window.theme-ember .config-row-title { color: #fff7ed; }
window.theme-ember .brand-subtitle, window.theme-ember .muted,
window.theme-ember .track-detail, window.theme-ember .compact-detail,
window.theme-ember .config-description, window.theme-ember .config-row-detail,
window.theme-ember .queue-peek-summary {
color: #bc8566;
}
window.theme-ember .card, window.theme-ember .config-card, window.theme-ember .theme-strip,
window.theme-ember .queue-peek-popover {
background: rgba(30, 12, 6, .96);
border-color: #5d2d18;
}
window.theme-ember .stage, window.theme-ember .config-card.playlist-card { border-top-color: #ff8529; }
window.theme-ember .queue-card { border-top-color: #6f371c; }
window.theme-ember .cover, window.theme-ember .artwork-frame,
window.theme-ember .transport, window.theme-ember .config-list-frame,
window.theme-ember .theme-options { background: #100603; border-color: #5b2d18; }
window.theme-ember .queue-card scrolledwindow,
window.theme-ember .queue-card viewport,
window.theme-ember .queue-card list,
window.theme-ember .queue-card listbox,
window.theme-ember .config-list-frame list,
window.theme-ember .config-list-frame row { background: #100603; color: #fff4e7; }
window.theme-ember .queue-row, window.theme-ember .config-row,
window.theme-ember .queue-peek-row { background: #160904; }
window.theme-ember .queue-row:hover, window.theme-ember .config-row:hover,
window.theme-ember .theme-option:hover,
window.theme-ember .queue-peek-row:hover { background: #2d1208; }
window.theme-ember .queue-row:selected, window.theme-ember .queue-row.is-playing,
window.theme-ember .config-row:selected,
window.theme-ember .queue-peek-row.is-playing {
background: #351509;
box-shadow: inset 3px 0 #ff8529;
}
window.theme-ember scale highlight { background: #ff8529; }
window.theme-ember scale trough { background: #3c1c0e; }
window.theme-ember scale slider { background: #fff1da; border-color: #ff8529; }
window.theme-ember .status-pill, window.theme-ember .compact-status,
window.theme-ember .queue-count, window.theme-ember .config-tag,
window.theme-ember .config-notice { color: #ffc36d; background: #2b1107; border-color: #673319; }
window.theme-ember .theme-option.active { color: #1d0901; background: #ff922f; border-color: #ffc05c; }
/* Ultraviolet — deep ink, bloom, and club-light contrast. */
window.theme-ultraviolet, window.theme-ultraviolet .app-shell { background: #05020e; color: #fbf5ff; }
window.theme-ultraviolet .topbar, window.theme-ultraviolet .config-topbar {
background-image: linear-gradient(100deg, #1a0a37, #080312 74%);
border-color: #4b286b;
}
window.theme-ultraviolet .content, window.theme-ultraviolet .config-shell {
background-image:
radial-gradient(circle at 80% 8%, rgba(173, 99, 255, .16), transparent 37%),
radial-gradient(circle at 10% 90%, rgba(255, 79, 181, .09), transparent 34%),
linear-gradient(140deg, #05020e, #100624 62%, #05020d);
}
window.theme-ultraviolet .compact-shell {
background-image:
radial-gradient(circle at 10% 20%, rgba(173, 99, 255, .19), transparent 40%),
linear-gradient(135deg, #16082e, #05020e 74%);
border-color: #513072;
}
window.theme-ultraviolet .brand-mark,
window.theme-ultraviolet .primary-button,
window.theme-ultraviolet .compact-play-button,
window.theme-ultraviolet .config-primary,
window.theme-ultraviolet .cue-menu-button.has-cues,
window.theme-ultraviolet button:active,
window.theme-ultraviolet button.active {
color: #10031f;
background: #b56aff;
border-color: #e0b7ff;
}
window.theme-ultraviolet .brand-mark { box-shadow: 0 0 26px rgba(173, 99, 255, .32); }
window.theme-ultraviolet .brand-accent,
window.theme-ultraviolet .eyebrow,
window.theme-ultraviolet .time-label,
window.theme-ultraviolet .config-row-icon,
window.theme-ultraviolet .compact-kicker,
window.theme-ultraviolet .queue-peek-index { color: #c17dff; }
window.theme-ultraviolet .brand, window.theme-ultraviolet .queue-title,
window.theme-ultraviolet .track-title, window.theme-ultraviolet .compact-title,
window.theme-ultraviolet .config-page-title, window.theme-ultraviolet .config-section-title,
window.theme-ultraviolet .config-row-title { color: #fcf6ff; }
window.theme-ultraviolet .brand-subtitle, window.theme-ultraviolet .muted,
window.theme-ultraviolet .track-detail, window.theme-ultraviolet .compact-detail,
window.theme-ultraviolet .config-description, window.theme-ultraviolet .config-row-detail,
window.theme-ultraviolet .queue-peek-summary { color: #a78bbc; }
window.theme-ultraviolet .card, window.theme-ultraviolet .config-card,
window.theme-ultraviolet .theme-strip,
window.theme-ultraviolet .queue-peek-popover {
background: rgba(19, 8, 39, .96);
border-color: #42245e;
}
window.theme-ultraviolet .stage,
window.theme-ultraviolet .config-card.playlist-card { border-top-color: #ad63ff; }
window.theme-ultraviolet .queue-card { border-top-color: #4f2c6d; }
window.theme-ultraviolet .cover, window.theme-ultraviolet .artwork-frame,
window.theme-ultraviolet .transport, window.theme-ultraviolet .config-list-frame,
window.theme-ultraviolet .theme-options { background: #090315; border-color: #43245e; }
window.theme-ultraviolet .queue-card scrolledwindow,
window.theme-ultraviolet .queue-card viewport,
window.theme-ultraviolet .queue-card list,
window.theme-ultraviolet .queue-card listbox,
window.theme-ultraviolet .config-list-frame list,
window.theme-ultraviolet .config-list-frame row { background: #090315; color: #fbf4ff; }
window.theme-ultraviolet .queue-row, window.theme-ultraviolet .config-row,
window.theme-ultraviolet .queue-peek-row { background: #100622; }
window.theme-ultraviolet .queue-row:hover, window.theme-ultraviolet .config-row:hover,
window.theme-ultraviolet .theme-option:hover,
window.theme-ultraviolet .queue-peek-row:hover { background: #23103d; }
window.theme-ultraviolet .queue-row:selected, window.theme-ultraviolet .queue-row.is-playing,
window.theme-ultraviolet .config-row:selected,
window.theme-ultraviolet .queue-peek-row.is-playing {
background: #2a1249;
box-shadow: inset 3px 0 #ad63ff;
}
window.theme-ultraviolet scale highlight { background: #ad63ff; }
window.theme-ultraviolet scale trough { background: #2c1740; }
window.theme-ultraviolet scale slider { background: #f7eaff; border-color: #ad63ff; }
window.theme-ultraviolet .status-pill, window.theme-ultraviolet .compact-status,
window.theme-ultraviolet .queue-count, window.theme-ultraviolet .config-tag,
window.theme-ultraviolet .config-notice { color: #deb7ff; background: #21103a; border-color: #573174; }
window.theme-ultraviolet .theme-option.active { color: #10031f; background: #b56aff; border-color: #e0b7ff; }
/* Daylight — airy, readable, and deliberately calm. */
window.theme-daylight, window.theme-daylight .app-shell { background: #e8f0f2; color: #142c32; }
window.theme-daylight .topbar, window.theme-daylight .config-topbar {
background-image: linear-gradient(100deg, #f9fcfd, #e3eef1 76%);
border-color: #bdd2d7;
box-shadow: 0 8px 28px rgba(37, 77, 87, .12);
}
window.theme-daylight .content, window.theme-daylight .config-shell {
background-image:
radial-gradient(circle at 80% 8%, rgba(0, 150, 183, .12), transparent 36%),
radial-gradient(circle at 10% 90%, rgba(77, 189, 198, .10), transparent 34%),
linear-gradient(140deg, #eef5f6, #dce9ec 62%, #f3f7f8);
}
window.theme-daylight .compact-shell {
background-image:
radial-gradient(circle at 10% 20%, rgba(0, 150, 183, .13), transparent 40%),
linear-gradient(135deg, #f8fcfc, #dfecef 76%);
border-color: #adcbd1;
box-shadow: inset 0 1px white, 0 18px 42px rgba(45, 81, 89, .20);
}
window.theme-daylight .brand-mark,
window.theme-daylight .primary-button,
window.theme-daylight .compact-play-button,
window.theme-daylight .config-primary,
window.theme-daylight .cue-menu-button.has-cues,
window.theme-daylight button:active,
window.theme-daylight button.active {
color: #f5feff;
background: #007f9f;
border-color: #006f8b;
}
window.theme-daylight .brand-mark { box-shadow: 0 0 22px rgba(0, 127, 159, .18); }
window.theme-daylight .brand-accent,
window.theme-daylight .eyebrow,
window.theme-daylight .time-label,
window.theme-daylight .config-row-icon,
window.theme-daylight .compact-kicker,
window.theme-daylight .queue-peek-index { color: #007f9f; }
window.theme-daylight .brand, window.theme-daylight .queue-title,
window.theme-daylight .track-title, window.theme-daylight .compact-title,
window.theme-daylight .config-page-title, window.theme-daylight .config-section-title,
window.theme-daylight .config-row-title { color: #102b32; }
window.theme-daylight .brand-subtitle, window.theme-daylight .muted,
window.theme-daylight .track-detail, window.theme-daylight .compact-detail,
window.theme-daylight .config-description, window.theme-daylight .config-row-detail,
window.theme-daylight .queue-peek-summary { color: #54747c; }
window.theme-daylight .card, window.theme-daylight .config-card,
window.theme-daylight .theme-strip,
window.theme-daylight .queue-peek-popover {
background: rgba(249, 252, 253, .96);
border-color: #bfd3d8;
box-shadow: 0 14px 36px rgba(42, 82, 92, .14);
}
window.theme-daylight .stage,
window.theme-daylight .config-card.playlist-card { border-top-color: #008eae; }
window.theme-daylight .queue-card { border-top-color: #a9c7cd; }
window.theme-daylight .cover, window.theme-daylight .artwork-frame,
window.theme-daylight .transport, window.theme-daylight .config-list-frame,
window.theme-daylight .theme-options { background: #edf4f5; border-color: #b7cfd4; }
window.theme-daylight .queue-card scrolledwindow,
window.theme-daylight .queue-card viewport,
window.theme-daylight .queue-card list,
window.theme-daylight .queue-card listbox,
window.theme-daylight .config-list-frame list,
window.theme-daylight .config-list-frame row { background: #edf4f5; color: #142c32; }
window.theme-daylight .queue-row, window.theme-daylight .config-row,
window.theme-daylight .queue-peek-row { color: #17343b; background: #f6fafb; }
window.theme-daylight .queue-row:hover, window.theme-daylight .config-row:hover,
window.theme-daylight .theme-option:hover,
window.theme-daylight .queue-peek-row:hover { background: #d9e9ec; }
window.theme-daylight .queue-row:selected, window.theme-daylight .queue-row.is-playing,
window.theme-daylight .config-row:selected,
window.theme-daylight .queue-peek-row.is-playing {
color: #0b252c;
background: #cfe5e9;
box-shadow: inset 3px 0 #008eae;
}
window.theme-daylight button { color: #21464e; background: #edf5f6; border-color: #adc9cf; }
window.theme-daylight button:hover { color: #0b252c; background: #dcecef; border-color: #008eae; }
window.theme-daylight scale highlight { background: #008eae; }
window.theme-daylight scale trough { background: #bdd3d8; }
window.theme-daylight scale slider { background: #ffffff; border-color: #008eae; }
window.theme-daylight .status-pill, window.theme-daylight .compact-status,
window.theme-daylight .queue-count, window.theme-daylight .config-tag,
window.theme-daylight .config-notice { color: #006d87; background: #d9ebee; border-color: #aacbd2; }
window.theme-daylight .config-shell entry { color: #17343b; background: #f8fbfc; border-color: #aecbd1; }
window.theme-daylight .theme-option.active { color: #f5feff; background: #007f9f; border-color: #006f8b; }
window.theme-daylight .config-shell button:disabled { color: #8ba1a6; background: #e4edef; border-color: #cedde0; }
window.theme-ember button { color: #f2c29b; background: #261006; border-color: #61311a; }
window.theme-ember button:hover { color: #fff4e5; background: #3b1809; border-color: #ff8529; }
window.theme-ember .config-shell entry { color: #fff3e5; background: #100603; border-color: #61311a; }
window.theme-ember .queue-search { color: #fff3e5; background: #100603; border-color: #61311a; caret-color: #ff8529; }
window.theme-ember .queue-search:focus { border-color: #ff8529; box-shadow: inset 0 0 0 1px #ff8529; }
window.theme-ember .config-shell entry:focus { border-color: #ff8529; box-shadow: inset 0 0 0 1px #ff8529; }
window.theme-ember .config-shell button:disabled { color: #76503c; background: #1b0c06; border-color: #452416; }
window.theme-ember .queue-card button:disabled { color: #704a35; background: #180a05; border-color: #3d2013; }
window.theme-ember .motion-button.motion-alive { color: #1d0901; background: #ffb156; border-color: #ffd18a; }
window.theme-ember .motion-button.motion-calm { color: #ffd0a1; background: #3a190b; border-color: #80451f; }
window.theme-ultraviolet button { color: #d8b8ed; background: #190a2d; border-color: #4b2a65; }
window.theme-ultraviolet button:hover { color: #fff6ff; background: #2b1249; border-color: #ad63ff; }
window.theme-ultraviolet .config-shell entry { color: #fcf5ff; background: #090315; border-color: #4b2a65; }
window.theme-ultraviolet .queue-search { color: #fcf5ff; background: #090315; border-color: #4b2a65; caret-color: #ad63ff; }
window.theme-ultraviolet .queue-search:focus { border-color: #ad63ff; box-shadow: inset 0 0 0 1px #ad63ff; }
window.theme-ultraviolet .config-shell entry:focus { border-color: #ad63ff; box-shadow: inset 0 0 0 1px #ad63ff; }
window.theme-ultraviolet .config-shell button:disabled { color: #6b577a; background: #120720; border-color: #372046; }
window.theme-ultraviolet .queue-card button:disabled { color: #655371; background: #10061c; border-color: #2f1a3d; }
window.theme-ultraviolet .motion-button.motion-alive { color: #10031f; background: #d09aff; border-color: #edcfff; }
window.theme-ultraviolet .motion-button.motion-calm { color: #e5c9f7; background: #311751; border-color: #654080; }
window.theme-daylight .motion-button.motion-alive { color: #ffffff; background: #0086a5; border-color: #006f8b; }
window.theme-daylight .motion-button.motion-calm { color: #16515e; background: #d7e9ec; border-color: #9fc4cc; }
window.theme-daylight .queue-card button:disabled { color: #8ba1a6; background: #e4edef; border-color: #cedde0; }
window.theme-daylight .queue-search { color: #17343b; background: #f8fbfc; border-color: #aecbd1; caret-color: #008eae; }
window.theme-daylight .queue-search:focus { border-color: #008eae; box-shadow: inset 0 0 0 1px #008eae; }
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>,
library_sources: Vec<PathBuf>,
active_source: Option<PathBuf>,
playlists: Vec<Playlist>,
theme: Theme,
motion_mode: MotionMode,
}
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(),
library_sources: Vec::new(),
active_source: None,
playlists: Vec::new(),
theme: Theme::default(),
motion_mode: MotionMode::default(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct CuePoint {
path: PathBuf,
seconds: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct Playlist {
name: String,
tracks: Vec<PathBuf>,
}
struct CueMenu {
button: gtk::MenuButton,
summary: gtk::Label,
previous: gtk::Button,
save: gtk::Button,
next: gtk::Button,
clear: gtk::Button,
}
struct QueuePeek {
button: gtk::MenuButton,
list: gtk::ListBox,
summary: gtk::Label,
open_full: gtk::Button,
}
#[derive(Clone)]
struct ConfigView {
root: gtk::Box,
back: gtk::Button,
add_folders: gtk::Button,
source_list: gtk::ListBox,
source_empty: gtk::Label,
source_scroll: gtk::ScrolledWindow,
source_load: gtk::Button,
source_remove: gtk::Button,
source_load_all: gtk::Button,
playlist_name: gtk::Entry,
playlist_save: gtk::Button,
playlist_list: gtk::ListBox,
playlist_empty: gtk::Label,
playlist_scroll: gtk::ScrolledWindow,
playlist_load: gtk::Button,
playlist_append: gtk::Button,
playlist_export: gtk::Button,
playlist_remove: gtk::Button,
notice: gtk::Label,
theme_buttons: Vec<gtk::Button>,
theme_detail: gtk::Label,
motion_button: gtk::Button,
}
struct CompactView {
root: gtk::Box,
expand_button: gtk::Button,
artwork: gtk::Picture,
artwork_placeholder: gtk::DrawingArea,
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,
queue_peek: QueuePeek,
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>,
library_sources: Vec<PathBuf>,
active_source: Option<PathBuf>,
playlists: Vec<Playlist>,
theme: Theme,
visual_palette: Rc<RefCell<VisualPalette>>,
motion_mode: Rc<RefCell<MotionMode>>,
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,
queue_search: gtk::SearchEntry,
queue_move_up: gtk::Button,
queue_move_down: gtk::Button,
queue_remove: gtk::Button,
queue_clear: gtk::Button,
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,
compact_queue_button: gtk::MenuButton,
compact_queue_list: gtk::ListBox,
compact_queue_summary: gtk::Label,
cue_buttons: Vec<gtk::MenuButton>,
cue_summaries: Vec<gtk::Label>,
config: ConfigView,
spectrum_bands: Rc<RefCell<Vec<f32>>>,
visualizer: gtk::DrawingArea,
artwork_placeholders: Vec<gtk::DrawingArea>,
seek: gtk::Scale,
elapsed: gtk::Label,
total: gtk::Label,
}
impl MusicApp {
fn sync_motion_control(&self) {
let motion_mode = *self.motion_mode.borrow();
self.config
.motion_button
.set_label(&format!("MOTION / {}", motion_mode.label()));
self.config
.motion_button
.set_tooltip_text(Some(motion_mode.description()));
for class_name in ["motion-still", "motion-calm", "motion-alive"] {
self.config.motion_button.remove_css_class(class_name);
}
self.config.motion_button.add_css_class(match motion_mode {
MotionMode::Still => "motion-still",
MotionMode::Calm => "motion-calm",
MotionMode::Alive => "motion-alive",
});
}
fn set_motion_mode(&mut self, motion_mode: MotionMode) {
if *self.motion_mode.borrow() == motion_mode {
return;
}
*self.motion_mode.borrow_mut() = motion_mode;
self.sync_motion_control();
self.visualizer.queue_draw();
self.set_config_notice(&format!("{} MOTION ACTIVE", motion_mode.label()));
self.save_session();
}
fn cycle_motion_mode(&mut self) {
let next = self.motion_mode.borrow().next();
self.set_motion_mode(next);
}
fn sync_theme_controls(&self) {
for (button, theme) in self.config.theme_buttons.iter().zip(Theme::ALL) {
if theme == self.theme {
button.add_css_class("active");
button.set_tooltip_text(Some(&format!("Current theme — {}", theme.description())));
} else {
button.remove_css_class("active");
button.set_tooltip_text(Some(theme.description()));
}
}
self.config.theme_detail.set_text(&format!(
"{} — {}",
self.theme.label(),
self.theme.summary()
));
}
fn set_theme(&mut self, theme: Theme) {
if theme == self.theme {
return;
}
for known_theme in Theme::ALL {
self.window.remove_css_class(known_theme.css_class());
}
self.window.add_css_class(theme.css_class());
self.theme = theme;
*self.visual_palette.borrow_mut() = theme.palette();
self.sync_theme_controls();
self.visualizer.queue_draw();
for placeholder in &self.artwork_placeholders {
placeholder.queue_draw();
}
let transition_seconds = self.motion_mode.borrow().transition_seconds();
if transition_seconds <= f64::EPSILON {
self.view_stack.set_opacity(1.0);
} else {
let stack = self.view_stack.clone();
stack.set_opacity(0.68);
let started = Instant::now();
glib::timeout_add_local(Duration::from_millis(16), move || {
let progress =
(started.elapsed().as_secs_f64() / transition_seconds).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - progress).powi(3);
stack.set_opacity(0.68 + 0.32 * eased);
if progress >= 1.0 {
stack.set_opacity(1.0);
glib::ControlFlow::Break
} else {
glib::ControlFlow::Continue
}
});
}
self.set_config_notice(&format!("{} THEME ACTIVE", theme.label()));
self.save_session();
}
fn cycle_theme(&mut self) {
self.set_theme(self.theme.next());
}
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(&compact_status_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 folders =
normalized_source_paths(paths.iter().filter(|path| path.is_dir()).cloned().collect());
let mut sources_changed = false;
for folder in folders {
if !self.library_sources.contains(&folder) {
self.library_sources.push(folder);
sources_changed = true;
}
}
if sources_changed {
self.library_sources
.sort_by_cached_key(|path| path.to_string_lossy().to_lowercase());
}
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 sources_changed {
self.rebuild_config();
}
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 replace_queue(&mut self, paths: Vec<PathBuf>) {
let mut known = HashSet::new();
self.queue = expand_audio_paths(paths)
.into_iter()
.filter(|path| known.insert(path.clone()))
.map(Track::from_path)
.collect();
self.shuffle_order.reset(self.queue.len(), None);
self.reset_player();
self.rebuild_queue();
if self.queue.is_empty() {
self.save_session();
} else {
self.load_track(0, false);
}
self.rebuild_config();
}
fn remember_and_load_sources(&mut self, paths: Vec<PathBuf>) {
let mut selected = Vec::new();
for path in paths {
if !path.is_dir() {
continue;
}
let path = fs::canonicalize(&path).unwrap_or(path);
if !selected.contains(&path) {
selected.push(path.clone());
}
if !self.library_sources.contains(&path) {
self.library_sources.push(path);
}
}
self.library_sources
.sort_by_cached_key(|path| path.to_string_lossy().to_lowercase());
if selected.is_empty() {
self.set_config_notice("NO READABLE FOLDERS SELECTED");
self.rebuild_config();
return;
}
self.active_source = (selected.len() == 1).then(|| selected[0].clone());
let count = selected.len();
self.replace_queue(selected);
self.set_config_notice(&format!(
"LOADED {count} {}",
if count == 1 { "FOLDER" } else { "FOLDERS" }
));
}
fn load_selected_source(&mut self) {
let Some(index) = self
.config
.source_list
.selected_row()
.map(|row| row.index() as usize)
else {
return;
};
let Some(path) = self.library_sources.get(index).cloned() else {
return;
};
if !path.is_dir() {
self.set_config_notice("THAT FOLDER IS CURRENTLY OFFLINE");
return;
}
self.active_source = Some(path.clone());
let name = source_name(&path);
self.replace_queue(vec![path]);
self.set_config_notice(&format!("LOADED {name}"));
}
fn load_all_sources(&mut self) {
let paths: Vec<PathBuf> = self
.library_sources
.iter()
.filter(|path| path.is_dir())
.cloned()
.collect();
if paths.is_empty() {
self.set_config_notice("NO SAVED FOLDERS ARE CURRENTLY ONLINE");
return;
}
let count = paths.len();
self.active_source = None;
self.replace_queue(paths);
self.set_config_notice(&format!("LOADED ALL {count} FOLDERS"));
}
fn remove_selected_source(&mut self) {
let Some(index) = self
.config
.source_list
.selected_row()
.map(|row| row.index() as usize)
else {
return;
};
if index >= self.library_sources.len() {
return;
}
let removed = self.library_sources.remove(index);
if self.active_source.as_ref() == Some(&removed) {
self.active_source = None;
}
self.set_config_notice("FOLDER FORGOTTEN — AUDIO FILES WERE NOT DELETED");
self.rebuild_config();
self.save_session();
}
fn save_queue_as_playlist(&mut self) {
let name = cleaned_playlist_name(self.config.playlist_name.text().as_str());
if name.is_empty() || self.queue.is_empty() {
return;
}
let tracks: Vec<PathBuf> = self.queue.iter().map(|track| track.path.clone()).collect();
if let Some(playlist) = self
.playlists
.iter_mut()
.find(|playlist| playlist.name.eq_ignore_ascii_case(&name))
{
playlist.name = name.clone();
playlist.tracks = tracks;
self.set_config_notice(&format!("UPDATED PLAYLIST {name}"));
} else {
self.playlists.push(Playlist {
name: name.clone(),
tracks,
});
self.set_config_notice(&format!("SAVED PLAYLIST {name}"));
}
self.playlists
.sort_by_cached_key(|playlist| playlist.name.to_lowercase());
self.config.playlist_name.set_text("");
self.rebuild_config();
self.save_session();
}
fn load_selected_playlist(&mut self, append: bool) {
let Some(index) = self
.config
.playlist_list
.selected_row()
.map(|row| row.index() as usize)
else {
return;
};
let Some(playlist) = self.playlists.get(index).cloned() else {
return;
};
let available: Vec<PathBuf> = playlist
.tracks
.iter()
.filter(|path| is_supported_audio(path))
.cloned()
.collect();
if available.is_empty() {
self.set_config_notice("NO TRACKS IN THAT PLAYLIST ARE CURRENTLY AVAILABLE");
return;
}
self.active_source = None;
if append {
self.add_tracks(available);
self.set_config_notice(&format!("APPENDED PLAYLIST {}", playlist.name));
} else {
self.replace_queue(available);
self.set_config_notice(&format!("LOADED PLAYLIST {}", playlist.name));
}
self.rebuild_config();
}
fn remove_selected_playlist(&mut self) {
let Some(index) = self
.config
.playlist_list
.selected_row()
.map(|row| row.index() as usize)
else {
return;
};
if index >= self.playlists.len() {
return;
}
let playlist = self.playlists.remove(index);
self.set_config_notice(&format!("REMOVED PLAYLIST {}", playlist.name));
self.rebuild_config();
self.save_session();
}
fn show_config(&mut self) {
self.widget_mode = false;
self.window.remove_css_class("widget-mode");
self.window.set_resizable(true);
self.window.set_default_size(980, 680);
self.view_stack.set_visible_child_name("config");
self.mode_button.set_label("WIDGET");
self.rebuild_config();
self.save_session();
}
fn config_is_visible(&self) -> bool {
self.view_stack.visible_child_name().as_deref() == Some("config")
}
fn set_config_notice(&self, message: &str) {
self.config.notice.set_text(message);
}
fn sync_config_actions(&self) {
let source_index = self
.config
.source_list
.selected_row()
.map(|row| row.index() as usize);
let source_selected = source_index
.and_then(|index| self.library_sources.get(index))
.is_some();
let source_online = source_index
.and_then(|index| self.library_sources.get(index))
.is_some_and(|path| path.is_dir());
self.config.source_load.set_sensitive(source_online);
self.config.source_remove.set_sensitive(source_selected);
self.config
.source_load_all
.set_sensitive(self.library_sources.iter().any(|path| path.is_dir()));
let playlist_index = self
.config
.playlist_list
.selected_row()
.map(|row| row.index() as usize);
let playlist_selected = playlist_index.is_some_and(|index| index < self.playlists.len());
let playlist_available = playlist_index
.and_then(|index| self.playlists.get(index))
.is_some_and(|playlist| playlist.tracks.iter().any(|path| is_supported_audio(path)));
self.config.playlist_load.set_sensitive(playlist_available);
self.config
.playlist_append
.set_sensitive(playlist_available);
self.config.playlist_export.set_sensitive(playlist_selected);
self.config.playlist_remove.set_sensitive(playlist_selected);
self.config.playlist_save.set_sensitive(
!self.queue.is_empty()
&& !cleaned_playlist_name(self.config.playlist_name.text().as_str()).is_empty(),
);
}
fn rebuild_config(&self) {
let selected_source = self
.config
.source_list
.selected_row()
.map(|row| row.index() as usize);
while let Some(child) = self.config.source_list.first_child() {
self.config.source_list.remove(&child);
}
self.config
.source_empty
.set_visible(self.library_sources.is_empty());
self.config
.source_scroll
.set_visible(!self.library_sources.is_empty());
for path in &self.library_sources {
let row = config_row(
"folder-music-symbolic",
&source_name(path),
&path.to_string_lossy(),
if self.active_source.as_ref() == Some(path) {
Some("ACTIVE")
} else if !path.is_dir() {
Some("OFFLINE")
} else {
None
},
);
self.config.source_list.append(&row);
}
let source_to_select = self
.active_source
.as_ref()
.and_then(|active| self.library_sources.iter().position(|path| path == active))
.or(selected_source)
.filter(|index| *index < self.library_sources.len())
.or((!self.library_sources.is_empty()).then_some(0));
if let Some(index) = source_to_select
&& let Some(row) = self.config.source_list.row_at_index(index as i32)
{
self.config.source_list.select_row(Some(&row));
}
let selected_playlist = self
.config
.playlist_list
.selected_row()
.map(|row| row.index() as usize);
while let Some(child) = self.config.playlist_list.first_child() {
self.config.playlist_list.remove(&child);
}
self.config
.playlist_empty
.set_visible(self.playlists.is_empty());
self.config
.playlist_scroll
.set_visible(!self.playlists.is_empty());
for playlist in &self.playlists {
let available = playlist
.tracks
.iter()
.filter(|path| is_supported_audio(path))
.count();
let detail = format!(
"{} TRACKS / {} AVAILABLE",
playlist.tracks.len(),
available
);
let row = config_row(
"view-list-symbolic",
&playlist.name,
&detail,
(available == 0).then_some("OFFLINE"),
);
self.config.playlist_list.append(&row);
}
let first_available = self
.playlists
.iter()
.position(|playlist| playlist.tracks.iter().any(|path| is_supported_audio(path)));
let playlist_to_select = selected_playlist
.filter(|index| *index < self.playlists.len())
.or(first_available)
.or((!self.playlists.is_empty()).then_some(0));
if let Some(index) = playlist_to_select
&& let Some(row) = self.config.playlist_list.row_at_index(index as i32)
{
self.config.playlist_list.select_row(Some(&row));
}
self.sync_config_actions();
}
fn filtered_queue_indices(&self) -> Vec<usize> {
let query = self.queue_search.text();
self.queue
.iter()
.enumerate()
.filter_map(|(index, track)| {
track_matches_search(track, query.as_str()).then_some(index)
})
.collect()
}
fn queue_index_for_row(&self, row_index: i32) -> Option<usize> {
usize::try_from(row_index)
.ok()
.and_then(|row_index| self.filtered_queue_indices().get(row_index).copied())
}
fn selected_queue_index(&self) -> Option<usize> {
self.list
.selected_row()
.and_then(|row| self.queue_index_for_row(row.index()))
}
fn row_for_queue_index(&self, queue_index: usize) -> Option<gtk::ListBoxRow> {
self.filtered_queue_indices()
.iter()
.position(|index| *index == queue_index)
.and_then(|row_index| self.list.row_at_index(row_index as i32))
}
fn focus_queue_search(&mut self) {
self.set_widget_mode(false);
self.queue_search.grab_focus();
}
fn sync_queue_actions(&self) {
let selected = self.selected_queue_index();
self.queue_move_up
.set_sensitive(selected.is_some_and(|index| index > 0));
self.queue_move_down.set_sensitive(
selected.is_some_and(|index| index.saturating_add(1) < self.queue.len()),
);
self.queue_remove.set_sensitive(selected.is_some());
self.queue_clear.set_sensitive(!self.queue.is_empty());
}
fn sync_compact_queue_peek(&self) {
while let Some(child) = self.compact_queue_list.first_child() {
self.compact_queue_list.remove(&child);
}
let queue_len = self.queue.len();
self.compact_queue_button.set_sensitive(queue_len > 0);
let tooltip = if queue_len == 0 {
"Queue peek — add music to begin".to_string()
} else {
format!("Queue peek — choose from {queue_len} track(s)")
};
self.compact_queue_button.set_tooltip_text(Some(&tooltip));
if queue_len == 0 {
self.compact_queue_summary.set_text("QUEUE EMPTY");
return;
}
let summary = match self.current {
Some(_) if self.shuffle => format!("{queue_len:02} TRACKS / SHUFFLE PICK"),
Some(index) => {
let ahead = queue_len.saturating_sub(index.saturating_add(1));
format!("{:02} OF {queue_len:02} / {ahead} AHEAD", index + 1)
}
None => format!("{queue_len:02} TRACKS / READY TO START"),
};
self.compact_queue_summary.set_text(&summary);
for index in queue_preview_indices(queue_len, self.current, 6) {
let track = &self.queue[index];
let row = gtk::ListBoxRow::new();
row.add_css_class("queue-peek-row");
row.set_activatable(true);
if self.current == Some(index) {
row.add_css_class("is-playing");
}
let content = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let marker = gtk::Label::new(Some(if self.current == Some(index) {
"NOW"
} else {
""
}));
if self.current != Some(index) {
marker.set_text(&format!("{:02}", index + 1));
}
marker.add_css_class("queue-peek-index");
marker.set_width_chars(3);
let labels = gtk::Box::new(gtk::Orientation::Vertical, 1);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(&track.title));
title.add_css_class("queue-peek-title");
title.set_xalign(0.0);
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
let detail = gtk::Label::new(Some(&track.queue_subtitle()));
detail.add_css_class("muted");
detail.set_xalign(0.0);
detail.set_ellipsize(gtk::pango::EllipsizeMode::End);
labels.append(&title);
labels.append(&detail);
content.append(&marker);
content.append(&labels);
row.set_child(Some(&content));
self.compact_queue_list.append(&row);
}
}
fn rebuild_queue(&self) {
let selected_index = self.selected_queue_index();
let visible_indices = self.filtered_queue_indices();
while let Some(child) = self.list.first_child() {
self.list.remove(&child);
}
let query = self.queue_search.text();
let has_query = !query.trim().is_empty();
let no_matches = !self.queue.is_empty() && visible_indices.is_empty();
if self.queue.is_empty() {
self.empty_queue
.set_text("Add music to begin.\nYour queue will appear here.");
} else if no_matches {
self.empty_queue
.set_text("No matching tracks.\nTry a title, artist, album, format, or folder.");
}
self.empty_queue
.set_visible(self.queue.is_empty() || no_matches);
self.queue_scroll.set_visible(!visible_indices.is_empty());
if has_query {
self.queue_count.set_text(&format!(
"{:02} / {:02}",
visible_indices.len(),
self.queue.len()
));
self.queue_count.set_tooltip_text(Some(&format!(
"{} matching {} of {} total",
visible_indices.len(),
if visible_indices.len() == 1 {
"track"
} else {
"tracks"
},
self.queue.len()
)));
} else {
let unit = if self.queue.len() == 1 {
"TRACK"
} else {
"TRACKS"
};
self.queue_count
.set_text(&format!("{:02} {unit}", self.queue.len()));
self.queue_count.set_tooltip_text(None);
}
for index in visible_indices.iter().copied() {
let track = &self.queue[index];
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.row_for_queue_index(index)
{
self.list.select_row(Some(&row));
}
self.sync_queue_actions();
self.sync_config_actions();
self.sync_compact_queue_peek();
}
fn remove_selected(&mut self) {
let Some(index) = self.selected_queue_index() else {
return;
};
if index >= self.queue.len() {
return;
}
let current = self.current;
let was_playing = self.playing;
self.list.unselect_all();
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();
let visible_indices = self.filtered_queue_indices();
let replacement = visible_indices
.iter()
.copied()
.find(|candidate| *candidate >= index)
.or_else(|| visible_indices.last().copied());
if let Some(replacement) = replacement
&& let Some(row) = self.row_for_queue_index(replacement)
{
self.list.select_row(Some(&row));
}
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(index) = self.selected_queue_index() else {
return;
};
if index >= self.queue.len() {
return;
}
let target = index.saturating_add_signed(direction);
if target >= self.queue.len() || target == index {
return;
}
self.list.unselect_all();
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.row_for_queue_index(target) {
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.row_for_queue_index(index) {
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.sync_compact_queue_peek();
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(520, 166);
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.rebuild_config();
self.sync_theme_controls();
self.sync_motion_control();
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.row_for_queue_index(index) {
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(),
library_sources: self.library_sources.clone(),
active_source: self.active_source.clone(),
playlists: self.playlists.clone(),
theme: self.theme,
motion_mode: *self.motion_mode.borrow(),
};
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");
window.add_css_class(session.theme.css_class());
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let (topbar, open_button, library_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,
queue_search,
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 visual_palette = Rc::new(RefCell::new(session.theme.palette()));
let motion_mode = Rc::new(RefCell::new(session.motion_mode));
let (
stage,
title,
detail,
status,
_status_row,
visualizer,
artwork,
artwork_placeholder,
seek,
elapsed,
total,
previous,
play_button,
next,
seek_back,
seek_forward,
shuffle_button,
repeat_button,
volume_button,
volume,
cue_menu,
) = build_stage(&spectrum_bands, &visual_palette, &motion_mode);
volume.set_value(volume_value);
content.append(&stage);
root.append(&content);
let compact = build_compact(&visual_palette);
let config = build_config();
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"));
view_stack.add_named(&config.root, Some("config"));
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(),
library_sources: normalized_source_paths(session.library_sources.clone()),
active_source: session
.active_source
.as_ref()
.map(|path| fs::canonicalize(path).unwrap_or_else(|_| path.clone())),
playlists: normalized_playlists(session.playlists.clone()),
theme: session.theme,
visual_palette,
motion_mode,
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,
queue_search: queue_search.clone(),
queue_move_up: move_up_button.clone(),
queue_move_down: move_down_button.clone(),
queue_remove: remove_button.clone(),
queue_clear: clear_button.clone(),
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(),
compact_queue_button: compact.queue_peek.button.clone(),
compact_queue_list: compact.queue_peek.list.clone(),
compact_queue_summary: compact.queue_peek.summary.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()],
config: config.clone(),
spectrum_bands,
visualizer: visualizer.clone(),
artwork_placeholders: vec![artwork_placeholder, compact.artwork_placeholder.clone()],
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,
&library_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);
connect_queue_peek(&compact.queue_peek, &state);
connect_queue_search(&queue_search, &state);
connect_config(&window, &config, &state);
state.borrow_mut().bus_watch = Some(connect_bus(&playbin, &state)?);
connect_updates(&visualizer, &state);
connect_shortcuts(&window, &open_button, &library_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 library = gtk::Button::new();
let library_content = gtk::Box::new(gtk::Orientation::Horizontal, 7);
library_content.append(>k::Image::from_icon_name("folder-music-symbolic"));
library_content.append(>k::Label::new(Some("LIBRARY")));
library.set_child(Some(&library_content));
library.set_tooltip_text(Some("Manage folders and playlists (Ctrl+L)"));
library.add_css_class("folder-button");
bar.append(&mark);
bar.append(&identity);
bar.append(&spacer);
bar.append(&library);
bar.append(&mode);
bar.append(&open);
(bar, open, library, mode)
}
fn build_config() -> ConfigView {
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
root.add_css_class("config-shell");
let topbar = gtk::Box::new(gtk::Orientation::Horizontal, 10);
topbar.add_css_class("config-topbar");
let back = gtk::Button::new();
let back_content = gtk::Box::new(gtk::Orientation::Horizontal, 7);
back_content.append(>k::Image::from_icon_name("go-previous-symbolic"));
back_content.append(>k::Label::new(Some("PLAYER")));
back.set_child(Some(&back_content));
back.add_css_class("mode-button");
back.set_tooltip_text(Some("Return to the player (Escape)"));
let identity = gtk::Box::new(gtk::Orientation::Vertical, 1);
identity.set_hexpand(true);
let title = gtk::Label::new(Some("YOUR CEOL"));
title.add_css_class("brand");
title.set_xalign(0.0);
let subtitle = gtk::Label::new(Some(
"LIBRARY / PLAYLISTS / APPEARANCE / EVERYTHING LOCAL",
));
subtitle.add_css_class("brand-subtitle");
subtitle.set_xalign(0.0);
identity.append(&title);
identity.append(&subtitle);
let add_folders = gtk::Button::new();
let add_content = gtk::Box::new(gtk::Orientation::Horizontal, 7);
add_content.append(>k::Image::from_icon_name("folder-new-symbolic"));
add_content.append(>k::Label::new(Some("ADD FOLDERS")));
add_folders.set_child(Some(&add_content));
add_folders.add_css_class("config-primary");
add_folders.set_tooltip_text(Some("Remember and load one or more music folders"));
topbar.append(&back);
topbar.append(&identity);
topbar.append(&add_folders);
let content = gtk::Box::new(gtk::Orientation::Vertical, 10);
content.add_css_class("config-content");
content.set_vexpand(true);
let heading = gtk::Box::new(gtk::Orientation::Vertical, 2);
let heading_title = gtk::Label::new(Some("Shape your listening space"));
heading_title.add_css_class("config-page-title");
heading_title.set_xalign(0.0);
let heading_detail = gtk::Label::new(Some(
"SWITCH BETWEEN SAVED FOLDERS OR CAPTURE THE CURRENT QUEUE AS A PLAYLIST",
));
heading_detail.add_css_class("config-page-detail");
heading_detail.set_xalign(0.0);
let notice = gtk::Label::new(Some("READY — YOUR MUSIC STAYS LOCAL"));
notice.add_css_class("config-notice");
notice.set_xalign(0.0);
heading.append(&heading_title);
heading.append(&heading_detail);
let theme_strip = gtk::Box::new(gtk::Orientation::Horizontal, 16);
theme_strip.add_css_class("theme-strip");
let theme_copy = gtk::Box::new(gtk::Orientation::Vertical, 2);
theme_copy.add_css_class("theme-copy");
theme_copy.set_hexpand(true);
let theme_eyebrow = gtk::Label::new(Some("APPEARANCE"));
theme_eyebrow.add_css_class("eyebrow");
theme_eyebrow.set_xalign(0.0);
let theme_detail = gtk::Label::new(Some("SIGNAL — Electric cyan"));
theme_detail.add_css_class("config-section-title");
theme_detail.set_xalign(0.0);
theme_detail.set_hexpand(true);
theme_detail.set_ellipsize(gtk::pango::EllipsizeMode::End);
let motion_button = gtk::Button::with_label("MOTION / ALIVE");
motion_button.add_css_class("motion-button");
motion_button.add_css_class("motion-alive");
motion_button.set_tooltip_text(Some(MotionMode::Alive.description()));
let appearance_title_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
appearance_title_row.add_css_class("appearance-title-row");
appearance_title_row.append(&theme_detail);
appearance_title_row.append(&motion_button);
let theme_hint = gtk::Label::new(Some("Ctrl+T cycles themes · Ctrl+Shift+T cycles motion"));
theme_hint.add_css_class("config-description");
theme_hint.set_xalign(0.0);
theme_copy.append(&theme_eyebrow);
theme_copy.append(&appearance_title_row);
theme_copy.append(&theme_hint);
let theme_options = gtk::Box::new(gtk::Orientation::Horizontal, 2);
theme_options.add_css_class("theme-options");
theme_options.set_homogeneous(true);
let theme_buttons: Vec<_> = Theme::ALL
.into_iter()
.map(|theme| {
let button = gtk::Button::with_label(theme.label());
button.add_css_class("theme-option");
button.add_css_class(theme.option_css_class());
button.set_tooltip_text(Some(theme.description()));
theme_options.append(&button);
button
})
.collect();
theme_strip.append(&theme_copy);
theme_strip.append(&theme_options);
let columns = gtk::Box::new(gtk::Orientation::Horizontal, 16);
columns.set_homogeneous(true);
columns.set_vexpand(true);
let source_card = gtk::Box::new(gtk::Orientation::Vertical, 8);
source_card.add_css_class("config-card");
source_card.set_vexpand(true);
let source_eyebrow = gtk::Label::new(Some("LIBRARY SOURCES"));
source_eyebrow.add_css_class("eyebrow");
source_eyebrow.set_xalign(0.0);
let source_title = gtk::Label::new(Some("Saved music folders"));
source_title.add_css_class("config-section-title");
source_title.set_xalign(0.0);
let source_detail = gtk::Label::new(Some(
"Choose one source, combine every online source, or remember another location.",
));
source_detail.add_css_class("config-description");
source_detail.set_xalign(0.0);
source_detail.set_wrap(true);
let source_frame = gtk::Box::new(gtk::Orientation::Vertical, 0);
source_frame.add_css_class("config-list-frame");
source_frame.set_vexpand(true);
let source_empty = gtk::Label::new(Some(
"No folders saved yet.\nAdd one or several music folders above.",
));
source_empty.add_css_class("config-empty");
source_empty.set_justify(gtk::Justification::Center);
source_empty.set_vexpand(true);
let source_list = gtk::ListBox::new();
source_list.set_selection_mode(gtk::SelectionMode::Single);
source_list.set_activate_on_single_click(false);
let source_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.min_content_height(108)
.vexpand(true)
.child(&source_list)
.build();
source_frame.append(&source_empty);
source_frame.append(&source_scroll);
let source_actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
source_actions.add_css_class("config-actions");
let source_load = gtk::Button::with_label("LOAD SELECTED");
source_load.add_css_class("config-primary");
source_load.set_hexpand(true);
source_load.set_sensitive(false);
let source_load_all = gtk::Button::with_label("LOAD ALL");
source_load_all.set_hexpand(true);
source_load_all.set_sensitive(false);
let source_remove = icon_button("user-trash-symbolic", "Forget selected folder");
source_remove.add_css_class("config-danger");
source_remove.set_sensitive(false);
source_actions.append(&source_load);
source_actions.append(&source_load_all);
source_actions.append(&source_remove);
source_card.append(&source_eyebrow);
source_card.append(&source_title);
source_card.append(&source_detail);
source_card.append(&source_frame);
source_card.append(&source_actions);
let playlist_card = gtk::Box::new(gtk::Orientation::Vertical, 8);
playlist_card.add_css_class("config-card");
playlist_card.add_css_class("playlist-card");
playlist_card.set_vexpand(true);
let playlist_eyebrow = gtk::Label::new(Some("PLAYLISTS"));
playlist_eyebrow.add_css_class("eyebrow");
playlist_eyebrow.set_xalign(0.0);
let playlist_title = gtk::Label::new(Some("Reusable queues"));
playlist_title.add_css_class("config-section-title");
playlist_title.set_xalign(0.0);
let playlist_detail = gtk::Label::new(Some(
"Save reusable queues, reload them anytime, or export portable M3U8 files.",
));
playlist_detail.add_css_class("config-description");
playlist_detail.set_xalign(0.0);
playlist_detail.set_wrap(true);
let save_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
save_row.add_css_class("config-save-row");
let playlist_name = gtk::Entry::new();
playlist_name.set_hexpand(true);
playlist_name.set_placeholder_text(Some("Playlist name"));
playlist_name.set_max_length(80);
let playlist_save = gtk::Button::with_label("SAVE QUEUE");
playlist_save.add_css_class("config-primary");
playlist_save.set_sensitive(false);
save_row.append(&playlist_name);
save_row.append(&playlist_save);
let playlist_frame = gtk::Box::new(gtk::Orientation::Vertical, 0);
playlist_frame.add_css_class("config-list-frame");
playlist_frame.set_vexpand(true);
let playlist_empty = gtk::Label::new(Some(
"No playlists saved yet.\nBuild a queue, name it, and save it here.",
));
playlist_empty.add_css_class("config-empty");
playlist_empty.set_justify(gtk::Justification::Center);
playlist_empty.set_vexpand(true);
let playlist_list = gtk::ListBox::new();
playlist_list.set_selection_mode(gtk::SelectionMode::Single);
playlist_list.set_activate_on_single_click(false);
let playlist_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.min_content_height(108)
.vexpand(true)
.child(&playlist_list)
.build();
playlist_frame.append(&playlist_empty);
playlist_frame.append(&playlist_scroll);
let playlist_actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
playlist_actions.add_css_class("config-actions");
let playlist_load = gtk::Button::with_label("REPLACE QUEUE");
playlist_load.add_css_class("config-primary");
playlist_load.set_hexpand(true);
playlist_load.set_sensitive(false);
let playlist_append = gtk::Button::with_label("APPEND");
playlist_append.set_hexpand(true);
playlist_append.set_sensitive(false);
let playlist_export = icon_button(
"document-save-symbolic",
"Export selected playlist as a local M3U8 file",
);
playlist_export.set_sensitive(false);
let playlist_remove = icon_button("user-trash-symbolic", "Delete selected playlist");
playlist_remove.add_css_class("config-danger");
playlist_remove.set_sensitive(false);
playlist_actions.append(&playlist_load);
playlist_actions.append(&playlist_append);
playlist_actions.append(&playlist_export);
playlist_actions.append(&playlist_remove);
playlist_card.append(&playlist_eyebrow);
playlist_card.append(&playlist_title);
playlist_card.append(&playlist_detail);
playlist_card.append(&save_row);
playlist_card.append(&playlist_frame);
playlist_card.append(&playlist_actions);
columns.append(&source_card);
columns.append(&playlist_card);
content.append(&heading);
content.append(¬ice);
content.append(&theme_strip);
content.append(&columns);
root.append(&topbar);
let page_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vscrollbar_policy(gtk::PolicyType::Automatic)
.vexpand(true)
.child(&content)
.build();
root.append(&page_scroll);
ConfigView {
root,
back,
add_folders,
source_list,
source_empty,
source_scroll,
source_load,
source_remove,
source_load_all,
playlist_name,
playlist_save,
playlist_list,
playlist_empty,
playlist_scroll,
playlist_load,
playlist_append,
playlist_export,
playlist_remove,
notice,
theme_buttons,
theme_detail,
motion_button,
}
}
fn build_queue() -> (
gtk::Box,
gtk::ListBox,
gtk::ScrolledWindow,
gtk::Label,
gtk::Label,
gtk::SearchEntry,
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 search = gtk::SearchEntry::new();
search.add_css_class("queue-search");
search.set_placeholder_text(Some("Find in queue…"));
search.set_tooltip_text(Some(
"Filter this queue without changing playback order (Ctrl+F)",
));
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");
move_up.set_sensitive(false);
let move_down = icon_button("go-down-symbolic", "Move selected track down (Alt+Down)");
move_down.add_css_class("queue-action");
move_down.set_sensitive(false);
let remove = gtk::Button::with_label("REMOVE");
remove.add_css_class("queue-action");
remove.set_hexpand(true);
remove.set_sensitive(false);
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_sensitive(false);
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("queue-empty");
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(&search);
card.append(&actions);
card.append(&empty);
card.append(&scroll);
(
card, list, scroll, empty, count, search, move_up, move_down, remove, clear,
)
}
fn build_artwork(
size: i32,
class_name: &str,
visual_palette: Rc<RefCell<VisualPalette>>,
) -> (gtk::Overlay, gtk::Picture, gtk::DrawingArea) {
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(move |_, context, width, height| {
let width = f64::from(width);
let height = f64::from(height);
let palette = *visual_palette.borrow();
context.set_source_rgb(palette.surface[0], palette.surface[1], palette.surface[2]);
let _ = context.paint();
context.set_source_rgba(
palette.accent[0],
palette.accent[1],
palette.accent[2],
0.12,
);
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(
palette.accent[0],
palette.accent[1],
palette.accent[2],
0.78,
);
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(palette.muted[0], palette.muted[1], palette.muted[2], 0.66);
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, placeholder)
}
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_queue_peek() -> QueuePeek {
let button = gtk::MenuButton::new();
button.set_icon_name("view-list-symbolic");
button.add_css_class("cue-menu-button");
button.set_sensitive(false);
button.set_tooltip_text(Some("Queue peek — choose a track without expanding"));
let popover = gtk::Popover::new();
popover.set_autohide(true);
let content = gtk::Box::new(gtk::Orientation::Vertical, 8);
content.add_css_class("queue-peek-popover");
let eyebrow = gtk::Label::new(Some("QUEUE PEEK"));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
let summary = gtk::Label::new(Some("QUEUE EMPTY"));
summary.add_css_class("queue-peek-summary");
summary.set_xalign(0.0);
let list = gtk::ListBox::new();
list.add_css_class("queue-peek-list");
list.set_selection_mode(gtk::SelectionMode::None);
list.set_activate_on_single_click(true);
let open_full = gtk::Button::with_label("OPEN FULL QUEUE");
open_full.add_css_class("queue-action");
open_full.set_tooltip_text(Some("Expand to the complete queue and visualizer"));
content.append(&eyebrow);
content.append(&summary);
content.append(&list);
content.append(&open_full);
popover.set_child(Some(&content));
button.set_popover(Some(&popover));
QueuePeek {
button,
list,
summary,
open_full,
}
}
fn build_compact(visual_palette: &Rc<RefCell<VisualPalette>>) -> CompactView {
let root = gtk::Box::new(gtk::Orientation::Vertical, 6);
root.add_css_class("compact-shell");
let top = gtk::Box::new(gtk::Orientation::Horizontal, 6);
top.add_css_class("compact-topbar");
let live = gtk::Label::new(Some("● CEOL / 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 queue_peek = build_queue_peek();
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(&queue_peek.button);
top.append(&cue_menu.button);
top.append(&status);
top.append(&expand_button);
let body = gtk::Box::new(gtk::Orientation::Horizontal, 10);
let (artwork_frame, artwork, artwork_placeholder) =
build_artwork(92, "artwork-compact", visual_palette.clone());
body.append(&artwork_frame);
let center = gtk::Box::new(gtk::Orientation::Vertical, 3);
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, 5);
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, 4);
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,
artwork_placeholder,
title,
detail,
status,
seek,
elapsed,
total,
previous,
play,
next,
seek_back,
seek_forward,
shuffle,
repeat,
volume,
queue_peek,
cue_menu,
}
}
#[allow(clippy::type_complexity)]
fn build_stage(
spectrum_bands: &Rc<RefCell<Vec<f32>>>,
visual_palette: &Rc<RefCell<VisualPalette>>,
motion_mode: &Rc<RefCell<MotionMode>>,
) -> (
gtk::Box,
gtk::Label,
gtk::Label,
gtk::Label,
gtk::Box,
gtk::DrawingArea,
gtk::Picture,
gtk::DrawingArea,
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(),
visual_palette.clone(),
motion_mode.clone(),
);
let (artwork_frame, artwork, artwork_placeholder) =
build_artwork(168, "artwork-large", visual_palette.clone());
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,
artwork_placeholder,
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,
library: >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 and playlists"));
for mime in [
"audio/mpeg",
"audio/flac",
"audio/ogg",
"audio/opus",
"audio/x-wav",
"audio/mp4",
"audio/aac",
"audio/x-mpegurl",
"application/vnd.apple.mpegurl",
] {
filter.add_mime_type(mime);
}
filter.add_pattern("*.m3u");
filter.add_pattern("*.M3U");
filter.add_pattern("*.m3u8");
filter.add_pattern("*.M3U8");
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);
library.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().show_config();
}
});
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_selected(move |_, _| {
if let Some(state) = weak_state.upgrade()
&& let Ok(state) = state.try_borrow()
{
state.sync_queue_actions();
}
});
let weak_state = Rc::downgrade(state);
list.connect_row_activated(move |_, row| {
if let Some(state) = weak_state.upgrade() {
let queue_index = state.borrow().queue_index_for_row(row.index());
if let Some(queue_index) = queue_index {
state.borrow_mut().play_track(queue_index);
}
}
});
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_queue_peek(menu: &QueuePeek, state: &Rc<RefCell<MusicApp>>) {
let weak_state = Rc::downgrade(state);
let menu_button = menu.button.clone();
menu.list.connect_row_activated(move |_, row| {
let Some(state) = weak_state.upgrade() else {
return;
};
let index = {
let state = state.borrow();
usize::try_from(row.index()).ok().and_then(|row_index| {
queue_preview_indices(state.queue.len(), state.current, 6)
.get(row_index)
.copied()
})
};
if let Some(index) = index {
state.borrow_mut().play_track(index);
menu_button.popdown();
}
});
let weak_state = Rc::downgrade(state);
let menu_button = menu.button.clone();
menu.open_full.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
let mut state = state.borrow_mut();
state.set_widget_mode(false);
state.save_session();
menu_button.popdown();
}
});
}
fn connect_queue_search(search: >k::SearchEntry, state: &Rc<RefCell<MusicApp>>) {
search.connect_stop_search(|search| search.set_text(""));
let key_controller = gtk::EventControllerKey::new();
key_controller.set_propagation_phase(gtk::PropagationPhase::Capture);
let escape_search = search.clone();
key_controller.connect_key_pressed(move |_, key, _, _| {
if key == gdk::Key::Escape && !escape_search.text().is_empty() {
escape_search.set_text("");
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
search.add_controller(key_controller);
let weak_state = Rc::downgrade(state);
search.connect_search_changed(move |_| {
if let Some(state) = weak_state.upgrade()
&& let Ok(state) = state.try_borrow()
{
state.rebuild_queue();
}
});
let weak_state = Rc::downgrade(state);
search.connect_activate(move |_| {
let Some(state) = weak_state.upgrade() else {
return;
};
let first_match = state.borrow().filtered_queue_indices().first().copied();
if let Some(index) = first_match {
state.borrow_mut().play_track(index);
}
});
}
fn connect_config(
window: >k::ApplicationWindow,
config: &ConfigView,
state: &Rc<RefCell<MusicApp>>,
) {
for (button, theme) in config.theme_buttons.iter().cloned().zip(Theme::ALL) {
let weak_state = Rc::downgrade(state);
button.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().set_theme(theme);
}
});
}
let weak_state = Rc::downgrade(state);
config.motion_button.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().cycle_motion_mode();
}
});
let weak_state = Rc::downgrade(state);
config.back.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().set_widget_mode(false);
}
});
let weak_state = Rc::downgrade(state);
let folder_window = window.clone();
config.add_folders.connect_clicked(move |_| {
let dialog = gtk::FileChooserNative::builder()
.title("Remember music folders")
.transient_for(&folder_window)
.action(gtk::FileChooserAction::SelectFolder)
.accept_label("Remember and load")
.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().remember_and_load_sources(paths);
}
}
});
dialog.show();
});
let weak_state = Rc::downgrade(state);
config.source_list.connect_row_selected(move |_, _| {
if let Some(state) = weak_state.upgrade() {
if let Ok(state) = state.try_borrow() {
state.sync_config_actions();
}
}
});
let weak_state = Rc::downgrade(state);
config.source_list.connect_row_activated(move |_, _| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().load_selected_source();
}
});
let weak_state = Rc::downgrade(state);
config.source_load.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().load_selected_source();
}
});
let weak_state = Rc::downgrade(state);
config.source_load_all.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().load_all_sources();
}
});
let weak_state = Rc::downgrade(state);
config.source_remove.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().remove_selected_source();
}
});
let weak_state = Rc::downgrade(state);
config.playlist_name.connect_changed(move |_| {
if let Some(state) = weak_state.upgrade() {
if let Ok(state) = state.try_borrow() {
state.sync_config_actions();
}
}
});
let weak_state = Rc::downgrade(state);
config.playlist_name.connect_activate(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().save_queue_as_playlist();
}
});
let weak_state = Rc::downgrade(state);
config.playlist_save.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().save_queue_as_playlist();
}
});
let weak_state = Rc::downgrade(state);
config.playlist_list.connect_row_selected(move |_, _| {
if let Some(state) = weak_state.upgrade() {
if let Ok(state) = state.try_borrow() {
state.sync_config_actions();
}
}
});
let weak_state = Rc::downgrade(state);
config.playlist_list.connect_row_activated(move |_, _| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().load_selected_playlist(false);
}
});
let weak_state = Rc::downgrade(state);
config.playlist_load.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().load_selected_playlist(false);
}
});
let weak_state = Rc::downgrade(state);
config.playlist_append.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().load_selected_playlist(true);
}
});
let weak_state = Rc::downgrade(state);
let export_window = window.clone();
config.playlist_export.connect_clicked(move |_| {
let Some(state) = weak_state.upgrade() else {
return;
};
let playlist = {
let state = state.borrow();
state
.config
.playlist_list
.selected_row()
.and_then(|row| usize::try_from(row.index()).ok())
.and_then(|index| state.playlists.get(index))
.cloned()
};
let Some(playlist) = playlist else {
return;
};
let dialog = gtk::FileChooserDialog::new(
Some(&format!("Export {}", playlist.name)),
Some(&export_window),
gtk::FileChooserAction::Save,
&[
("Cancel", gtk::ResponseType::Cancel),
("Export M3U8", gtk::ResponseType::Accept),
],
);
dialog.set_modal(true);
dialog.set_current_name(&playlist_export_file_name(&playlist.name));
let filter = gtk::FileFilter::new();
filter.set_name(Some("M3U8 playlist"));
filter.add_mime_type("application/vnd.apple.mpegurl");
filter.add_pattern("*.m3u8");
filter.add_pattern("*.M3U8");
dialog.add_filter(&filter);
let weak_state = Rc::downgrade(&state);
dialog.connect_response(move |dialog, response| {
if response != gtk::ResponseType::Accept {
dialog.close();
return;
}
let result = dialog
.file()
.and_then(|file| file.path())
.ok_or_else(|| std::io::Error::other("no local export path selected"))
.and_then(|path| write_m3u8_playlist(path, &playlist.tracks));
if let Some(state) = weak_state.upgrade() {
match result {
Ok(path) => {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("playlist.m3u8");
state
.borrow()
.set_config_notice(&format!("EXPORTED {file_name}"));
}
Err(_) => state
.borrow()
.set_config_notice("EXPORT FAILED — CHOOSE A WRITABLE LOCAL FOLDER"),
}
}
dialog.close();
});
dialog.present();
});
let weak_state = Rc::downgrade(state);
config.playlist_remove.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().remove_selected_playlist();
}
});
}
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,
library: >k::Button,
state: &Rc<RefCell<MusicApp>>,
) {
let controller = gtk::EventControllerKey::new();
let weak_state = Rc::downgrade(state);
let open = open.clone();
let library = library.clone();
let shortcut_window = window.clone();
controller.connect_key_pressed(move |_, key, _, modifiers| {
let config_visible = weak_state
.upgrade()
.is_some_and(|state| state.borrow().config_is_visible());
if config_visible {
if key == gdk::Key::Escape {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().set_widget_mode(false);
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gdk::Key::m | gdk::Key::M)
{
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().set_widget_mode(true);
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gdk::Key::t | gdk::Key::T)
{
if let Some(state) = weak_state.upgrade() {
if modifiers.contains(gdk::ModifierType::SHIFT_MASK) {
state.borrow_mut().cycle_motion_mode();
} else {
state.borrow_mut().cycle_theme();
}
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gdk::Key::f | gdk::Key::F)
{
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().focus_queue_search();
}
return glib::Propagation::Stop;
}
return glib::Propagation::Proceed;
}
if key == gdk::Key::Escape
&& let Some(state) = weak_state.upgrade()
&& !state.borrow().queue_search.text().is_empty()
{
state.borrow().queue_search.set_text("");
return glib::Propagation::Stop;
}
let focused_control =
gtk::prelude::GtkWindowExt::focus(&shortcut_window).is_some_and(|widget| {
widget.is::<gtk::Button>()
|| widget.is::<gtk::Scale>()
|| widget.is::<gtk::Entry>()
|| 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) => {
library.emit_clicked()
}
gdk::Key::o | gdk::Key::O => open.emit_clicked(),
gdk::Key::l | gdk::Key::L => library.emit_clicked(),
gdk::Key::f | gdk::Key::F => {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().focus_queue_search();
}
}
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::t | gdk::Key::T => {
if let Some(state) = weak_state.upgrade() {
if modifiers.contains(gdk::ModifierType::SHIFT_MASK) {
state.borrow_mut().cycle_motion_mode();
} else {
state.borrow_mut().cycle_theme();
}
}
}
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>>>,
visual_palette: Rc<RefCell<VisualPalette>>,
motion_mode: Rc<RefCell<MotionMode>>,
) {
let started = Instant::now();
area.set_draw_func(move |_, context, width, height| {
let width = f64::from(width);
let height = f64::from(height);
let palette = *visual_palette.borrow();
let motion_mode = *motion_mode.borrow();
let time = started.elapsed().as_secs_f64();
let motion_strength = match motion_mode {
MotionMode::Still => 0.0,
MotionMode::Calm => 0.42,
MotionMode::Alive => 1.0,
};
let motion_time = time * motion_strength;
context.set_source_rgb(
palette.background[0],
palette.background[1],
palette.background[2],
);
let _ = context.paint();
let drift = 0.5 + 0.5 * (motion_time * 0.32).sin();
let glow = gtk::cairo::RadialGradient::new(
width * (0.25 + drift * 0.5),
height * 0.28,
0.0,
width * (0.25 + drift * 0.5),
height * 0.28,
height * 0.72,
);
glow.add_color_stop_rgba(
0.0,
palette.accent[0],
palette.accent[1],
palette.accent[2],
0.065 + 0.045 * motion_strength,
);
glow.add_color_stop_rgba(
1.0,
palette.background[0],
palette.background[1],
palette.background[2],
0.0,
);
let _ = context.set_source(&glow);
let _ = context.paint();
context.set_line_width(1.0);
context.set_source_rgba(
palette.accent[0],
palette.accent[1],
palette.accent[2],
0.075,
);
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(palette.muted[0], palette.muted[1], palette.muted[2], 0.76);
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(
palette.accent[0],
palette.accent[1],
palette.accent[2],
0.52,
);
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(
palette.accent[0],
palette.accent[1],
palette.accent[2],
0.16,
);
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 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;
let shimmer_amount = 0.12 * motion_strength;
let shimmer = 1.0 - shimmer_amount
+ shimmer_amount * (motion_time * 2.4 + index as f64 * 0.18).sin();
context.set_source_rgba(
palette.accent[0] * shimmer,
palette.accent[1] * shimmer,
palette.accent[2] * shimmer,
0.28 + level * 0.70,
);
context.rectangle(x, y, bar_width, bar_height);
let _ = context.fill();
context.set_source_rgba(
palette.accent[0],
palette.accent[1],
palette.accent[2],
0.10 + level * 0.25,
);
context.rectangle(x, height * 0.55 + 3.0, bar_width, bar_height * 0.35);
let _ = context.fill();
context.set_source_rgba(
palette.accent_2[0],
palette.accent_2[1],
palette.accent_2[2],
0.55 + level * 0.40,
);
context.rectangle(x, y, bar_width, 1.5);
let _ = context.fill();
}
context.set_source_rgba(
palette.accent_2[0],
palette.accent_2[1],
palette.accent_2[2],
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 = (motion_time * 3.0 + f64::from(point) * 0.31).sin()
+ (motion_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(palette.muted[0], palette.muted[1], palette.muted[2], 0.68);
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 config_row(icon: &str, title: &str, detail: &str, tag: Option<&str>) -> gtk::ListBoxRow {
let row = gtk::ListBoxRow::new();
row.add_css_class("config-row");
let content = gtk::Box::new(gtk::Orientation::Horizontal, 10);
let icon = gtk::Image::from_icon_name(icon);
icon.add_css_class("config-row-icon");
let labels = gtk::Box::new(gtk::Orientation::Vertical, 2);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(title));
title.add_css_class("config-row-title");
title.set_xalign(0.0);
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
let detail = gtk::Label::new(Some(detail));
detail.add_css_class("config-row-detail");
detail.set_xalign(0.0);
detail.set_ellipsize(gtk::pango::EllipsizeMode::Middle);
labels.append(&title);
labels.append(&detail);
content.append(&icon);
content.append(&labels);
if let Some(tag) = tag {
let tag = gtk::Label::new(Some(tag));
tag.add_css_class("config-tag");
content.append(&tag);
}
row.set_child(Some(&content));
row
}
fn source_name(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
fn cleaned_playlist_name(name: &str) -> String {
name.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(80)
.collect()
}
fn playlist_export_file_name(name: &str) -> String {
let safe: String = cleaned_playlist_name(name)
.chars()
.map(|character| {
if character.is_control()
|| matches!(
character,
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'
)
{
'-'
} else {
character
}
})
.collect();
let safe = safe.trim_matches(|character| matches!(character, ' ' | '.' | '-'));
format!(
"{}.m3u8",
if safe.is_empty() {
"ceol-playlist"
} else {
safe
}
)
}
fn with_m3u8_extension(mut path: PathBuf) -> PathBuf {
let has_extension = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("m3u8"));
if !has_extension {
path.set_extension("m3u8");
}
path
}
fn exported_playlist_entry(path: &Path, destination: &Path) -> String {
let base = destination.parent().unwrap_or_else(|| Path::new("."));
let relative = if path.is_relative() {
Some(path)
} else {
path.strip_prefix(base).ok()
};
if let Some(relative) = relative {
let relative = relative.to_string_lossy();
if !relative.is_empty() && !relative.contains(['\n', '\r']) {
return relative.replace('\\', "/");
}
}
gio::File::for_path(path).uri().to_string()
}
fn m3u8_contents(destination: &Path, tracks: &[PathBuf]) -> String {
let mut output = String::from("#EXTM3U\n");
for track in tracks {
let title = track
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("Local track")
.replace(['\n', '\r'], " ");
output.push_str(&format!("#EXTINF:-1,{title}\n"));
output.push_str(&exported_playlist_entry(track, destination));
output.push('\n');
}
output
}
fn write_m3u8_playlist(path: PathBuf, tracks: &[PathBuf]) -> std::io::Result<PathBuf> {
let path = with_m3u8_extension(path);
let temporary = path.with_extension("m3u8.tmp");
fs::write(&temporary, m3u8_contents(&path, tracks))?;
if let Err(error) = fs::rename(&temporary, &path) {
let _ = fs::remove_file(temporary);
return Err(error);
}
Ok(path)
}
fn normalized_source_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut unique = HashSet::new();
let mut paths: Vec<PathBuf> = paths
.into_iter()
.map(|path| fs::canonicalize(&path).unwrap_or(path))
.filter(|path| unique.insert(path.clone()))
.collect();
paths.sort_by_cached_key(|path| path.to_string_lossy().to_lowercase());
paths
}
fn normalized_playlists(playlists: Vec<Playlist>) -> Vec<Playlist> {
let mut normalized: Vec<Playlist> = Vec::new();
for playlist in playlists {
let name = cleaned_playlist_name(&playlist.name);
if name.is_empty() {
continue;
}
let mut known = HashSet::new();
let tracks = playlist
.tracks
.into_iter()
.filter(|path| known.insert(path.clone()))
.collect();
if let Some(existing) = normalized
.iter_mut()
.find(|existing| existing.name.eq_ignore_ascii_case(&name))
{
existing.name = name;
existing.tracks = tracks;
} else {
normalized.push(Playlist { name, tracks });
}
}
normalized.sort_by_cached_key(|playlist| playlist.name.to_lowercase());
normalized
}
fn track_matches_search(track: &Track, query: &str) -> bool {
let terms: Vec<String> = query
.split_whitespace()
.map(|term| term.to_lowercase())
.collect();
if terms.is_empty() {
return true;
}
let haystack = format!(
"{} {} {} {} {} {} {}",
track.title,
track.detail,
track.artist.as_deref().unwrap_or_default(),
track.album.as_deref().unwrap_or_default(),
track.codec.as_deref().unwrap_or_default(),
parent_name(&track.path),
track.path.to_string_lossy()
)
.to_lowercase();
terms.iter().all(|term| haystack.contains(term))
}
fn queue_preview_indices(queue_len: usize, current: Option<usize>, limit: usize) -> Vec<usize> {
if queue_len == 0 || limit == 0 {
return Vec::new();
}
let visible = queue_len.min(limit);
let maximum_start = queue_len.saturating_sub(visible);
let start = current
.filter(|index| *index < queue_len)
.unwrap_or(0)
.min(maximum_start);
(start..start + visible).collect()
}
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 compact_status_text(status: &str) -> String {
match status {
"SESSION READY" => "READY".to_string(),
"QUEUE COMPLETE" => "DONE".to_string(),
"PLAYBACK ERROR" => "ERROR".to_string(),
_ if status.starts_with("BUFFERING ") => status.replacen("BUFFERING", "BUFFER", 1),
_ => status.to_string(),
}
}
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 is_supported_playlist(path: &Path) -> bool {
path.is_file() && has_supported_playlist_extension(path)
}
fn local_playlist_entries(path: &Path) -> Vec<PathBuf> {
let Ok(data) = fs::read(path) else {
return Vec::new();
};
let base = path.parent().unwrap_or_else(|| Path::new("."));
String::from_utf8_lossy(&data)
.lines()
.filter_map(|line| {
let entry = line.trim().trim_start_matches('\u{feff}').trim();
if entry.is_empty() || entry.starts_with('#') {
return None;
}
if entry.starts_with("file://") {
return gio::File::for_uri(entry).path();
}
if entry.contains("://") {
return None;
}
let entry = PathBuf::from(entry);
Some(if entry.is_absolute() {
entry
} else {
base.join(entry)
})
})
.collect()
}
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 visited_playlists = HashSet::new();
let mut seen_tracks = HashSet::new();
let mut tracks = Vec::new();
while let Some(path) = pending.pop() {
if is_supported_audio(&path) {
let track = fs::canonicalize(&path).unwrap_or(path);
if seen_tracks.insert(track.clone()) {
tracks.push(track);
}
continue;
}
if is_supported_playlist(&path) {
let playlist = fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if visited_playlists.insert(playlist) {
pending.extend(local_playlist_entries(&path).into_iter().rev());
}
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 has_supported_playlist_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "m3u" | "m3u8"))
}
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 compacts_long_status_labels_for_the_widget() {
assert_eq!(compact_status_text("SESSION READY"), "READY");
assert_eq!(compact_status_text("QUEUE COMPLETE"), "DONE");
assert_eq!(compact_status_text("PLAYBACK ERROR"), "ERROR");
assert_eq!(compact_status_text("BUFFERING 57%"), "BUFFER 57%");
assert_eq!(compact_status_text("PLAYING"), "PLAYING");
}
#[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 queue_search_matches_metadata_format_and_folder_terms() {
let mut track =
Track::from_path(PathBuf::from("/music/Ambient Sets/neon_nights-live.flac"));
track.artist = Some("Ada Rivers".to_string());
track.album = Some("Glass Horizon".to_string());
track.codec = Some("FLAC".to_string());
assert!(track_matches_search(&track, ""));
assert!(track_matches_search(&track, "NEON"));
assert!(track_matches_search(&track, "ada horizon"));
assert!(track_matches_search(&track, "flac ambient"));
assert!(!track_matches_search(&track, "ada techno"));
}
#[test]
fn queue_preview_prioritizes_upcoming_tracks_and_fills_boundary_space() {
assert_eq!(queue_preview_indices(10, Some(3), 4), [3, 4, 5, 6]);
assert_eq!(queue_preview_indices(10, Some(8), 4), [6, 7, 8, 9]);
assert_eq!(queue_preview_indices(3, None, 6), [0, 1, 2]);
assert_eq!(queue_preview_indices(3, Some(9), 2), [0, 1]);
assert!(queue_preview_indices(0, None, 6).is_empty());
assert!(queue_preview_indices(3, Some(1), 0).is_empty());
}
#[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,
}],
library_sources: vec![PathBuf::from("/music"), PathBuf::from("/archive/music")],
active_source: Some(PathBuf::from("/music")),
playlists: vec![Playlist {
name: "Night drive".to_string(),
tracks: vec![PathBuf::from("/music/test.flac")],
}],
theme: Theme::Ultraviolet,
motion_mode: MotionMode::Calm,
};
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);
assert_eq!(restored.library_sources, session.library_sources);
assert_eq!(restored.active_source, session.active_source);
assert_eq!(restored.playlists, session.playlists);
assert_eq!(restored.theme, Theme::Ultraviolet);
assert_eq!(restored.motion_mode, MotionMode::Calm);
}
#[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")));
assert!(has_supported_playlist_extension(Path::new("mix.M3U8")));
assert!(!has_supported_playlist_extension(Path::new("mix.json")));
}
#[test]
fn expands_local_m3u_playlists_in_order_without_cycles_or_duplicates() {
let root = env::temp_dir().join(format!(
"ceol-m3u-test-{}-{}",
std::process::id(),
shuffle_seed()
));
let tracks = root.join("tracks");
fs::create_dir_all(&tracks).expect("test track folder should be created");
fs::write(tracks.join("01 opening.MP3"), []).expect("first track should be created");
fs::write(tracks.join("02 closing.flac"), []).expect("second track should be created");
let playlist = root.join("journey.m3u8");
let nested = root.join("nested.m3u");
let closing_uri = gio::File::for_path(tracks.join("02 closing.flac")).uri();
fs::write(
&playlist,
format!(
"\u{feff}#EXTM3U\n{closing_uri}\nhttps://example.com/remote.mp3\nnested.m3u\ntracks/02 closing.flac\n"
),
)
.expect("main playlist should be created");
fs::write(&nested, "#EXTM3U\ntracks/01 opening.MP3\njourney.m3u8\n")
.expect("nested playlist should be created");
let expanded = expand_audio_paths(vec![playlist]);
let names: Vec<_> = expanded
.iter()
.filter_map(|path| path.file_name().and_then(|name| name.to_str()))
.collect();
assert_eq!(names, ["02 closing.flac", "01 opening.MP3"]);
fs::remove_dir_all(root).expect("test playlist folder should be removed");
}
#[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);
}
#[test]
fn cleans_and_limits_playlist_names() {
assert_eq!(cleaned_playlist_name(" Night drive "), "Night drive");
assert_eq!(cleaned_playlist_name(" \n\t "), "");
assert_eq!(cleaned_playlist_name(&"x".repeat(100)).chars().count(), 80);
assert_eq!(
playlist_export_file_name(" Night / Drive:*? "),
"Night - Drive.m3u8"
);
assert_eq!(playlist_export_file_name(".. / .."), "ceol-playlist.m3u8");
}
#[test]
fn exports_portable_m3u8_content_and_preserves_offline_entries() {
let root = env::temp_dir().join(format!(
"ceol-m3u-export-test-{}-{}",
std::process::id(),
shuffle_seed()
));
let music = root.join("music");
fs::create_dir_all(&music).expect("test music folder should be created");
let local_track = music.join("01 local song.flac");
fs::write(&local_track, []).expect("test local track should be created");
let offline_track = PathBuf::from("/offline-library/02 archived song.mp3");
let destination = root.join("Portable Mix");
let exported =
write_m3u8_playlist(destination, &[local_track.clone(), offline_track.clone()])
.expect("playlist should export atomically");
assert_eq!(exported.file_name().unwrap(), "Portable Mix.m3u8");
let contents = fs::read_to_string(&exported).expect("export should be readable");
assert!(contents.starts_with("#EXTM3U\n"));
assert!(contents.contains("#EXTINF:-1,01 local song\nmusic/01 local song.flac\n"));
assert!(contents.contains("file:///offline-library/02%20archived%20song.mp3"));
assert_eq!(
local_playlist_entries(&exported),
[local_track, offline_track]
);
assert!(!root.join("Portable Mix.m3u8.tmp").exists());
fs::remove_dir_all(root).expect("test export folder should be removed");
}
#[test]
fn normalizes_playlist_names_and_tracks() {
let playlists = normalized_playlists(vec![
Playlist {
name: " Focus ".to_string(),
tracks: vec![PathBuf::from("a.mp3"), PathBuf::from("a.mp3")],
},
Playlist {
name: "focus".to_string(),
tracks: vec![PathBuf::from("b.flac")],
},
Playlist {
name: " ".to_string(),
tracks: Vec::new(),
},
]);
assert_eq!(playlists.len(), 1);
assert_eq!(playlists[0].name, "focus");
assert_eq!(playlists[0].tracks, [PathBuf::from("b.flac")]);
}
#[test]
fn normalizes_and_sorts_remembered_sources() {
let sources = normalized_source_paths(vec![
PathBuf::from("/music/zeta"),
PathBuf::from("/music/Alpha"),
PathBuf::from("/music/zeta"),
]);
assert_eq!(
sources,
[PathBuf::from("/music/Alpha"), PathBuf::from("/music/zeta")]
);
}
#[test]
fn legacy_sessions_default_new_library_fields() {
let restored: Session = serde_json::from_str(r#"{"queue":[],"widget_mode":true}"#)
.expect("legacy sessions should remain readable");
assert!(restored.widget_mode);
assert!(restored.library_sources.is_empty());
assert!(restored.active_source.is_none());
assert!(restored.playlists.is_empty());
assert_eq!(restored.theme, Theme::Signal);
assert_eq!(restored.motion_mode, MotionMode::Alive);
}
#[test]
fn themes_serialize_to_stable_names_and_cycle() {
assert_eq!(
serde_json::to_string(&Theme::Signal).unwrap(),
r#""signal""#
);
assert_eq!(
serde_json::to_string(&Theme::Ultraviolet).unwrap(),
r#""ultraviolet""#
);
assert_eq!(Theme::Signal.next(), Theme::Ember);
assert_eq!(Theme::Daylight.next(), Theme::Signal);
}
#[test]
fn motion_profiles_serialize_and_cycle_stably() {
assert_eq!(
serde_json::to_string(&MotionMode::Still).unwrap(),
r#""still""#
);
assert_eq!(
serde_json::to_string(&MotionMode::Alive).unwrap(),
r#""alive""#
);
assert_eq!(MotionMode::Still.next(), MotionMode::Calm);
assert_eq!(MotionMode::Alive.next(), MotionMode::Still);
assert_eq!(MotionMode::Still.transition_seconds(), 0.0);
}
}