mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
a6ee0668c8
* feat(crossfade): add "trim silence between tracks" toggle
New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.
Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.
The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.
* feat(crossfade): trim silence between tracks (waveform-driven)
Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.
- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
silence + content bounds, using the peak curve, a low absolute cut and
a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
contentEnd - crossfadeSecs, when the current track ends in real
trailing silence, so the fade overlaps music. Guarded once per play
generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
built source is try_seek'd past the next track's leading silence before
append, then seek_offset/samples_played are re-anchored so position is
content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
crossfadePreload.ts with a fixed ~30 s budget before the track needs to
play (widened by trailing silence so the early advance keeps the
budget). Also fired right after a seek into the window so jumping near
the end still buffers in time.
Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.
* feat(crossfade): recommend hot cache for trim; probe B-head regardless
Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.
Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.
* feat(crossfade): content-driven smart crossfade overlap
Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.
* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI
Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.
* feat(crossfade): don't double-fade a track that already fades out
Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.
audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.
* feat(crossfade): rename the smart crossfade mode to "AutoDJ"
User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.
* feat(crossfade): standard ~2s blend for hard loud→loud meetings
When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.
* fix(crossfade): keep B's fade-in across the B-head start-offset seek
EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.
* feat(crossfade): gate AutoDJ early fade on next-track readiness
The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.
When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.
* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload
With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.
- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
the progress task treats it like crossfade-off, so the early timer never
fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
(wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
engine crossfade is preserved there. When the next track never readies, A
plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
it to skip the 8s start throttle so the RAM slot fills before the fade.
* docs(changelog): AutoDJ content-aware crossfade (PR #1122)
Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
406 lines
8.7 KiB
CSS
406 lines
8.7 KiB
CSS
/* ─── Queue Panel ─── */
|
|
.queue-panel {
|
|
grid-area: queue;
|
|
background: var(--bg-sidebar);
|
|
border-left: 1px solid var(--border-subtle);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
min-height: 0; /* allow 1fr row to shrink freely */
|
|
}
|
|
|
|
.queue-panel.queue-drop-active {
|
|
box-shadow: inset 0 0 0 2px var(--accent);
|
|
background: linear-gradient(var(--accent-dim), var(--accent-dim)), var(--bg-sidebar);
|
|
}
|
|
|
|
.queue-header {
|
|
height: 64px;
|
|
box-sizing: border-box;
|
|
padding: 0 var(--space-4);
|
|
background: var(--bg-app);
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Slim now-playing indicator shown only in queue mode when the now-playing
|
|
card is collapsed — keeps the current track visible (it is not in the list
|
|
in queue mode). Click re-expands the full card. */
|
|
.queue-now-playing-mini {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
width: 100%;
|
|
padding: 6px var(--space-4);
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
color: var(--accent);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.queue-now-playing-mini:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.queue-action-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
transition: background var(--transition-fast), color var(--transition-fast);
|
|
flex-shrink: 0;
|
|
}
|
|
.queue-action-btn:hover:not(:disabled) {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
.queue-action-btn:disabled {
|
|
opacity: 0.35;
|
|
cursor: default;
|
|
}
|
|
|
|
.queue-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 0 var(--space-3);
|
|
height: 44px;
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
background: var(--bg-app);
|
|
}
|
|
|
|
.crossfade-popover {
|
|
position: absolute;
|
|
top: calc(100% + 10px);
|
|
right: 0;
|
|
width: 170px;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
padding: 10px 12px;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
|
z-index: 200;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.crossfade-popover-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--accent);
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
.crossfade-popover-value {
|
|
margin-left: auto;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
font-family: monospace;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.crossfade-popover-slider {
|
|
width: 100%;
|
|
accent-color: var(--accent);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.crossfade-popover-range {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.crossfade-popover-modes {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.crossfade-popover-mode {
|
|
flex: 1 1 0;
|
|
min-width: 0;
|
|
padding: 5px 6px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
|
}
|
|
|
|
.crossfade-popover-mode:hover {
|
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.crossfade-popover-mode.active {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
color: var(--bg-app);
|
|
}
|
|
|
|
.queue-toolbar-sep {
|
|
width: 1px;
|
|
height: 18px;
|
|
background: var(--border-subtle);
|
|
margin: 0 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.queue-round-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 50%;
|
|
color: var(--text-primary);
|
|
background: var(--bg-hover);
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: background var(--transition-fast), color var(--transition-fast);
|
|
flex-shrink: 0;
|
|
}
|
|
.queue-round-btn:hover:not(:disabled) {
|
|
background: var(--border);
|
|
color: var(--accent);
|
|
}
|
|
.queue-round-btn:disabled {
|
|
opacity: 0.35;
|
|
cursor: default;
|
|
}
|
|
.queue-round-btn.active {
|
|
color: var(--bg-app);
|
|
background: var(--accent);
|
|
}
|
|
|
|
.queue-current-track {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
}
|
|
|
|
.queue-current-track-body {
|
|
padding: var(--space-3) var(--space-4);
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: flex-start;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
.queue-current-cover {
|
|
width: 90px;
|
|
height: 90px;
|
|
flex-shrink: 0;
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
background: var(--card-placeholder-bg);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
position: relative;
|
|
}
|
|
|
|
.queue-current-cover img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.queue-current-cover .fallback {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.queue-current-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.queue-current-info h3 {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
margin: 0;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.queue-current-sub {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
transition: color var(--transition-fast);
|
|
}
|
|
|
|
/* Album link sits on the .queue-current-sub element itself; artist links are
|
|
.is-link spans nested inside it (OpenArtistRefInline). Both get the same
|
|
accent + underline affordance on hover as clickable names everywhere else. */
|
|
.queue-current-sub.is-link,
|
|
.queue-current-sub .is-link {
|
|
cursor: pointer;
|
|
}
|
|
.queue-current-sub.is-link:hover,
|
|
.queue-current-sub .is-link:hover {
|
|
color: var(--accent);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.queue-current-enrichment {
|
|
font-size: 11px;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.queue-current-tech {
|
|
width: 100%;
|
|
font-size: 9px;
|
|
font-family: monospace;
|
|
letter-spacing: 0.05em;
|
|
background: var(--bg-hover);
|
|
color: var(--accent);
|
|
padding: 3px var(--space-4);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
text-align: center;
|
|
backdrop-filter: blur(8px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.queue-current-tech-source {
|
|
flex-shrink: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
line-height: 0;
|
|
opacity: 0.92;
|
|
}
|
|
|
|
.queue-current-tech-main {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* Two-line layout: base info + RG badge on top, optional ReplayGain
|
|
values underneath when the badge is expanded. The stack wraps both
|
|
rows so the source icon stays vertically centered next to the whole
|
|
text block. */
|
|
.queue-current-tech-stack {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1px;
|
|
}
|
|
|
|
.queue-current-tech-row {
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
/* Pill-shaped toggle that signals "this track has ReplayGain metadata"
|
|
without committing screen space to the numbers — values appear in the
|
|
tooltip and on the second line when expanded. */
|
|
.queue-current-tech-rg-badge {
|
|
flex-shrink: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 1px 5px 1px 6px;
|
|
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
color: var(--accent);
|
|
border-radius: 999px;
|
|
font: inherit;
|
|
font-size: 8px;
|
|
letter-spacing: 0.06em;
|
|
cursor: pointer;
|
|
transition: background 0.12s, border-color 0.12s, transform 0.12s;
|
|
}
|
|
.queue-current-tech-rg-badge:hover {
|
|
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
|
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
|
}
|
|
.queue-current-tech-rg-badge svg {
|
|
transition: transform 0.18s ease;
|
|
}
|
|
.queue-current-tech-rg-badge--open svg {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.queue-current-tech-rg {
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
font-size: 8px;
|
|
opacity: 0.72;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.queue-current-tech-metric {
|
|
margin: 0;
|
|
padding: 0;
|
|
border: none;
|
|
background: transparent;
|
|
font: inherit;
|
|
letter-spacing: inherit;
|
|
line-height: inherit;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.queue-current-tech-metric:hover {
|
|
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
}
|
|
|
|
.queue-current-tech-metric--lufs-reanalyze {
|
|
display: inline-flex;
|
|
align-items: baseline;
|
|
gap: 0;
|
|
padding: 2px 4px;
|
|
margin: -2px -4px;
|
|
max-width: 100%;
|
|
min-width: 0;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.queue-divider {
|
|
padding: var(--space-3) var(--space-4) 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|