Files
Psychotoxical-psysonic/src/styles/layout/queue-panel.css
T
cucadmuh a6ee0668c8 feat(crossfade): AutoDJ — content-aware silence-trimming crossfade (#1122)
* 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.
2026-06-18 02:15:20 +03:00

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;
}