chore: import upstream snapshot with attribution
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
/* ───────────── Top bar ───────────── */
|
||||
|
||||
.app:not(.is-import) .appbar {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ─── Collapsed icon strip (double-click to toggle) ─── */
|
||||
|
||||
/* Expandable body wrapper — same grid-row trick as .widget-body */
|
||||
.appbar-body {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
transition: grid-template-rows 220ms ease, opacity 180ms ease;
|
||||
}
|
||||
.appbar-body-inner {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* Strip wrapper — collapsed by default */
|
||||
.appbar-icon-strip {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
min-height: 0; /* prevent flex parent from reserving space when row is 0fr */
|
||||
transition: grid-template-rows 220ms ease, opacity 200ms ease 20ms;
|
||||
}
|
||||
.appbar-strip-inner {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Collapsed state */
|
||||
.app.appbar-collapsed .appbar-body {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
height: 0; /* force flex child to zero — grid-template-rows alone isn't enough in a flex container */
|
||||
}
|
||||
.app.appbar-collapsed .appbar-icon-strip {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
.app.appbar-collapsed .appbar {
|
||||
min-height: 52px;
|
||||
height: 52px;
|
||||
padding: 6px 14px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.app.appbar-collapsed .appbar-strip-inner {
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.strip-sq {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--glass-line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: background 120ms, border-color 120ms, opacity 120ms;
|
||||
}
|
||||
.strip-sq:hover { background: rgba(255,255,255,0.08) !important; }
|
||||
.strip-sq-process:hover { filter: brightness(1.1); }
|
||||
.strip-sq-brand {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,0.05);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.strip-sq-url {
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--ink-faint);
|
||||
margin-right: 3px;
|
||||
}
|
||||
.strip-sq-stems {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.strip-sq-stem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 120ms, background 120ms;
|
||||
}
|
||||
.strip-sq-stem:hover { opacity: 0.85 !important; }
|
||||
.strip-sq-stem.inactive { opacity: 0.2; }
|
||||
.strip-sq-process {
|
||||
background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%);
|
||||
color: #17130c;
|
||||
border-color: transparent;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.strip-sq-process.loading {
|
||||
cursor: progress;
|
||||
filter: saturate(0.92);
|
||||
}
|
||||
.strip-sq-process.loading svg {
|
||||
display: none;
|
||||
}
|
||||
.strip-sq-process.loading::before {
|
||||
content: "";
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(currentColor 0deg 270deg, transparent 270deg 360deg);
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
|
||||
animation: spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
font-size: 11px;
|
||||
color: rgba(216,222,230,0.35);
|
||||
margin-top: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.brand-update-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(100, 200, 120, 0.10);
|
||||
border: 1px solid rgba(100, 200, 120, 0.22);
|
||||
color: #6dcc82;
|
||||
font-size: 9px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand-update-chip:hover {
|
||||
background: rgba(100, 200, 120, 0.2);
|
||||
color: #88dfa0;
|
||||
}
|
||||
|
||||
.appbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 24px 22px;
|
||||
border: 1px solid var(--glass-line);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
radial-gradient(circle at 82% -20%, rgba(216, 168, 74, 0.11), transparent 20rem),
|
||||
linear-gradient(180deg, rgba(27, 39, 48, 0.76), rgba(13, 22, 30, 0.84));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.045),
|
||||
var(--shadow-panel);
|
||||
transition: padding 220ms ease;
|
||||
}
|
||||
|
||||
.brand-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
width: 190px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.beta-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid rgba(216, 168, 74, 0.65);
|
||||
border-radius: 6px;
|
||||
background: var(--gold-soft);
|
||||
color: var(--gold-bright);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 13px;
|
||||
color: var(--ink-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-urlbar,
|
||||
.post-process-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appbar-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appbar-import-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appbar-import-panel .import-input-row {
|
||||
grid-template-columns: minmax(0, 1fr) clamp(140px, 14vw, 220px);
|
||||
gap: clamp(12px, 2vw, 28px);
|
||||
padding-bottom: 22px;
|
||||
}
|
||||
|
||||
.appbar-import-panel .url-wrap {
|
||||
min-height: 58px;
|
||||
padding: 0 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 11px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
|
||||
rgba(3, 9, 14, 0.72);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.035),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.32),
|
||||
0 0 0 0 rgba(216, 168, 74, 0);
|
||||
transition:
|
||||
border-color var(--t-base) var(--easing),
|
||||
box-shadow var(--t-base) var(--easing),
|
||||
background-color var(--t-base) var(--easing);
|
||||
}
|
||||
|
||||
.appbar-import-panel .url-wrap:focus-within {
|
||||
border-color: rgba(216, 168, 74, 0.56);
|
||||
background-color: rgba(6, 13, 19, 0.86);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.045),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.34),
|
||||
0 0 0 3px rgba(216, 168, 74, 0.08),
|
||||
0 14px 34px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.appbar-import-panel .url-prefix {
|
||||
color: #cbd2dc;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.appbar-import-panel input[type="url"] {
|
||||
font-size: clamp(15px, 0.95vw, 18px);
|
||||
height: 100%;
|
||||
color: #edf0f4;
|
||||
caret-color: var(--gold-bright);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.appbar-import-panel .btn-primary {
|
||||
height: 58px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border-radius: 11px;
|
||||
font-size: clamp(14px, 0.9vw, 16px);
|
||||
}
|
||||
|
||||
.appbar-import-panel .stem-choice-row {
|
||||
grid-template-columns: clamp(110px, 11vw, 156px) repeat(6, minmax(0, 1fr));
|
||||
gap: clamp(8px, 1vw, 14px);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.appbar-import-panel .stem-choice-row > span {
|
||||
font-size: clamp(12px, 0.8vw, 14px);
|
||||
}
|
||||
|
||||
.appbar-import-panel .stem-choice {
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
font-size: clamp(12px, 0.8vw, 14px);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, currentColor 9%, transparent), transparent),
|
||||
rgba(8, 15, 22, 0.42);
|
||||
}
|
||||
|
||||
.url-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.url-prefix {
|
||||
flex-shrink: 0;
|
||||
color: #d7dbe2;
|
||||
}
|
||||
|
||||
.appbar-form input[type="url"],
|
||||
.appbar-import-panel input[type="url"],
|
||||
.import-card input[type="url"] {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 19px;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
appearance: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.appbar-form input[type="url"]::placeholder,
|
||||
.appbar-import-panel input[type="url"]::placeholder,
|
||||
.import-card input[type="url"]::placeholder {
|
||||
color: #9da3ad;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.appbar-import-panel input[type="url"]::selection {
|
||||
background: rgba(216, 168, 74, 0.28);
|
||||
color: #fff6de;
|
||||
}
|
||||
|
||||
.appbar-import-panel input[type="url"] {
|
||||
font-size: clamp(15px, 0.95vw, 18px);
|
||||
color: #edf0f4;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: 56px;
|
||||
min-width: 150px;
|
||||
background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%);
|
||||
color: #17130c;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 24px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.38),
|
||||
0 16px 36px rgba(216, 168, 74, 0.18);
|
||||
transition:
|
||||
filter var(--t-base) var(--easing),
|
||||
transform var(--t-fast) var(--easing);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.82;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.btn-primary.loading {
|
||||
filter: saturate(0.92);
|
||||
}
|
||||
|
||||
.btn-primary.loading .icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-primary.loading::before {
|
||||
content: "";
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(currentColor 0deg 270deg, transparent 270deg 360deg);
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
|
||||
animation: spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn-primary.loading::before {
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(216, 168, 74, 0.1), transparent 34rem),
|
||||
radial-gradient(circle at 0% 45%, rgba(216, 168, 74, 0.055), transparent 28rem),
|
||||
radial-gradient(circle at 100% 45%, rgba(216, 168, 74, 0.055), transparent 28rem),
|
||||
linear-gradient(180deg, #081016 0%, #050a0f 100%);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: 0;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.surface-panel {
|
||||
border: 1px solid var(--glass-line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(28, 39, 48, 0.82), rgba(15, 23, 31, 0.86));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.045),
|
||||
var(--shadow-panel);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1640px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 24px 28px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.app:not(.is-import) {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
padding: 16px 29px 0;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app.is-import .now-playing,
|
||||
.app.is-import #lanes {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .import-page,
|
||||
.app:not(.is-import) .site-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: auto;
|
||||
padding: 18px 14px 22px;
|
||||
color: #8d929a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.site-footer > span {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.site-footer nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.site-footer a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-footer a:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
height: 42px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(18, 26, 34, 0.75);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
/* ──────────────────────────────────────────────
|
||||
catalog.css — Left catalog / history panel
|
||||
────────────────────────────────────────────── */
|
||||
|
||||
/* ─── Panel shell ─── */
|
||||
.catalog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(28,39,48,0.82), rgba(15,23,31,0.86));
|
||||
border: 1px solid var(--glass-line);
|
||||
border-radius: 12px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), var(--shadow-panel);
|
||||
padding: 12px 10px;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
top: 0;
|
||||
overflow: hidden;
|
||||
transition: padding 220ms ease;
|
||||
}
|
||||
.app.cat-collapsed .catalog { padding: 12px 8px; }
|
||||
|
||||
/* ─── Header (click to collapse) ─── */
|
||||
.catalog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 6px 10px;
|
||||
border-bottom: 1px solid rgba(157,170,182,0.1);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
transition: background 120ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.catalog-head:hover { background: rgba(31,43,52,0.55); }
|
||||
|
||||
.catalog-head h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.catalog-head .cat-count {
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
padding-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cat-chevron {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--ink-faint);
|
||||
transition: transform 220ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide text content when collapsed */
|
||||
.app.cat-collapsed .catalog-head h3,
|
||||
.app.cat-collapsed .catalog-head .cat-count { display: none; }
|
||||
.app.cat-collapsed .catalog-head { justify-content: center; padding: 10px 0; }
|
||||
.app.cat-collapsed .cat-chevron { transform: rotate(180deg); }
|
||||
|
||||
/* ─── Search ─── */
|
||||
.catalog-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(21,31,39,0.6);
|
||||
color: var(--ink-faint);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.catalog-search svg { flex-shrink: 0; }
|
||||
.catalog-search input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
.catalog-search input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ─── New folder button ─── */
|
||||
.new-folder-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--ink-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 120ms;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.new-folder-btn:hover {
|
||||
border-color: var(--gold-line);
|
||||
color: var(--gold);
|
||||
}
|
||||
.new-folder-btn svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
|
||||
/* ─── Catalog list ─── */
|
||||
.catalog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
/* ─── Folder ─── */
|
||||
.folder { display: flex; flex-direction: column; }
|
||||
.folder-head {
|
||||
--folder-color: var(--gold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
color: var(--ink-dim);
|
||||
transition: background 120ms, color 120ms;
|
||||
user-select: none;
|
||||
}
|
||||
.folder-head:hover { background: rgba(31,43,52,0.55); color: var(--ink); }
|
||||
.folder.drop-target .folder-head {
|
||||
background: rgba(216,168,74,0.10);
|
||||
box-shadow: inset 0 0 0 1px var(--gold-line);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.folder-head .f-chevron {
|
||||
width: 11px; height: 11px;
|
||||
transition: transform 180ms ease;
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.folder.collapsed .folder-head .f-chevron { transform: rotate(-90deg); }
|
||||
|
||||
.folder-head .f-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex-shrink: 0;
|
||||
color: var(--folder-color);
|
||||
}
|
||||
|
||||
.folder-head .f-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
cursor: inherit;
|
||||
}
|
||||
.folder-head .f-name[contenteditable="true"] {
|
||||
background: rgba(28,34,43,0.8);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.folder-head .f-count { font-size: 10.5px; color: var(--ink-faint); }
|
||||
|
||||
.folder-color-dot {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 1px solid rgba(255,255,255,0.22);
|
||||
border-radius: 999px;
|
||||
background: var(--folder-color);
|
||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.22);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.folder-color-dot:hover,
|
||||
.folder-color-dot:focus-visible {
|
||||
border-color: rgba(255,255,255,0.82);
|
||||
outline: none;
|
||||
}
|
||||
.folder-color-dot.active {
|
||||
box-shadow: 0 0 0 2px rgba(255,255,255,0.18), inset 0 0 0 1px rgba(0,0,0,0.24);
|
||||
border-color: rgba(255,255,255,0.86);
|
||||
}
|
||||
|
||||
.folder-head .f-del {
|
||||
width: 18px; height: 18px;
|
||||
border: none; background: transparent;
|
||||
color: var(--ink-faint);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
.folder-head:hover .f-del { opacity: 1; }
|
||||
.folder-head .f-del:hover { background: rgba(21,31,39,0.8); color: var(--danger); }
|
||||
|
||||
.folder-editor-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(3, 8, 13, 0.42);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.folder-editor {
|
||||
width: min(300px, calc(100vw - 32px));
|
||||
border: 1px solid var(--glass-line);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(28,39,48,0.98), rgba(13,21,29,0.98));
|
||||
box-shadow: var(--shadow-panel);
|
||||
padding: 12px;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.folder-editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.folder-editor-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ink-faint);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
.folder-editor-close:hover {
|
||||
background: rgba(21,31,39,0.8);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.folder-editor-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.folder-editor-name {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: rgba(10, 17, 24, 0.78);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 0 10px;
|
||||
outline: none;
|
||||
}
|
||||
.folder-editor-name:focus {
|
||||
border-color: var(--gold-line);
|
||||
box-shadow: 0 0 0 2px rgba(216,168,74,0.12);
|
||||
}
|
||||
|
||||
.folder-editor-colors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.folder-editor-colors .folder-color-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.folder-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.folder-editor-actions button {
|
||||
min-height: 30px;
|
||||
border-radius: 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 0 11px;
|
||||
}
|
||||
.folder-editor-cancel {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(21,31,39,0.52);
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.folder-editor-save {
|
||||
border: 1px solid var(--gold-line);
|
||||
background: rgba(216,168,74,0.16);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.folder-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 3px 0 3px 14px;
|
||||
margin-left: 7px;
|
||||
border-left: 1px solid rgba(157,170,182,0.1);
|
||||
}
|
||||
.folder.collapsed .folder-body { display: none; }
|
||||
.folder-empty {
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
padding: 5px 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ─── Track item ─── */
|
||||
.cat-item {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.cat-item:hover { background: rgba(31,43,52,0.55); border-color: var(--line); }
|
||||
.cat-item.active { background: rgba(31,43,52,0.55); border-color: var(--gold-line); }
|
||||
.cat-item.dragging { opacity: 0.4; }
|
||||
.cat-item[draggable="true"] { cursor: grab; }
|
||||
|
||||
.cat-thumb {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 7px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(21,31,39,0.8);
|
||||
}
|
||||
.cat-thumb svg,
|
||||
.cat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.cat-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.cat-title {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cat-sub {
|
||||
color: var(--ink-faint);
|
||||
font-size: 10.5px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cat-sub .dot { opacity: 0.4; }
|
||||
|
||||
.cat-status {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #5fbc56;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cat-status.processing {
|
||||
background: var(--gold);
|
||||
animation: cat-pulse 1.4s infinite;
|
||||
}
|
||||
@keyframes cat-pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} }
|
||||
|
||||
.cat-del {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--ink-faint);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 120ms, background 120ms, color 120ms;
|
||||
}
|
||||
|
||||
.cat-item:hover .cat-del,
|
||||
.cat-del:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cat-del:hover {
|
||||
background: rgba(21,31,39,0.8);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ─── Catalog footer ─── */
|
||||
.catalog-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 6px 0;
|
||||
border-top: 1px solid rgba(157,170,182,0.08);
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.catalog-foot svg { flex-shrink: 0; }
|
||||
|
||||
/* ─── Collapsed strip: just album thumbs ─── */
|
||||
.cat-collapsed-strip {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding-top: 4px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.app.cat-collapsed .cat-collapsed-strip { display: flex; }
|
||||
.app.cat-collapsed .catalog-search,
|
||||
.app.cat-collapsed .new-folder-btn,
|
||||
.app.cat-collapsed .catalog-list,
|
||||
.app.cat-collapsed .catalog-foot { display: none; }
|
||||
|
||||
.strip-thumb {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, transform 120ms;
|
||||
flex-shrink: 0;
|
||||
background: rgba(21,31,39,0.8);
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.strip-thumb:hover { transform: scale(1.06); }
|
||||
.strip-thumb.active {
|
||||
border-color: var(--gold-line);
|
||||
box-shadow: 0 0 0 2px rgba(216,168,74,0.18);
|
||||
}
|
||||
.strip-thumb.folder-thumb {
|
||||
color: var(--folder-color, var(--gold));
|
||||
}
|
||||
.strip-thumb.trash-thumb {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.strip-thumb svg,
|
||||
.strip-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.strip-thumb.folder-thumb svg,
|
||||
.strip-thumb.trash-thumb svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 8px auto;
|
||||
}
|
||||
+2424
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
/* ───────────── Job progress ───────────── */
|
||||
|
||||
.job {
|
||||
display: none !important;
|
||||
background: var(--panel);
|
||||
border: var(--border-w) solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.job-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#job-title {
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
#job-stage {
|
||||
font-size: 13px;
|
||||
color: var(--ink-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
background: var(--line);
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
progress::-webkit-progress-bar {
|
||||
background: var(--line);
|
||||
}
|
||||
progress::-webkit-progress-value {
|
||||
background: var(--ink);
|
||||
transition: width var(--t-base) var(--easing);
|
||||
}
|
||||
progress::-moz-progress-bar {
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
/* Compact technical detail line under the progress bar -- shows the
|
||||
truthful backend `stage` value while #job-stage rotates phrases. */
|
||||
.job-detail {
|
||||
margin-top: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-dim);
|
||||
opacity: 0.65;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
color: var(--ink-dim);
|
||||
border: var(--border-w) solid var(--line);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 4px 10px;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--t-base) var(--easing),
|
||||
color var(--t-base) var(--easing),
|
||||
border-color var(--t-base) var(--easing);
|
||||
}
|
||||
.cancel-btn:hover:not(:disabled) {
|
||||
background: var(--danger);
|
||||
color: var(--bg);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.cancel-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ───────────── Error ───────────── */
|
||||
|
||||
.error {
|
||||
background: rgba(214, 90, 74, 0.1);
|
||||
border: var(--border-w) solid rgba(214, 90, 74, 0.5);
|
||||
color: var(--danger);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.error-msg {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
}
|
||||
.retry-btn {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: var(--border-w) solid var(--danger);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 5px 12px;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--t-base) var(--easing),
|
||||
color var(--t-base) var(--easing);
|
||||
}
|
||||
.retry-btn:hover {
|
||||
background: var(--danger);
|
||||
color: var(--bg);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/* ───────────── Master fader + VU ─────────────
|
||||
* Console-style: tick-marked vertical fader on the left, twin segmented
|
||||
* LED VU meters on the right, both inside a recessed dark panel.
|
||||
* Custom rendering throughout — `appearance: none` so the browser
|
||||
* doesn't fall back to its blue-track default vertical slider, which
|
||||
* ignores ::-webkit-slider-thumb overrides and offsets the thumb. */
|
||||
|
||||
.np-master {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
height: 140px;
|
||||
padding: 8px 10px;
|
||||
background: linear-gradient(180deg, #141414, #0d0d0d);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Fader column with tick marks down both sides. */
|
||||
.master-fader-track {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 44px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
.master-fader-track::before,
|
||||
.master-fader-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 5px;
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--line-strong) 0,
|
||||
var(--line-strong) 1px,
|
||||
transparent 1px,
|
||||
transparent 8px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
.master-fader-track::before { left: 4px; }
|
||||
.master-fader-track::after { right: 4px; }
|
||||
|
||||
.master-fader {
|
||||
/* No `appearance: slider-vertical` — that's a legacy webkit shortcut
|
||||
that forces a blue-track default vertical slider AND ignores
|
||||
::-webkit-slider-thumb overrides (which is why the thumb floats
|
||||
left of center). `writing-mode: vertical-lr` rotates the
|
||||
horizontal range input, `direction: rtl` puts max at the top. */
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.master-fader::-webkit-slider-runnable-track {
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: #050505;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.master-fader::-moz-range-track {
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: #050505;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.master-fader::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
/* In writing-mode: vertical-lr, `width` is the long (horizontal) axis
|
||||
and `height` is the thin (vertical) axis. Pill-shaped horizontal
|
||||
handle. margin-left: -12px centers it on the 4px track. */
|
||||
width: 28px;
|
||||
height: 14px;
|
||||
margin-left: -12px;
|
||||
background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%);
|
||||
border: 1px solid #050505;
|
||||
border-radius: 3px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.7),
|
||||
0 1px 2px rgba(0, 0, 0, 0.7);
|
||||
cursor: pointer;
|
||||
}
|
||||
.master-fader::-moz-range-thumb {
|
||||
width: 28px;
|
||||
height: 14px;
|
||||
background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%);
|
||||
border: 1px solid #050505;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/* ───────────── Preview mixer ───────────── */
|
||||
|
||||
.mixer-column {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(68px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lane-header {
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
justify-items: center;
|
||||
position: relative;
|
||||
transition: opacity var(--t-base) var(--easing);
|
||||
color: var(--stem-color, #d8dde4);
|
||||
}
|
||||
|
||||
.lane-header[data-stem="vocals"] { --stem-color: #e54249; }
|
||||
.lane-header[data-stem="drums"] { --stem-color: #f06a14; }
|
||||
.lane-header[data-stem="bass"] { --stem-color: #eab414; }
|
||||
.lane-header[data-stem="guitar"] { --stem-color: #5bbf50; }
|
||||
.lane-header[data-stem="piano"] { --stem-color: #7f45d8; }
|
||||
.lane-header[data-stem="other"] { --stem-color: #3e8dcf; }
|
||||
.lane-header[data-stem="original"] { --stem-color: #a8b0bd; }
|
||||
|
||||
.lane-header + .lane-header {
|
||||
border-left: 1px solid var(--glass-line);
|
||||
}
|
||||
|
||||
.lane-header.muted {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.lane-stripe {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lane-content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.lane-name-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lane-icon {
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--stem-color);
|
||||
filter: drop-shadow(0 0 10px color-mix(in srgb, currentColor 34%, transparent));
|
||||
}
|
||||
|
||||
.lane-icon-toggle {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--stem-color);
|
||||
cursor: pointer;
|
||||
opacity: 0.38;
|
||||
transition:
|
||||
opacity var(--t-base) var(--easing),
|
||||
border-color var(--t-base) var(--easing),
|
||||
background-color var(--t-base) var(--easing),
|
||||
transform var(--t-fast) var(--easing);
|
||||
}
|
||||
|
||||
.lane-icon-toggle.active {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, currentColor 24%, transparent);
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
box-shadow: inset 0 0 18px color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
|
||||
.lane-icon-toggle:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.lane-icon-toggle:focus-visible {
|
||||
outline: 2px solid var(--gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lane-icon-toggle:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.lane-name {
|
||||
color: #d7dce3 !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lane-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 106px;
|
||||
align-items: end;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lane-mini-wave {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lane-dl {
|
||||
display: none;
|
||||
color: #d8dde4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lane-knob {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
width: 48px;
|
||||
height: 104px;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
background:
|
||||
repeating-linear-gradient(to bottom, rgba(148, 163, 184, 0.22) 0 1px, transparent 1px 12px),
|
||||
linear-gradient(90deg, transparent 0 44%, rgba(148, 163, 184, 0.2) 44% 56%, transparent 56% 100%);
|
||||
position: relative;
|
||||
cursor: ns-resize;
|
||||
/* --lane-pos: 0..1 = bottom..top. Default 0.5 = unity gain (vol=1
|
||||
of LANE_VOLUME_MAX=2). JS rewrites this when volume changes. */
|
||||
--lane-pos: 0.5;
|
||||
}
|
||||
|
||||
/* Fader thumb. Travels vertically along the track based on --lane-pos.
|
||||
The travel envelope is 100% - 18px (the thumb's own height) so the
|
||||
thumb never overshoots the track edges. The transition is only for
|
||||
programmatic moves (Reset, dblclick, wheel) -- during an active
|
||||
drag we kill the transition (.lane-knob.dragging rule below) so the
|
||||
thumb tracks the cursor 1:1 instead of chasing it through an
|
||||
easing curve, which reads as a rigid/laggy fader. */
|
||||
.lane-knob::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc((1 - var(--lane-pos)) * (100% - 14px));
|
||||
width: 30px;
|
||||
height: 14px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 3px;
|
||||
border: 1px solid #050505;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.2), transparent 42%),
|
||||
linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.7),
|
||||
0 0 0 1px color-mix(in srgb, currentColor 34%, transparent),
|
||||
0 1px 2px rgba(0, 0, 0, 0.72);
|
||||
transition: top 120ms var(--easing);
|
||||
will-change: top;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lane-knob.dragging::before {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Slight grip cue while actively dragging -- thumb gets a tighter
|
||||
shadow ring so it feels "picked up". */
|
||||
.lane-knob.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.lane-knob-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lane-knob.disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ms-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: #d8dde4;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background-color var(--t-base) var(--easing),
|
||||
transform var(--t-fast) var(--easing);
|
||||
}
|
||||
|
||||
.ms-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ms-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.ms-btn.mute.active {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ms-btn.solo.active {
|
||||
background: var(--gold);
|
||||
color: #17130c;
|
||||
}
|
||||
|
||||
.ms-btn:disabled {
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lane-controls .ms-btn.mute {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.lane-controls .ms-btn.solo {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.lane-vu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .preview-mixer-body {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .mixer-column {
|
||||
height: 100%;
|
||||
grid-template-columns: repeat(var(--visible-track-count, 6), minmax(52px, 1fr));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-header {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
grid-template-rows: 64px 1fr;
|
||||
padding: 7px 0 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-name-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-icon-toggle {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-controls {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
padding: 0 5px 38px;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-knob {
|
||||
width: 42px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-vu {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 72px;
|
||||
bottom: 48px;
|
||||
width: 12px;
|
||||
height: auto;
|
||||
transform: translateX(18px);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(to top,
|
||||
rgba(48, 197, 87, 0.14) 0 55%,
|
||||
rgba(225, 178, 46, 0.14) 55% 80%,
|
||||
rgba(214, 64, 64, 0.14) 80% 100%),
|
||||
rgba(4, 10, 15, 0.82);
|
||||
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-dl {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 6px;
|
||||
height: 34px;
|
||||
transform: none;
|
||||
border: 1px solid color-mix(in srgb, var(--stem-color) 28%, rgba(148, 163, 184, 0.18));
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 13%, transparent), transparent),
|
||||
rgba(255, 255, 255, 0.055);
|
||||
color: var(--stem-color);
|
||||
opacity: 0.9;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.035),
|
||||
0 6px 14px rgba(0, 0, 0, 0.16);
|
||||
transition:
|
||||
opacity var(--t-base) var(--easing),
|
||||
background-color var(--t-base) var(--easing),
|
||||
border-color var(--t-base) var(--easing),
|
||||
transform var(--t-fast) var(--easing);
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-dl svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
stroke-width: 2.15;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-dl:hover:not(.disabled) {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--stem-color) 58%, transparent);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 18%, transparent), transparent),
|
||||
rgba(255, 255, 255, 0.075);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-dl:focus-visible {
|
||||
outline: 2px solid var(--gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-dl.disabled {
|
||||
opacity: 0.28;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-vu::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
background:
|
||||
repeating-linear-gradient(to top, transparent 0 3px, rgba(0, 0, 0, 0.4) 3px 5px),
|
||||
linear-gradient(to top, #24bf55 0 58%, #e1b22e 58% 80%, #d64040 80% 100%);
|
||||
clip-path: inset(calc(100% - var(--vu-level, 0%)) 0 0 0);
|
||||
transition: clip-path 30ms linear;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .lane-vu::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
bottom: var(--vu-peak, 0%);
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
transition: bottom 80ms linear;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .master-strip {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 72px 1fr;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .master-strip .master-fader-track {
|
||||
height: calc(100% - 4px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .master-strip .master-fader-track::before,
|
||||
.app:not(.is-import) .master-strip .master-fader-track::after {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/* ───────────── Responsive ─────────────
|
||||
* Breakpoints:
|
||||
* ≤1100px — laptop / portrait tablet: now-playing card reflows to 3-col
|
||||
* with master spanning full width below transport.
|
||||
* ≤900px — landscape phone / small tablet: appbar wraps, lanes scroll
|
||||
* horizontally with a sticky mixer column, master compresses.
|
||||
* ≤640px — phone: mixer column narrower, brand smaller, transport
|
||||
* buttons full-width touch-friendly, master inline-compact.
|
||||
* ≤480px — small phone: tightest layout, art shrinks, paddings minimal.
|
||||
* pointer:coarse — touch input regardless of screen size: bump
|
||||
* interactive targets to a comfortable hit area.
|
||||
*/
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.import-card {
|
||||
width: min(100%, 900px);
|
||||
}
|
||||
.stem-choice-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.stem-choice-row > span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.appbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.appbar-import-panel .import-input-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(144px, 180px);
|
||||
}
|
||||
.appbar-import-panel .stem-choice-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.appbar-import-panel .stem-choice-row > span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.feature-strip {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 22px;
|
||||
}
|
||||
.feature-item + .feature-item {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--gold-line);
|
||||
padding-left: 0;
|
||||
padding-top: 22px;
|
||||
}
|
||||
.now-playing {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"art info transport"
|
||||
"art master master";
|
||||
gap: 14px 16px;
|
||||
}
|
||||
.np-art { grid-area: art; }
|
||||
.np-info { grid-area: info; }
|
||||
.np-transport { grid-area: transport; }
|
||||
.np-master { grid-area: master; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app {
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.appbar {
|
||||
gap: 12px;
|
||||
}
|
||||
.import-page {
|
||||
padding: 54px 18px 20px;
|
||||
}
|
||||
.import-copy h2 {
|
||||
font-size: 31px;
|
||||
}
|
||||
.import-card {
|
||||
margin-top: 36px;
|
||||
padding: 18px;
|
||||
}
|
||||
.import-input-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.appbar-import-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.appbar-import-panel .import-input-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
}
|
||||
.brand-group {
|
||||
flex: 1;
|
||||
}
|
||||
.appbar-form {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
flex: none;
|
||||
}
|
||||
/* Compress the master section to recover vertical space — the full
|
||||
140px panel makes the now-playing card too tall when stacked. */
|
||||
.np-master {
|
||||
height: 90px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
/* Lane scrolling: mixer column stays put, waves scroll under it. */
|
||||
.lanes-body,
|
||||
.lanes-ruler {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mixer-column,
|
||||
.lanes-ruler-gutter {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
background: var(--panel);
|
||||
}
|
||||
/* Keep the waves area readable when scrolling — without a min-width
|
||||
the waveform canvases collapse below ~250px and become unusable. */
|
||||
.waves-column,
|
||||
.lanes-ruler-time {
|
||||
min-width: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:root {
|
||||
--header-w: 200px;
|
||||
}
|
||||
.brand {
|
||||
font-size: 22px;
|
||||
}
|
||||
.brand-logo {
|
||||
width: 146px;
|
||||
}
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
}
|
||||
.import-copy h2 {
|
||||
font-size: 26px;
|
||||
}
|
||||
.import-copy p {
|
||||
font-size: 15px;
|
||||
}
|
||||
.stem-choice-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.feature-strip {
|
||||
padding: 22px;
|
||||
}
|
||||
.feature-item {
|
||||
grid-template-columns: 52px 1fr;
|
||||
}
|
||||
.now-playing {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas:
|
||||
"art info"
|
||||
"transport master";
|
||||
gap: 12px;
|
||||
}
|
||||
/* Inline transport + master side-by-side rather than stacked, so the
|
||||
card stays compact. Master is already compressed by the 900px rule. */
|
||||
.np-master {
|
||||
height: 80px;
|
||||
}
|
||||
.np-transport {
|
||||
align-self: center;
|
||||
}
|
||||
.lane-mini-wave {
|
||||
display: none; /* save horizontal space in the mixer column */
|
||||
}
|
||||
.np-title {
|
||||
font-size: 14px;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--header-w: 180px;
|
||||
--lane-h: 84px;
|
||||
}
|
||||
.app {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
/* Single-column stem buttons; the 2-col grid still squeezes labels at
|
||||
~360px because each button needs the icon + text + 8px gap. */
|
||||
.stem-choice-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.stem-choice {
|
||||
min-height: 44px;
|
||||
}
|
||||
.feature-strip {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.privacy-note {
|
||||
margin-top: 22px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.now-playing {
|
||||
padding: 10px 12px;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas:
|
||||
"art info"
|
||||
"transport transport"
|
||||
"master master";
|
||||
gap: 10px;
|
||||
}
|
||||
.np-art {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
.np-art-play {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.np-master {
|
||||
height: 72px;
|
||||
}
|
||||
.np-transport {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.chip {
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
/* Drop the second chip column to one row of just the essentials */
|
||||
.np-chips {
|
||||
gap: 6px;
|
||||
}
|
||||
/* Mixer rows: tighten internal padding so M/S/knob/cloud fit. */
|
||||
.lane-content {
|
||||
padding: 8px 36px 8px 8px;
|
||||
}
|
||||
.lane-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Tighten lane VU on phone so the row stays readable. */
|
||||
.lane-vu {
|
||||
width: 18px;
|
||||
right: 4px;
|
||||
}
|
||||
/* Keep waves usable — lower the min-width slightly on tiny screens. */
|
||||
.waves-column,
|
||||
.lanes-ruler-time {
|
||||
min-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-input devices: enlarge interactive targets to ≥40px hit area
|
||||
regardless of screen size. Desktop stays compact. */
|
||||
@media (pointer: coarse) {
|
||||
.ms-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.lane-knob {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.lane-knob-indicator {
|
||||
transform-origin: 50% 16px;
|
||||
height: 11px;
|
||||
top: 5px;
|
||||
}
|
||||
.lane-dl {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.btn-transport {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.avatar-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
/* Bigger master fader thumb so it's easier to grab. */
|
||||
.master-fader::-webkit-slider-thumb {
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
margin-left: -14px;
|
||||
}
|
||||
.master-fader::-moz-range-thumb {
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape orientation on short screens (phones held sideways): the
|
||||
now-playing card eats too much vertical space if it tries to stack.
|
||||
Prefer a single horizontal row. */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.now-playing {
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
grid-template-areas: "art info transport master";
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.np-master {
|
||||
height: 70px;
|
||||
}
|
||||
.np-art {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
/* ───────────── Import page ───────────── */
|
||||
|
||||
.import-page {
|
||||
min-height: clamp(420px, 60vh, 704px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: clamp(28px, 6vw, 78px) clamp(14px, 3vw, 24px) clamp(18px, 3vw, 24px);
|
||||
border: 1px solid var(--glass-line);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 22%, rgba(216, 168, 74, 0.1), transparent 23rem),
|
||||
radial-gradient(circle at 8% 45%, rgba(216, 168, 74, 0.06), transparent 22rem),
|
||||
radial-gradient(circle at 92% 45%, rgba(216, 168, 74, 0.06), transparent 22rem),
|
||||
linear-gradient(180deg, rgba(16, 26, 34, 0.78), rgba(8, 15, 22, 0.88));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.import-copy {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.import-copy h2 {
|
||||
margin: 0;
|
||||
color: #f1f3f5;
|
||||
font-size: clamp(24px, 3.4vw + 8px, 39px);
|
||||
line-height: 1.12;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.import-copy h2 span {
|
||||
color: var(--gold-bright);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.import-copy p {
|
||||
margin: 16px 0 0;
|
||||
color: #a9b0bb;
|
||||
font-size: clamp(14px, 1vw + 8px, 18px);
|
||||
}
|
||||
|
||||
.import-card {
|
||||
width: min(100%, 1088px);
|
||||
margin-top: clamp(20px, 4vw, 58px);
|
||||
padding: clamp(14px, 2vw, 19px) clamp(16px, 3vw, 36px) clamp(18px, 2.5vw, 28px);
|
||||
border-radius: 13px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.import-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--glass-line);
|
||||
}
|
||||
|
||||
.stem-choice-row {
|
||||
display: grid;
|
||||
grid-template-columns: 138px repeat(6, minmax(86px, 1fr));
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.stem-choice-row > span {
|
||||
color: #d9dde4;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stem-choice {
|
||||
min-height: 54px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
|
||||
rgba(8, 15, 22, 0.34);
|
||||
color: currentColor;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
border-color var(--t-base) var(--easing),
|
||||
background-color var(--t-base) var(--easing),
|
||||
box-shadow var(--t-base) var(--easing),
|
||||
transform var(--t-fast) var(--easing);
|
||||
}
|
||||
|
||||
.stem-choice:hover {
|
||||
transform: translateY(-1px);
|
||||
background-color: rgba(255, 255, 255, 0.045);
|
||||
box-shadow: inset 0 0 22px color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
/* Deselected state -- the user has unchecked this stem from the
|
||||
"stems to extract" set, so it's greyed out and won't appear in the
|
||||
studio dashboard after processing. Maintains layout (still occupies
|
||||
its grid slot) so the row doesn't reflow on toggle. */
|
||||
.stem-choice[aria-pressed="false"] {
|
||||
opacity: 0.32;
|
||||
border-color: rgba(148, 163, 184, 0.2) !important;
|
||||
background: rgba(8, 15, 22, 0.2);
|
||||
}
|
||||
|
||||
.stem-choice[aria-pressed="false"]:hover {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.stem-choice svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.stem-choice.vocals { color: #ff3f46; border-color: rgba(255, 63, 70, 0.28); }
|
||||
.stem-choice.drums { color: #ff7a1a; border-color: rgba(255, 122, 26, 0.24); }
|
||||
.stem-choice.bass { color: #ffd04d; border-color: rgba(255, 208, 77, 0.24); }
|
||||
.stem-choice.guitar { color: #3fc952; border-color: rgba(63, 201, 82, 0.24); }
|
||||
.stem-choice.piano { color: #974cff; border-color: rgba(151, 76, 255, 0.24); }
|
||||
.stem-choice.other { color: #3695ff; border-color: rgba(54, 149, 255, 0.24); }
|
||||
|
||||
.privacy-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 34px;
|
||||
color: #a6adb7;
|
||||
font-size: 15px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.feature-strip {
|
||||
width: min(100%, 1160px);
|
||||
min-height: 136px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: clamp(18px, 2.4vw, 36px);
|
||||
margin-top: clamp(22px, 4vw, 54px);
|
||||
padding: clamp(16px, 2.4vw, 24px) clamp(18px, 3vw, 46px);
|
||||
border-radius: 13px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(40px, 4vw, 68px) 1fr;
|
||||
align-items: center;
|
||||
gap: clamp(12px, 1.5vw, 24px);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.feature-item svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 48px;
|
||||
}
|
||||
|
||||
.feature-item + .feature-item {
|
||||
border-left: 1px solid var(--gold-line);
|
||||
padding-left: clamp(18px, 2.4vw, 42px);
|
||||
}
|
||||
|
||||
.feature-item strong {
|
||||
display: block;
|
||||
color: var(--ink);
|
||||
font-size: clamp(14px, 1vw + 6px, 16px);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.feature-item span {
|
||||
color: #a6adb7;
|
||||
font-size: clamp(13px, 0.8vw + 8px, 15px);
|
||||
}
|
||||
|
||||
/* The HTML hard-codes <br> in feature descriptions for desktop layout;
|
||||
on narrow viewports those forced breaks produce ragged 2-word lines.
|
||||
Collapse them so the text wraps naturally. */
|
||||
@media (max-width: 720px) {
|
||||
.feature-item br { display: none; }
|
||||
}
|
||||
|
||||
.wave-lines {
|
||||
position: absolute;
|
||||
top: 152px;
|
||||
width: 32%;
|
||||
height: 120px;
|
||||
opacity: 0.46;
|
||||
background:
|
||||
repeating-linear-gradient(108deg, transparent 0 24px, rgba(216, 168, 74, 0.52) 25px 27px, transparent 28px 50px);
|
||||
mask-image: radial-gradient(ellipse, black 0 48%, transparent 72%);
|
||||
}
|
||||
|
||||
.wave-left {
|
||||
left: 0;
|
||||
transform: skewY(-7deg);
|
||||
}
|
||||
|
||||
.wave-right {
|
||||
right: 0;
|
||||
transform: skewY(7deg);
|
||||
}
|
||||
|
||||
/* Decorative diagonal lines crowd the headline on narrow viewports
|
||||
(they're absolutely positioned at top: 152px; width: 32%) -- hide
|
||||
them below the tablet breakpoint to keep the headline area clean. */
|
||||
@media (max-width: 720px) {
|
||||
.wave-lines { display: none; }
|
||||
}
|
||||
|
||||
/* ───────────── Now-playing card ───────────── */
|
||||
|
||||
.now-playing {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
clamp(110px, 11vw, 160px)
|
||||
minmax(0, 1.2fr)
|
||||
minmax(0, 0.9fr)
|
||||
minmax(0, 0.65fr)
|
||||
minmax(0, 0.65fr)
|
||||
minmax(0, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 224px;
|
||||
padding: 23px 24px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.app:not(.is-import) .now-playing {
|
||||
flex: 0 0 auto;
|
||||
min-height: clamp(120px, 14vh, 160px);
|
||||
padding-top: clamp(7px, 1vh, 12px);
|
||||
padding-bottom: clamp(7px, 1vh, 12px);
|
||||
}
|
||||
|
||||
.app:not(.is-import) .np-art {
|
||||
width: clamp(92px, 13vh, 132px);
|
||||
height: clamp(92px, 14vh, 144px);
|
||||
}
|
||||
|
||||
.np-art {
|
||||
position: relative;
|
||||
width: 154px;
|
||||
height: 168px;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(14, 22, 30, 0.95), rgba(5, 9, 13, 0.96)),
|
||||
var(--panel-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.np-art-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-faint);
|
||||
background: linear-gradient(135deg, #1a1a1a, #0e0e0e);
|
||||
}
|
||||
.np-art-img {
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: calc(100% - 12px);
|
||||
height: calc(100% - 12px);
|
||||
object-fit: contain;
|
||||
display: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.np-art-img.loaded {
|
||||
display: block;
|
||||
}
|
||||
.np-art-play {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: 0;
|
||||
background: linear-gradient(180deg, var(--gold-bright), var(--gold));
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 12px 24px rgba(216, 168, 74, 0.2);
|
||||
transition: transform var(--t-fast) var(--easing), background-color var(--t-base) var(--easing);
|
||||
}
|
||||
.np-art-play:hover {
|
||||
background: linear-gradient(180deg, #ecc76e, #c78a30);
|
||||
}
|
||||
.np-art-play svg {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.np-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding: 0 clamp(14px, 2vw, 28px) 0 clamp(16px, 2.2vw, 30px);
|
||||
border-right: 1px solid var(--glass-line);
|
||||
}
|
||||
.np-title {
|
||||
font-size: clamp(19px, 1.45vw, 24px);
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
line-height: 1.2;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 100%;
|
||||
}
|
||||
.np-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 5px 12px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: var(--ink);
|
||||
border: var(--border-w) solid var(--line);
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.np-play-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 6px;
|
||||
color: #c6cbd2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.np-scrub {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.np-scrub span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 50%;
|
||||
background: var(--gold-bright);
|
||||
box-shadow: 0 0 0 4px rgba(216, 168, 74, 0.18);
|
||||
}
|
||||
|
||||
.stem-energy,
|
||||
.analysis-card {
|
||||
min-height: clamp(100px, 14vh, 156px);
|
||||
padding: 0 clamp(10px, 1.4vw, 24px);
|
||||
border-left: 1px solid var(--glass-line);
|
||||
}
|
||||
|
||||
.stem-energy {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.key-card {
|
||||
grid-column: 4;
|
||||
}
|
||||
|
||||
.loudness-card {
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
.beat-card {
|
||||
grid-column: 6;
|
||||
}
|
||||
|
||||
.loudness-card.hidden + .beat-card {
|
||||
grid-column: 5 / 7;
|
||||
}
|
||||
|
||||
.stem-energy h3,
|
||||
.analysis-card h3 {
|
||||
margin: 0 0 13px;
|
||||
color: #c9ced6;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stem-energy h3 span,
|
||||
.analysis-card h3 span,
|
||||
.panel-title span {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 4px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.energy-row {
|
||||
display: grid;
|
||||
grid-template-columns: 78px 1fr 40px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 5px 0;
|
||||
color: var(--stem-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.energy-row span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.energy-row b {
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(90deg, currentColor 0 var(--v, 0%), rgba(148, 163, 184, 0.13) var(--v, 0%) 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
transition: background 80ms linear;
|
||||
}
|
||||
|
||||
.energy-row em {
|
||||
color: #c8cdd4;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vocals { --stem-color: #e54249; color: var(--stem-color); }
|
||||
.drums { --stem-color: #f06a14; color: var(--stem-color); }
|
||||
.bass { --stem-color: #eab414; color: var(--stem-color); }
|
||||
.guitar { --stem-color: #5bbf50; color: var(--stem-color); }
|
||||
.piano { --stem-color: #7f45d8; color: var(--stem-color); }
|
||||
.other { --stem-color: #3e8dcf; color: var(--stem-color); }
|
||||
.original { --stem-color: #a8b0bd; color: var(--stem-color); }
|
||||
|
||||
.analysis-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.key-card {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.beat-card {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.analysis-card strong {
|
||||
color: var(--gold-bright);
|
||||
font-size: clamp(17px, 1.7vw, 26px);
|
||||
line-height: 1.1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.analysis-card small {
|
||||
color: #b9bfc7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.confidence-ring {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
conic-gradient(
|
||||
var(--gold) calc(var(--confidence-pct, 0) * 1%),
|
||||
rgba(216, 168, 74, 0.16) 0
|
||||
);
|
||||
color: #e9edf2;
|
||||
isolation: isolate;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.confidence-ring::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.confidence-ring::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(216, 168, 74, 0.12),
|
||||
0 0 14px rgba(216, 168, 74, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.confidence-ring span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loudness-card strong {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
color: #f0f2f5;
|
||||
}
|
||||
|
||||
.beat-values {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.beat-values strong {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
color: var(--gold-bright);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.beat-values strong small {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
color: #b9bfc7;
|
||||
}
|
||||
|
||||
.np-transport {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
color: #cfd4db;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.np-transport-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
}
|
||||
.btn-transport {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--glass-line);
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--t-base) var(--easing),
|
||||
color var(--t-base) var(--easing),
|
||||
border-color var(--t-base) var(--easing),
|
||||
transform var(--t-fast) var(--easing);
|
||||
}
|
||||
.btn-transport:hover {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
.btn-transport:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.btn-transport .pause-icon {
|
||||
display: none;
|
||||
}
|
||||
.btn-transport.playing .pause-icon {
|
||||
display: block;
|
||||
}
|
||||
.btn-transport.playing svg:not(.pause-icon) {
|
||||
display: none;
|
||||
}
|
||||
.btn-transport.playing {
|
||||
background: linear-gradient(180deg, #4ade80, #16a34a);
|
||||
color: white;
|
||||
border-color: #15803d;
|
||||
box-shadow: 0 16px 34px rgba(34, 197, 94, 0.22);
|
||||
}
|
||||
.btn-transport.stopped {
|
||||
background: linear-gradient(180deg, #f87171, #dc2626);
|
||||
color: white;
|
||||
border-color: #b91c1c;
|
||||
box-shadow: 0 16px 34px rgba(239, 68, 68, 0.22);
|
||||
}
|
||||
.btn-transport.loop.active {
|
||||
background: linear-gradient(180deg, var(--gold-bright), var(--gold));
|
||||
color: white;
|
||||
border-color: var(--gold-line);
|
||||
box-shadow: 0 16px 34px rgba(216, 168, 74, 0.18);
|
||||
}
|
||||
.np-time {
|
||||
font-size: 13px;
|
||||
color: var(--ink-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* ── Design tokens ── */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Surfaces */
|
||||
--bg: #0b0f12;
|
||||
--bg-2: #0f1418;
|
||||
--panel: #131a1f;
|
||||
--panel-2: #182026;
|
||||
--panel-3: #1d262d;
|
||||
--border: #232c34;
|
||||
--border-strong: #2e3942;
|
||||
|
||||
/* Text */
|
||||
--fg: #e8ecf0;
|
||||
--fg-2: #c2c9d1;
|
||||
--muted: #8a939c;
|
||||
--muted-2: #5d666e;
|
||||
|
||||
/* Stem colours */
|
||||
--vocals: #ef4444;
|
||||
--drums: #f97316;
|
||||
--bass: #eab308;
|
||||
--guitar: #22c55e;
|
||||
--piano: #a855f7;
|
||||
--other: #9ca3af;
|
||||
|
||||
/* Accent */
|
||||
--accent: #f4b740;
|
||||
--accent-2: #d99a2b;
|
||||
|
||||
/* Legacy compat aliases used by waves.css / player.js */
|
||||
--gold: var(--accent);
|
||||
--gold-bright: #f8c054;
|
||||
--gold-soft: rgba(244,183,64,0.16);
|
||||
--gold-line: rgba(244,183,64,0.28);
|
||||
--ink: var(--fg);
|
||||
--ink-dim: var(--fg-2);
|
||||
--ink-faint: var(--muted);
|
||||
--line: var(--border);
|
||||
--line-strong: var(--border-strong);
|
||||
--glass-line: var(--border);
|
||||
--danger: #d65a4a;
|
||||
--focus-ring: rgba(236,236,236,0.55);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', -apple-system, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* Layout */
|
||||
--header-w: 300px;
|
||||
--lane-h: 86px;
|
||||
--radius: 10px;
|
||||
--radius-sm: 8px;
|
||||
--radius-xs: 6px;
|
||||
|
||||
/* Motion */
|
||||
--t-fast: 80ms;
|
||||
--t-base: 120ms;
|
||||
--easing: cubic-bezier(0.2,0.8,0.2,1);
|
||||
--shadow-panel: 0 4px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root { --t-fast: 0ms; --t-base: 0ms; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg width="1200" height="320" viewBox="0 0 1200 320" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
|
||||
<title id="title">StemDeck Horizontal Logo</title>
|
||||
<desc id="desc">StemDeck logo with golden waveform symbol and wordmark.</desc>
|
||||
<defs>
|
||||
<linearGradient id="gold" x1="82" y1="48" x2="282" y2="264" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFE38A"/>
|
||||
<stop offset="0.48" stop-color="#F2B53D"/>
|
||||
<stop offset="1" stop-color="#C98512"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="url(#gold)">
|
||||
<rect x="78" y="154" width="16" height="12" rx="6"/>
|
||||
<rect x="110" y="118" width="20" height="84" rx="10"/>
|
||||
<rect x="144" y="78" width="22" height="164" rx="11"/>
|
||||
<rect x="178" y="126" width="18" height="68" rx="9"/>
|
||||
<rect x="212" y="52" width="24" height="216" rx="12"/>
|
||||
<rect x="250" y="106" width="20" height="108" rx="10"/>
|
||||
<rect x="284" y="80" width="22" height="160" rx="11"/>
|
||||
<rect x="318" y="128" width="20" height="64" rx="10"/>
|
||||
<rect x="352" y="154" width="16" height="12" rx="6"/>
|
||||
</g>
|
||||
<text x="430" y="178" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F4F6F8">Stem</text>
|
||||
<text x="678" y="178" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F2B53D">Deck</text>
|
||||
<text x="435" y="232" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="28" font-weight="500" letter-spacing="11" fill="#7F8A94">POWERED STEM SEPARATION</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,738 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>StemDeck — split any track into stems</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/variables.css" />
|
||||
<link rel="stylesheet" href="/css/waves.css" />
|
||||
<link rel="stylesheet" href="/css/daw.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app daw" id="app">
|
||||
|
||||
<!-- ══ TOPBAR ══ -->
|
||||
<div class="daw-topbar">
|
||||
<!-- Brand -->
|
||||
<div class="daw-brand">
|
||||
<span class="daw-brand-name">
|
||||
<span class="fg">Stem</span><span class="accent">Deck</span>
|
||||
</span>
|
||||
</div>
|
||||
<span id="brandVersion" class="daw-version"></span>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="daw-sep"></div>
|
||||
|
||||
<!-- Composer pill -->
|
||||
<form id="job-form" class="daw-composer">
|
||||
|
||||
<!-- URL zone (JS uses .url-wrap for drag events) -->
|
||||
<div class="daw-url-zone url-wrap">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" stroke-width="1.8" style="flex-shrink:0" aria-hidden="true">
|
||||
<path d="M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1 M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>
|
||||
</svg>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
placeholder="Paste a YouTube or SoundCloud link, or drop an audio file…"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- File pill (shown when file selected) -->
|
||||
<div class="file-pill hidden" id="filePill">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
|
||||
</svg>
|
||||
<span class="file-name" id="fileName"></span>
|
||||
<span class="file-size" id="fileSize"></span>
|
||||
<button class="file-clear" id="fileClear" type="button" aria-label="Remove file">×</button>
|
||||
</div>
|
||||
<button class="daw-upload-btn" type="button" title="Upload audio file" id="uploadFileBtn" aria-label="Upload audio file">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M12 3v12 M7 8l5-5 5 5 M5 21h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input id="fileInput" type="file" accept=".mp3,.wav,.flac,.mp4,.m4a,audio/mpeg,audio/wav,audio/flac,video/mp4,audio/mp4" style="display:none" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="daw-composer-sep"></div>
|
||||
|
||||
<!-- Stem selection -->
|
||||
<div class="daw-stem-section">
|
||||
<span class="daw-extract-label">Extract</span>
|
||||
<div class="daw-stem-chips">
|
||||
<button class="stem-choice stem-choice-all" type="button" id="stemAllBtn" aria-pressed="true">All</button>
|
||||
<button class="stem-choice" type="button" data-stem="vocals" aria-pressed="true" style="--color:var(--vocals)">
|
||||
<span class="stem-dot"></span>Vocals
|
||||
</button>
|
||||
<button class="stem-choice" type="button" data-stem="drums" aria-pressed="true" style="--color:var(--drums)">
|
||||
<span class="stem-dot"></span>Drums
|
||||
</button>
|
||||
<button class="stem-choice" type="button" data-stem="bass" aria-pressed="true" style="--color:var(--bass)">
|
||||
<span class="stem-dot"></span>Bass
|
||||
</button>
|
||||
<button class="stem-choice" type="button" data-stem="guitar" aria-pressed="true" style="--color:var(--guitar)">
|
||||
<span class="stem-dot"></span>Guitar
|
||||
</button>
|
||||
<button class="stem-choice" type="button" data-stem="piano" aria-pressed="true" style="--color:var(--piano)">
|
||||
<span class="stem-dot"></span>Piano
|
||||
</button>
|
||||
<button class="stem-choice" type="button" data-stem="other" aria-pressed="false" style="--color:var(--other)">
|
||||
<span class="stem-dot"></span>Other
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Process button -->
|
||||
<button id="submit" class="daw-process-btn" type="submit">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M13 2 3 14h7l-1 8 10-12h-7z"/>
|
||||
</svg>
|
||||
Split stems
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<div class="daw-notif-wrap" style="position:relative;flex-shrink:0;">
|
||||
<button class="daw-iconbtn daw-notif-btn" id="notifBtn" title="Notifications" type="button" aria-label="Notifications" aria-expanded="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9 M13.7 21a2 2 0 0 1-3.4 0"/>
|
||||
</svg>
|
||||
<span class="daw-notif-badge hidden" id="notifBadge" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="daw-notif-panel" role="menu" aria-label="Notifications">
|
||||
<div class="daw-notif-header">
|
||||
<span class="uplabel">Notifications</span>
|
||||
<button class="daw-notif-close" type="button" aria-label="Close notifications">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="daw-notif-list" id="notifList">
|
||||
<div class="daw-notif-card daw-notif-release hidden" id="notifReleaseCard">
|
||||
<div class="daw-notif-card-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z"/></svg>
|
||||
</div>
|
||||
<div class="daw-notif-card-body">
|
||||
<div class="daw-notif-card-title">New release available</div>
|
||||
<div class="daw-notif-card-desc" id="notifReleaseDesc"></div>
|
||||
</div>
|
||||
<button class="daw-notif-dismiss" id="notifReleaseDismiss" type="button" aria-label="Dismiss notification">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="daw-notif-empty" id="notifEmpty">No new notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden collapsed strip (JS compatibility) -->
|
||||
<div class="hidden" id="appbarStrip" aria-hidden="true" style="display:none!important">
|
||||
<div><div class="strip-sq-stems" id="appbarStripStems"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ BODY ══ -->
|
||||
<div class="daw-body">
|
||||
|
||||
<!-- ── Sidebar / Catalog ── -->
|
||||
<aside class="sidebar" id="catalogPanel">
|
||||
<!-- Rail -->
|
||||
<nav class="sidebar-rail" aria-label="Library navigation">
|
||||
<button class="rail-btn rail-collapse" id="sidebarCollapseBtn" type="button" title="Toggle library" aria-label="Toggle library" aria-expanded="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="rail-btn rail-library active" id="appMenuBtn" type="button" title="Library" aria-label="Library" aria-pressed="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path d="M4 4h16v16H4z M4 9h16"/>
|
||||
</svg>
|
||||
<span>Library</span>
|
||||
</button>
|
||||
<button class="rail-btn rail-favorites" type="button" title="Favorites" aria-label="Favorites" aria-pressed="false">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
<span>Favorites</span>
|
||||
</button>
|
||||
<button class="rail-btn rail-trash" type="button" title="Trash" aria-label="Trash" aria-pressed="false">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path d="M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6"/>
|
||||
</svg>
|
||||
<span>Trash</span>
|
||||
</button>
|
||||
<div style="flex:1"></div>
|
||||
<button class="rail-btn" id="settingsBtn" type="button" aria-label="Settings" aria-haspopup="dialog">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 2v2 M12 20v2 M4 12H2 M22 12h-2 M5 5l1.5 1.5 M17.5 17.5 19 19 M5 19l1.5-1.5 M17.5 6.5 19 5"/>
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button class="rail-btn rail-recommend" id="friendsBtn" type="button" title="We Recommend" aria-label="We Recommend" aria-haspopup="dialog">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
|
||||
</svg>
|
||||
<span>We<br>Recommend</span>
|
||||
</button>
|
||||
<button class="rail-btn" id="aboutBtn" type="button" title="Help" aria-label="Help">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .8-1 1.7 M12 17h.01"/>
|
||||
</svg>
|
||||
<span>Help</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar body -->
|
||||
<div class="sidebar-body">
|
||||
<!-- Search -->
|
||||
<div class="daw-search" id="catalogToggle" role="button" tabindex="0" aria-label="Search library">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>
|
||||
</svg>
|
||||
<input id="catalogSearch" type="search" placeholder="Search or #tag…" autocomplete="off" spellcheck="false" />
|
||||
<ul class="tag-suggest" id="tagSuggest" role="listbox" aria-label="Tag suggestions"></ul>
|
||||
<span class="search-kbd" aria-hidden="true">⌘K</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Clear bin (visible only in trash-view) -->
|
||||
<div id="clearBinBar" class="clear-bin-bar">
|
||||
<button id="clearBinBtn" class="clear-bin-btn" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6"/>
|
||||
</svg>
|
||||
Empty trash
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Catalog list (JS populates) -->
|
||||
<div id="catalogList" class="daw-lib-list" role="list"></div>
|
||||
|
||||
<!-- Collapsed strip (JS compat) -->
|
||||
<div id="catalogStrip" class="cat-collapsed-strip"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── Main area ── -->
|
||||
<div class="daw-main-col">
|
||||
<div id="lanes" class="daw-main">
|
||||
|
||||
<!-- Job progress -->
|
||||
<div id="error" class="error hidden" role="alert"></div>
|
||||
|
||||
<!-- Track header / info panel -->
|
||||
<div id="transport" class="daw-track-header">
|
||||
|
||||
<!-- Row 1: track card + metadata cards -->
|
||||
<div class="daw-info-row">
|
||||
|
||||
<div class="daw-meta-card" data-meta="key">
|
||||
<span class="meta-card-label">KEY</span>
|
||||
<div class="meta-card-main">
|
||||
<span class="meta-card-value" id="summary-key">—</span>
|
||||
<div class="daw-camelot-ring hidden" id="summary-confidence"></div>
|
||||
</div>
|
||||
<span class="meta-card-sub" id="summary-scale"></span>
|
||||
<span class="meta-card-sub hidden" id="summary-confidence-label"></span>
|
||||
</div>
|
||||
|
||||
<div class="daw-meta-card" data-meta="bpm">
|
||||
<span class="meta-card-label">BPM</span>
|
||||
<div class="meta-card-main">
|
||||
<span class="meta-card-value accent" id="summary-bpm">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="daw-meta-card" data-meta="lufs">
|
||||
<span class="meta-card-label">LUFS</span>
|
||||
<div class="meta-card-main">
|
||||
<span class="meta-card-value" id="summary-lufs">—</span>
|
||||
</div>
|
||||
<span class="meta-card-sub" id="summary-peak"></span>
|
||||
</div>
|
||||
|
||||
<div class="daw-meta-card" data-meta="duration">
|
||||
<span class="meta-card-label">DURATION</span>
|
||||
<div class="meta-card-main">
|
||||
<span class="meta-card-value num" id="summary-duration">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="daw-meta-card" data-meta="scale">
|
||||
<span class="meta-card-label">SCALE</span>
|
||||
<div class="meta-card-main">
|
||||
<span class="meta-card-value" id="summary-scale-name">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="daw-meta-card" data-meta="dr">
|
||||
<span class="meta-card-label">DYNAMIC RANGE</span>
|
||||
<div class="meta-card-main">
|
||||
<span class="meta-card-value" id="summary-dr">—</span>
|
||||
</div>
|
||||
<span class="meta-card-sub" id="summary-dr-label"></span>
|
||||
</div>
|
||||
|
||||
<div class="daw-meta-card" data-meta="stability">
|
||||
<span class="meta-card-label">TEMPO STABILITY</span>
|
||||
<div class="meta-card-main">
|
||||
<span class="meta-card-value" id="summary-stability">—</span>
|
||||
</div>
|
||||
<span class="meta-card-sub" id="summary-stability-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: stem presence cards -->
|
||||
<div class="stem-presence-panel">
|
||||
<div class="stem-card inactive" data-stem="vocals">
|
||||
<span class="stem-card-label">VOCAL PRESENCE</span>
|
||||
<span class="stem-card-pct">—</span>
|
||||
</div>
|
||||
<div class="stem-card inactive" data-stem="drums">
|
||||
<span class="stem-card-label">DRUM INTENSITY</span>
|
||||
<span class="stem-card-pct">—</span>
|
||||
</div>
|
||||
<div class="stem-card inactive" data-stem="bass">
|
||||
<span class="stem-card-label">BASS DEPTH</span>
|
||||
<span class="stem-card-pct">—</span>
|
||||
</div>
|
||||
<div class="stem-card inactive" data-stem="guitar">
|
||||
<span class="stem-card-label">GUITAR PRESENCE</span>
|
||||
<span class="stem-card-pct">—</span>
|
||||
</div>
|
||||
<div class="stem-card inactive" data-stem="piano">
|
||||
<span class="stem-card-label">PIANO PRESENCE</span>
|
||||
<span class="stem-card-pct">—</span>
|
||||
</div>
|
||||
<div class="stem-card inactive" data-stem="other">
|
||||
<span class="stem-card-label">OTHER</span>
|
||||
<span class="stem-card-pct">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden compat -->
|
||||
<div style="display:none" aria-hidden="true"><div id="loudness-card"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Sections bar -->
|
||||
<div class="daw-section-ribbon">
|
||||
<div class="daw-mixer-label daw-sections-header">
|
||||
<span class="daw-label-title">Sections</span>
|
||||
<span class="sections-save-indicator hidden" id="sectionsSaveIndicator" aria-live="polite" aria-label="Saving sections"></span>
|
||||
<button class="sections-add-btn-label" id="sectionsAddBtn" type="button" title="Add section" aria-label="Add section">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="daw-sections-area" id="daw-sections"></div>
|
||||
</div>
|
||||
|
||||
<!-- Waveform header / ruler -->
|
||||
<div class="daw-wave-header">
|
||||
<div class="daw-wave-label">
|
||||
<span class="daw-label-title">Mixer</span>
|
||||
<span class="uplabel daw-label-sub">Drag fader · M/S</span>
|
||||
</div>
|
||||
<div class="daw-ruler-area">
|
||||
<div class="lanes-ruler" data-position="top">
|
||||
<div class="lanes-ruler-time" id="ruler-time">
|
||||
<div class="playhead-marker" aria-hidden="true">
|
||||
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
|
||||
<polygon points="0,0 10,0 5,8" fill="var(--accent)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content area: stems + waveform -->
|
||||
<div class="daw-content">
|
||||
|
||||
<!-- Stems panel (left — M/S controls per stem) -->
|
||||
<div class="daw-stems-panel" aria-label="Stems">
|
||||
|
||||
<!-- Presence panel hidden but in DOM for JS -->
|
||||
<div style="display:none" aria-hidden="true">
|
||||
<section class="presence-panel surface-panel">
|
||||
<div class="presence-ruler" id="presence-ruler">
|
||||
<span></span><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b>
|
||||
</div>
|
||||
<div class="presence-grid">
|
||||
<div class="presence-labels">
|
||||
<span class="vocals">Vocals</span><span class="drums">Drums</span>
|
||||
<span class="bass">Bass</span><span class="guitar">Guitar</span>
|
||||
<span class="piano">Piano</span><span class="other">Others</span>
|
||||
</div>
|
||||
<div class="presence-bars">
|
||||
<i class="vocals" data-stem="vocals"></i>
|
||||
<i class="drums" data-stem="drums"></i>
|
||||
<i class="bass" data-stem="bass"></i>
|
||||
<i class="guitar" data-stem="guitar"></i>
|
||||
<i class="piano" data-stem="piano"></i>
|
||||
<i class="other" data-stem="other"></i>
|
||||
<div class="presence-playhead hidden" id="presence-playhead" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Stem rows with M/S -->
|
||||
<div class="stem-list" aria-label="Waveform lane icons">
|
||||
<span class="original" data-stem="original">
|
||||
<i class="drag-handle"></i>
|
||||
<em>
|
||||
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
Original
|
||||
</em>
|
||||
<b class="stem-mute" data-stem="original" role="button" tabindex="0" aria-label="Mute Original" aria-pressed="false">M</b>
|
||||
<b class="stem-solo" data-stem="original" role="button" tabindex="0" aria-label="Solo Original" aria-pressed="false">S</b>
|
||||
<button class="stem-monitor" type="button" data-stem="original" aria-label="Solo only Original"></button>
|
||||
<i class="mini-meter"></i>
|
||||
</span>
|
||||
<span class="vocals" data-stem="vocals">
|
||||
<i class="drag-handle"></i>
|
||||
<em>
|
||||
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg>
|
||||
Vocals
|
||||
</em>
|
||||
<b class="stem-mute" data-stem="vocals" role="button" tabindex="0" aria-label="Mute Vocals" aria-pressed="false">M</b>
|
||||
<b class="stem-solo" data-stem="vocals" role="button" tabindex="0" aria-label="Solo Vocals" aria-pressed="false">S</b>
|
||||
<button class="stem-monitor" type="button" data-stem="vocals" aria-label="Solo only Vocals"></button>
|
||||
<i class="mini-meter"></i>
|
||||
</span>
|
||||
<span class="drums" data-stem="drums">
|
||||
<i class="drag-handle"></i>
|
||||
<em>
|
||||
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M7 13.5a5 5 0 0 0 10 0"/><path d="M7 13.5h10"/><circle cx="9" cy="10" r="2.5"/><circle cx="15" cy="10" r="2.5"/></svg>
|
||||
Drums
|
||||
</em>
|
||||
<b class="stem-mute" data-stem="drums" role="button" tabindex="0" aria-label="Mute Drums" aria-pressed="false">M</b>
|
||||
<b class="stem-solo" data-stem="drums" role="button" tabindex="0" aria-label="Solo Drums" aria-pressed="false">S</b>
|
||||
<button class="stem-monitor" type="button" data-stem="drums" aria-label="Solo only Drums"></button>
|
||||
<i class="mini-meter"></i>
|
||||
</span>
|
||||
<span class="bass" data-stem="bass">
|
||||
<i class="drag-handle"></i>
|
||||
<em>
|
||||
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M16.5 3h4v5h-3"/><path d="M17.5 5.5 9.8 13.2"/><path d="M10 13c1.6 2.2 1.1 5.1-1.2 6.5-2.1 1.3-5 .5-6-1.6-.9-1.9-.1-4.1 1.8-5 .9-.4 1.8-.4 2.8-.1.1-1.1.6-2.1 1.6-2.6 1.2-.6 2.6-.1 3.2 1.1"/></svg>
|
||||
Bass
|
||||
</em>
|
||||
<b class="stem-mute" data-stem="bass" role="button" tabindex="0" aria-label="Mute Bass" aria-pressed="false">M</b>
|
||||
<b class="stem-solo" data-stem="bass" role="button" tabindex="0" aria-label="Solo Bass" aria-pressed="false">S</b>
|
||||
<button class="stem-monitor" type="button" data-stem="bass" aria-label="Solo only Bass"></button>
|
||||
<i class="mini-meter"></i>
|
||||
</span>
|
||||
<span class="guitar" data-stem="guitar">
|
||||
<i class="drag-handle"></i>
|
||||
<em>
|
||||
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M16 4.5 20 2l2 2-2.5 4"/><path d="M18.2 5.8 10.2 13.8"/><circle cx="7" cy="16.4" r="1.4"/></svg>
|
||||
Guitar
|
||||
</em>
|
||||
<b class="stem-mute" data-stem="guitar" role="button" tabindex="0" aria-label="Mute Guitar" aria-pressed="false">M</b>
|
||||
<b class="stem-solo" data-stem="guitar" role="button" tabindex="0" aria-label="Solo Guitar" aria-pressed="false">S</b>
|
||||
<button class="stem-monitor" type="button" data-stem="guitar" aria-label="Solo only Guitar"></button>
|
||||
<i class="mini-meter"></i>
|
||||
</span>
|
||||
<span class="piano" data-stem="piano">
|
||||
<i class="drag-handle"></i>
|
||||
<em>
|
||||
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 5v14"/><path d="M12 5v14"/><path d="M17 5v14"/></svg>
|
||||
Piano
|
||||
</em>
|
||||
<b class="stem-mute" data-stem="piano" role="button" tabindex="0" aria-label="Mute Piano" aria-pressed="false">M</b>
|
||||
<b class="stem-solo" data-stem="piano" role="button" tabindex="0" aria-label="Solo Piano" aria-pressed="false">S</b>
|
||||
<button class="stem-monitor" type="button" data-stem="piano" aria-label="Solo only Piano"></button>
|
||||
<i class="mini-meter"></i>
|
||||
</span>
|
||||
<span class="other" data-stem="other">
|
||||
<i class="drag-handle"></i>
|
||||
<em>
|
||||
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 13v-2"/><path d="M8 17V7"/><path d="M12 21V3"/><path d="M16 17V7"/><path d="M20 13v-2"/></svg>
|
||||
Others
|
||||
</em>
|
||||
<b class="stem-mute" data-stem="other" role="button" tabindex="0" aria-label="Mute Other" aria-pressed="false">M</b>
|
||||
<b class="stem-solo" data-stem="other" role="button" tabindex="0" aria-label="Solo Other" aria-pressed="false">S</b>
|
||||
<button class="stem-monitor" type="button" data-stem="other" aria-label="Solo only Other"></button>
|
||||
<i class="mini-meter"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mixer faders (JS populates) -->
|
||||
<div id="mixer" class="mixer-column"></div>
|
||||
</div>
|
||||
|
||||
<!-- Waveform panel -->
|
||||
<div class="daw-wave-panel wave-editor surface-panel">
|
||||
<div id="job" class="job hidden" role="status" aria-live="polite">
|
||||
<div class="job-header">
|
||||
<div id="job-title" class="title"></div>
|
||||
<div id="job-stage" class="stage"></div>
|
||||
<button id="job-cancel" class="cancel-btn hidden" type="button" aria-label="Cancel job">Cancel</button>
|
||||
</div>
|
||||
<progress id="progress" max="100" value="0"></progress>
|
||||
<div id="job-detail" class="job-detail" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="wave-scroll" id="wave-scroll">
|
||||
<div class="wave-canvas" id="wave-canvas">
|
||||
<div class="waves-column">
|
||||
<div id="waves-grid" class="waves-grid" aria-hidden="true"></div>
|
||||
<div id="multitrack-container"></div>
|
||||
<div id="loop-region" class="loop-region hidden" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="wave-loading-overlay hidden" id="waveLoadingOverlay" aria-label="Loading waveform" aria-live="polite">
|
||||
<div class="wave-loading-lines" aria-hidden="true">
|
||||
<i></i><i></i><i></i><i></i><i></i><i></i>
|
||||
</div>
|
||||
<p id="waveLoadingPhrase" class="wave-loading-msg"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.daw-content -->
|
||||
|
||||
<!-- Transport footer -->
|
||||
</div><!-- /#lanes -->
|
||||
|
||||
<!-- ══ FOOTER — sibling of #lanes inside .daw-main-col, not clipped ══ -->
|
||||
<footer class="daw-footer">
|
||||
|
||||
<!-- Top row: track info | transport + time | chips -->
|
||||
<div class="footer-content">
|
||||
|
||||
<div class="footer-track">
|
||||
<div class="footer-track-title-row">
|
||||
<div class="daw-track-title footer-track-title" id="title">—</div>
|
||||
<button class="daw-fav-btn" id="fav-btn" aria-label="Toggle favorite" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="footer-track-body">
|
||||
<div class="daw-cover footer-cover" id="np-art">
|
||||
<div class="np-art-placeholder" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<img id="np-thumb" class="np-art-img" alt="" referrerpolicy="no-referrer" />
|
||||
</div>
|
||||
<div class="daw-track-meta">
|
||||
<div class="daw-track-time-row">
|
||||
<span class="num daw-track-sub" id="t-time">—</span>
|
||||
<span class="daw-track-sep" aria-hidden="true">|</span>
|
||||
<span class="daw-track-stems-label" id="t-stems-chip">— Stems</span>
|
||||
</div>
|
||||
<div class="daw-track-details">
|
||||
<div class="daw-detail-row">
|
||||
<span class="daw-detail-label">Extracted</span>
|
||||
<span class="daw-detail-val" id="track-extracted">—</span>
|
||||
</div>
|
||||
<div class="daw-detail-row">
|
||||
<span class="daw-detail-label">Source</span>
|
||||
<span class="daw-detail-val" id="track-source">—</span>
|
||||
</div>
|
||||
<div class="daw-detail-row">
|
||||
<span class="daw-detail-label">Quality</span>
|
||||
<span class="daw-detail-val" id="track-quality">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.footer-track-body -->
|
||||
<!-- Hidden spans for JS compat -->
|
||||
<div style="display:none" aria-hidden="true">
|
||||
<span id="t-bpm"></span><span id="t-key"></span><span id="track-artist"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-center">
|
||||
<div class="footer-transport">
|
||||
<button class="daw-iconbtn btn-transport" id="t-stop" type="button" title="Stop" aria-label="Stop">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="daw-play-btn btn-transport" id="t-play" type="button" aria-label="Play / Pause">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="margin-left:2px">
|
||||
<path d="M6 4l14 8L6 20z"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" class="pause-icon" aria-hidden="true" style="display:none">
|
||||
<rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="daw-iconbtn btn-transport loop" id="t-loop" type="button" title="Loop (L)" aria-label="Loop">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M17 2l4 4-4 4 M3 12V8a2 2 0 0 1 2-2h16 M7 22l-4-4 4-4 M21 12v4a2 2 0 0 1-2 2H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="footer-loop-times" title="Exact loop start / end (mm:ss.mmm or seconds)">
|
||||
<input type="text" id="t-loop-start" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop start" value="00:00.000">
|
||||
<span class="loop-times-sep" aria-hidden="true">-</span>
|
||||
<input type="text" id="t-loop-end" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop end" value="00:00.000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-time-row">
|
||||
<span class="num footer-elapsed" id="footer-time-elapsed">0:00</span>
|
||||
<span class="footer-time-sep">/</span>
|
||||
<span class="num footer-total" id="footer-time-total">0:00</span>
|
||||
</div>
|
||||
<div class="tempo-bar" id="t-speed-wrap" title="Playback speed (double-click to reset)">
|
||||
<span class="tempo-bar-label">TEMPO</span>
|
||||
<input type="range" id="t-speed" min="0" max="2" step="0.25" value="1" aria-label="Playback speed">
|
||||
<span class="tempo-bar-divider" aria-hidden="true"></span>
|
||||
<span class="tempo-bar-val" id="t-speed-label">1.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-chips">
|
||||
<div class="footer-chip-wrap" id="footer-export-wrap">
|
||||
<button class="footer-chip footer-chip-primary" id="t-export-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="t-export-panel">
|
||||
<svg class="export-dl-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<svg class="export-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke-opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9"/>
|
||||
</svg>
|
||||
<span class="export-btn-label" id="t-export-label">Export Mix</span>
|
||||
<svg class="export-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="footer-chip-panel chip-panel-export hidden" id="t-export-panel" role="menu" aria-label="Export options">
|
||||
<div class="export-format-toggle" role="radiogroup" aria-label="Export format">
|
||||
<button class="export-fmt active" id="t-fmt-wav" type="button" role="radio" aria-checked="true">WAV</button>
|
||||
<button class="export-fmt" id="t-fmt-mp3" type="button" role="radio" aria-checked="false">MP3</button>
|
||||
<button class="export-fmt" id="t-fmt-flac" type="button" role="radio" aria-checked="false">FLAC</button>
|
||||
<button class="export-fmt export-fmt-video" id="t-fmt-mp4" type="button" role="radio" aria-checked="false">MP4</button>
|
||||
</div>
|
||||
<button class="chip-panel-item export-item" id="t-export-mix" type="button" role="menuitem">
|
||||
<span class="chip-item-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><line x1="4" y1="9" x2="4" y2="15"/><line x1="9" y1="5" x2="9" y2="19"/><line x1="14" y1="8" x2="14" y2="16"/><line x1="19" y1="4" x2="19" y2="20"/></svg>
|
||||
</span>
|
||||
<span class="chip-item-text">
|
||||
<span class="chip-item-title">Export Mix</span>
|
||||
<span class="chip-item-desc">Export the mixed audio</span>
|
||||
</span>
|
||||
<svg class="chip-item-check" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
<button class="chip-panel-item export-item" id="t-export-stems" type="button" role="menuitem">
|
||||
<span class="chip-item-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><polygon points="12 2 22 8.5 12 15 2 8.5 12 2"/><polyline points="2 15.5 12 22 22 15.5"/></svg>
|
||||
</span>
|
||||
<span class="chip-item-text">
|
||||
<span class="chip-item-title">Export All Stems</span>
|
||||
<span class="chip-item-desc">All stems as a .zip</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="chip-panel-item export-item" id="t-export-region" type="button" role="menuitem" aria-disabled="true">
|
||||
<span class="chip-item-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>
|
||||
</span>
|
||||
<span class="chip-item-text">
|
||||
<span class="chip-item-title">Export Current Region</span>
|
||||
<span class="chip-item-desc">Export selected region</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.footer-content -->
|
||||
|
||||
<!-- Full-width waveform bar -->
|
||||
<div class="footer-wave-bar">
|
||||
<canvas id="footer-waveform" aria-hidden="true"></canvas>
|
||||
<div class="footer-scrub" id="footer-scrub" role="slider" aria-label="Seek" tabindex="0">
|
||||
<div class="footer-scrub-fill" id="footer-scrub-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
</div><!-- /.daw-main-col -->
|
||||
</div><!-- /.daw-body -->
|
||||
|
||||
</div><!-- /.daw -->
|
||||
|
||||
<!-- About dialog -->
|
||||
<div class="about-backdrop hidden" id="aboutDialog" role="dialog" aria-modal="true" aria-labelledby="aboutTitle">
|
||||
<div class="about-card">
|
||||
<button class="about-close" id="aboutClose" type="button" aria-label="Close about dialog">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="about-logo" aria-hidden="true">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<rect x="2" y="10" width="3" height="8" rx="1.5" fill="currentColor" opacity=".5"/>
|
||||
<rect x="7" y="6" width="3" height="16" rx="1.5" fill="currentColor" opacity=".7"/>
|
||||
<rect x="12" y="2" width="4" height="24" rx="2" fill="currentColor"/>
|
||||
<rect x="18" y="6" width="3" height="16" rx="1.5" fill="currentColor" opacity=".7"/>
|
||||
<rect x="23" y="10" width="3" height="8" rx="1.5" fill="currentColor" opacity=".5"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 id="aboutTitle">StemDeck</h2>
|
||||
<p class="about-tagline">Open source. No subscriptions. Built by musicians, for musicians.</p>
|
||||
<span class="about-version-badge" id="aboutVersion">…</span>
|
||||
|
||||
<div class="about-primary-links">
|
||||
<a class="about-link about-link-primary" href="https://stemdeck.app" target="_blank" rel="noopener noreferrer">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
Website
|
||||
</a>
|
||||
<a class="about-link about-link-secondary" href="https://github.com/stemdeckapp/stemdeck" target="_blank" rel="noopener noreferrer">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="about-divider"></div>
|
||||
|
||||
<div class="about-socials">
|
||||
<a class="about-social-btn" href="https://discord.gg/JGk7FdZb9N" target="_blank" rel="noopener noreferrer" title="Discord" aria-label="Discord">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
|
||||
</a>
|
||||
<a class="about-social-btn" href="https://www.reddit.com/r/StemDeckApp/" target="_blank" rel="noopener noreferrer" title="Reddit" aria-label="Reddit">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
|
||||
</a>
|
||||
<a class="about-social-btn" href="https://www.instagram.com/stemdeck" target="_blank" rel="noopener noreferrer" title="Instagram" aria-label="Instagram">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>
|
||||
</a>
|
||||
<a class="about-social-btn" href="https://x.com/StemDeckApp" target="_blank" rel="noopener noreferrer" title="X" aria-label="X">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.747l7.73-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Our Friends dialog -->
|
||||
<div class="about-backdrop hidden" id="friendsDialog" role="dialog" aria-modal="true" aria-labelledby="friendsTitle">
|
||||
<div class="about-card friends-card">
|
||||
<button class="about-close" id="friendsClose" type="button" aria-label="Close friends dialog">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
<div class="about-logo" aria-hidden="true">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 id="friendsTitle">We Recommend</h2>
|
||||
<p class="about-tagline">Wonderful people doing beautiful work. Go meet them ❤️</p>
|
||||
<div class="lib-friends-grid" id="friendsDialogGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/main.js"></script>
|
||||
<script type="module" src="/js/ui-chrome.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
import { TRACK_NAMES } from "./constants.js";
|
||||
import {
|
||||
multitrack, mixerEl, audioContext, trackAnalysers,
|
||||
vuRafId, trackIndex,
|
||||
setAudioContext, setVuRafId,
|
||||
} from "./state.js";
|
||||
|
||||
export function attachAnalysers() {
|
||||
if (!multitrack) return;
|
||||
if (trackAnalysers.length) return;
|
||||
const wsArr = multitrack.wavesurfers || multitrack._wavesurfers;
|
||||
const audios = multitrack.audios;
|
||||
if (!wsArr?.length || !audios?.length) return;
|
||||
|
||||
const ctx = multitrack.audioContext;
|
||||
if (!ctx) return;
|
||||
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
||||
setAudioContext(ctx);
|
||||
|
||||
for (const stemName of TRACK_NAMES) {
|
||||
const idx = trackIndex[stemName];
|
||||
if (idx === undefined) continue;
|
||||
const audioEl = audios[idx];
|
||||
if (!audioEl) continue;
|
||||
|
||||
const isHtmlMedia = audioEl instanceof HTMLMediaElement;
|
||||
const isWebAudio = typeof audioEl.getGainNode === "function";
|
||||
if (!isHtmlMedia && !isWebAudio) continue;
|
||||
if (isHtmlMedia && !audioEl.src) continue;
|
||||
|
||||
let analyser;
|
||||
try {
|
||||
analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = 0.5;
|
||||
if (isWebAudio) {
|
||||
// wavesurfer's audio wrapper exposes its internal gain node;
|
||||
// analyser taps the post-gain signal.
|
||||
audioEl.getGainNode().connect(analyser);
|
||||
} else {
|
||||
// Bare HTMLAudioElement: route through Web Audio so we can tap
|
||||
// the post-gain signal. applyMix() may have already created the
|
||||
// MediaElementSource + GainNode chain; re-use it if so (the spec
|
||||
// only allows createMediaElementSource once per element).
|
||||
if (!audioEl._stMediaSource) {
|
||||
audioEl._stMediaSource = ctx.createMediaElementSource(audioEl);
|
||||
audioEl._stGainNode = ctx.createGain();
|
||||
audioEl._stMediaSource.connect(audioEl._stGainNode);
|
||||
audioEl._stGainNode.connect(ctx.destination);
|
||||
audioEl.volume = 1;
|
||||
} else if (!audioEl._stGainNode) {
|
||||
audioEl._stGainNode = ctx.createGain();
|
||||
audioEl._stMediaSource.connect(audioEl._stGainNode);
|
||||
audioEl._stGainNode.connect(ctx.destination);
|
||||
audioEl.volume = 1;
|
||||
}
|
||||
audioEl._stGainNode.connect(analyser);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("VU analyser hookup failed for stem", stemName, err);
|
||||
continue;
|
||||
}
|
||||
// Time-domain buffer must be sized to fftSize (not frequencyBinCount,
|
||||
// which is fftSize/2). With a too-small buffer, getByteTimeDomainData
|
||||
// only writes the first N samples and trailing entries keep stale
|
||||
// values, biasing RMS toward whatever was last left there.
|
||||
const data = new Uint8Array(analyser.fftSize);
|
||||
const vuEl = mixerEl.querySelector(`.lane-vu[data-stem="${stemName}"]`);
|
||||
const miniMeterEl = document.querySelector(`.stem-list .${stemName} .mini-meter`);
|
||||
trackAnalysers.push({ analyser, data, vuEl, miniMeterEl, peak: 0 });
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
for (const t of trackAnalysers) {
|
||||
t.analyser.getByteTimeDomainData(t.data);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < t.data.length; i++) {
|
||||
const v = (t.data[i] - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sum / t.data.length);
|
||||
// Linear RMS with 2.5x gain. Compensates for the 0.5 master
|
||||
// attenuation the analyser sees post-gain via createMediaElementSource,
|
||||
// so typical -20 dBFS music lands in the 25-50% bar range, drum
|
||||
// hits push toward 80-100%, silence falls to 0. Linear (not dB)
|
||||
// gives the meter punch on transients that dB scaling smooths out.
|
||||
const level = Math.min(1, rms * 2.5);
|
||||
// Peak hold: bar follows level on the way up, falls slowly on
|
||||
// the way down. That's what makes a VU look alive -- it lifts
|
||||
// sharply on each drum hit / vocal phrase and drifts down in
|
||||
// between rather than tracking instantaneous RMS jitter.
|
||||
const prevPeak = t.peak || 0;
|
||||
const nextPeak = level > prevPeak ? level : Math.max(0, prevPeak - 0.012);
|
||||
t.peak = nextPeak;
|
||||
// Separate peak-hold marker that holds the recent max for
|
||||
// ~600 ms before falling, so a thin tick visually marks
|
||||
// "loudest moment in the last second" above the bar.
|
||||
const prevHold = t.peakHold || 0;
|
||||
let nextHold = prevHold;
|
||||
let holdFrames = t.holdFrames || 0;
|
||||
if (level > prevHold) {
|
||||
nextHold = level;
|
||||
holdFrames = 36;
|
||||
} else if (holdFrames > 0) {
|
||||
holdFrames -= 1;
|
||||
} else {
|
||||
nextHold = Math.max(0, prevHold - 0.02);
|
||||
}
|
||||
t.peakHold = nextHold;
|
||||
t.holdFrames = holdFrames;
|
||||
const lvlPct = Math.round(level * 100);
|
||||
const peakPct = Math.round(nextPeak * 100);
|
||||
const holdPct = Math.round(nextHold * 100);
|
||||
if (t.vuEl) {
|
||||
if (lvlPct !== t.lastLevelPct) {
|
||||
t.vuEl.style.setProperty("--vu-level", `${lvlPct}%`);
|
||||
}
|
||||
if (holdPct !== t.lastHoldPct) {
|
||||
t.vuEl.style.setProperty("--vu-peak", `${holdPct}%`);
|
||||
}
|
||||
}
|
||||
if (t.miniMeterEl) {
|
||||
if (peakPct !== t.lastPeakPct) {
|
||||
t.miniMeterEl.style.setProperty("--vu-scale", nextPeak.toFixed(3));
|
||||
}
|
||||
if (holdPct !== t.lastHoldPct) {
|
||||
t.miniMeterEl.style.setProperty("--vu-peak-pct", String(holdPct));
|
||||
t.miniMeterEl.style.setProperty(
|
||||
"--vu-peak-opacity",
|
||||
nextHold > 0.04 ? "1" : "0",
|
||||
);
|
||||
}
|
||||
}
|
||||
t.lastLevelPct = lvlPct;
|
||||
t.lastPeakPct = peakPct;
|
||||
t.lastHoldPct = holdPct;
|
||||
}
|
||||
|
||||
setVuRafId(requestAnimationFrame(tick));
|
||||
};
|
||||
setVuRafId(requestAnimationFrame(tick));
|
||||
}
|
||||
|
||||
export function stopVuLoop() {
|
||||
if (vuRafId) {
|
||||
cancelAnimationFrame(vuRafId);
|
||||
setVuRafId(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Web Audio decode-and-mix playback engine.
|
||||
//
|
||||
// Safari/WKWebView goes choppy when playing N streaming <audio> elements (one per
|
||||
// stem) over HTTP/1.1: the 6-connection-per-origin cap + small media buffers + the
|
||||
// multitrack's per-element currentTime nudging cause underruns. This engine instead
|
||||
// decodes each active stem once into an AudioBuffer and plays them all from a single
|
||||
// AudioContext clock — sample-accurate, zero streaming connections during playback,
|
||||
// no drift. Works identically on WKWebView, Safari, and Chrome.
|
||||
//
|
||||
// Graph: per-stem AudioBufferSourceNode -> GainNode (vol/mute/solo) -> AnalyserNode (VU)
|
||||
// -> masterGain -> SoundTouchNode -> destination
|
||||
//
|
||||
// Used behind a feature flag (see player.js) so it can be A/B'd against the legacy
|
||||
// streaming path before cutover.
|
||||
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
|
||||
/**
|
||||
* @param {{name:string,url:string}[]} stems Active stems only (caller filters).
|
||||
* @param {{onTime?:(t:number)=>void, onEnded?:()=>void}} cbs
|
||||
*/
|
||||
export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
|
||||
// Mobile/iOS only starts audio from a context resumed inside a user gesture.
|
||||
// Callers can pass a shared, gesture-unlocked `context` (the mobile UI does);
|
||||
// desktop passes none and we own a fresh one. We only close contexts we own.
|
||||
const ctx = context || new AudioCtx();
|
||||
const ownsCtx = !context;
|
||||
const master = ctx.createGain();
|
||||
|
||||
// SoundTouch pitch-preserving time-stretch on the master bus.
|
||||
// Falls back to tape-effect (playbackRate) if AudioWorklet is unavailable.
|
||||
let stNode = null;
|
||||
const _workletReady = (ctx.audioWorklet
|
||||
? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => {
|
||||
stNode = new AudioWorkletNode(ctx, 'soundtouch-processor');
|
||||
master.connect(stNode);
|
||||
stNode.connect(ctx.destination);
|
||||
}).catch((err) => {
|
||||
console.warn('[audioEngine] SoundTouch worklet load failed, using tape-effect fallback:', err);
|
||||
master.connect(ctx.destination);
|
||||
})
|
||||
: Promise.resolve().then(() => { master.connect(ctx.destination); }));
|
||||
|
||||
/** @type {Map<string,{buffer:AudioBuffer,gain:GainNode,analyser:AnalyserNode,source:AudioBufferSourceNode|null}>} */
|
||||
const tracks = new Map();
|
||||
let duration = 0;
|
||||
let playing = false;
|
||||
let startCtxTime = 0; // ctx.currentTime at playback start
|
||||
let startOffset = 0; // media offset at that moment
|
||||
let rafId = null;
|
||||
let destroyed = false;
|
||||
let loop = { enabled: false, start: 0, end: 0 };
|
||||
let _playbackRate = 1.0;
|
||||
|
||||
// Decode all stems up front AND load the SoundTouch worklet in parallel.
|
||||
// Resolves true once at least one stem is ready (worklet load is best-effort).
|
||||
const ready = (async () => {
|
||||
await Promise.all([
|
||||
_workletReady,
|
||||
...stems.map(async (s) => {
|
||||
if (!s?.url) return;
|
||||
try {
|
||||
const res = await fetch(s.url);
|
||||
if (!res.ok) throw new Error(`fetch ${res.status}`);
|
||||
const buffer = await ctx.decodeAudioData(await res.arrayBuffer());
|
||||
if (destroyed) return;
|
||||
const gain = ctx.createGain();
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
gain.connect(analyser);
|
||||
analyser.connect(master);
|
||||
tracks.set(s.name, { buffer, gain, analyser, source: null });
|
||||
duration = Math.max(duration, buffer.duration);
|
||||
} catch (e) {
|
||||
console.warn(`[audioEngine] decode failed for ${s.name}:`, e);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
return tracks.size > 0;
|
||||
})();
|
||||
|
||||
const now = () => (playing ? (ctx.currentTime - startCtxTime) * _playbackRate + startOffset : startOffset);
|
||||
|
||||
function stopSources() {
|
||||
for (const t of tracks.values()) {
|
||||
if (t.source) {
|
||||
try { t.source.stop(); } catch { /* already stopped */ }
|
||||
try { t.source.disconnect(); } catch { /* noop */ }
|
||||
t.source = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startSources(offset) {
|
||||
const when = ctx.currentTime;
|
||||
for (const t of tracks.values()) {
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = t.buffer;
|
||||
// SoundTouch handles time-stretch; playbackRate stays 1.0.
|
||||
// Falls back to tape-effect only when the worklet is unavailable.
|
||||
if (!stNode) src.playbackRate.value = _playbackRate;
|
||||
src.connect(t.gain);
|
||||
src.start(when, Math.max(0, Math.min(offset, t.buffer.duration)));
|
||||
t.source = src;
|
||||
}
|
||||
startCtxTime = when;
|
||||
startOffset = offset;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!playing) return;
|
||||
let t = now();
|
||||
if (loop.enabled && loop.end > loop.start && t >= loop.end) {
|
||||
seek(loop.start);
|
||||
t = loop.start;
|
||||
} else if (t >= duration) {
|
||||
pause();
|
||||
startOffset = duration;
|
||||
onTime?.(duration);
|
||||
onEnded?.();
|
||||
return;
|
||||
}
|
||||
onTime?.(t);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (playing || destroyed || !tracks.size) return;
|
||||
// Safari: resume the context fire-and-forget within the user-gesture tick.
|
||||
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
||||
let off = startOffset;
|
||||
if (off >= duration) off = 0;
|
||||
startSources(off);
|
||||
playing = true;
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (!playing) return;
|
||||
const t = now();
|
||||
stopSources();
|
||||
playing = false;
|
||||
startOffset = Math.max(0, Math.min(t, duration));
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
||||
}
|
||||
|
||||
function seek(t) {
|
||||
const clamped = Math.max(0, Math.min(t, duration || 0));
|
||||
if (playing) {
|
||||
stopSources();
|
||||
startSources(clamped);
|
||||
} else {
|
||||
startOffset = clamped;
|
||||
}
|
||||
onTime?.(clamped);
|
||||
}
|
||||
|
||||
function setGain(name, v) {
|
||||
const t = tracks.get(name);
|
||||
if (t) t.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
|
||||
}
|
||||
|
||||
function setMasterGain(v) {
|
||||
master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
stopSources();
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
||||
tracks.clear();
|
||||
if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } }
|
||||
if (ownsCtx) ctx.close().catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
setTime: seek, // alias to match the multitrack interface used by transport.js
|
||||
isPlaying: () => playing,
|
||||
getCurrentTime: now,
|
||||
getDuration: () => duration,
|
||||
setLoop: (enabled, start, end) => { loop = { enabled, start, end }; },
|
||||
setPlaybackRate(rate) {
|
||||
_playbackRate = rate;
|
||||
if (stNode) {
|
||||
// Pitch-preserving: update SoundTouch tempo parameter
|
||||
stNode.parameters.get('tempo').value = rate;
|
||||
} else {
|
||||
// Tape-effect fallback
|
||||
for (const t of tracks.values()) {
|
||||
if (t.source) t.source.playbackRate.value = rate;
|
||||
}
|
||||
}
|
||||
},
|
||||
setGain,
|
||||
setMasterGain,
|
||||
getAnalyser: (name) => tracks.get(name)?.analyser ?? null,
|
||||
// Decoded AudioBuffers keyed by stem name — reused by the visuals (overview
|
||||
// waveforms, mini-waves, VU envelopes, energy bars) so they don't need the
|
||||
// multitrack to also decode the audio. Map<name, AudioBuffer>.
|
||||
getBuffers: () => {
|
||||
const m = new Map();
|
||||
for (const [name, t] of tracks) m.set(name, t.buffer);
|
||||
return m;
|
||||
},
|
||||
destroy,
|
||||
audioContext: ctx,
|
||||
};
|
||||
}
|
||||
|
||||
// Rough decoded-PCM memory estimate (Float32 = 4 bytes/sample/channel) used by the
|
||||
// caller's guard to fall back to streaming for very long / many-stem tracks.
|
||||
export function estimateDecodedBytes(durationSec, stemCount, channels = 2, sampleRate = 44100) {
|
||||
return Math.round(durationSec * stemCount * channels * sampleRate * 4);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,442 @@
|
||||
// Chunked audio engine for the mobile player.
|
||||
//
|
||||
// Fetches WAV stems in fixed-size windows via HTTP Range requests and chains
|
||||
// AudioBufferSourceNodes back-to-back for gapless playback. Compared to the
|
||||
// full-decode engine (audioEngine.js):
|
||||
// - First audio after ~7 MB download (one 10-second chunk per 4 stems on WiFi)
|
||||
// instead of waiting for the complete file
|
||||
// - Peak RAM ~28 MB vs ~420 MB for a 5-minute 4-stem track
|
||||
// - No track-length cap
|
||||
// - Same glitch-free behavior on Safari/WKWebView: AudioBufferSourceNode,
|
||||
// no streaming elements, no HTTP/1.1 connection-cap underruns
|
||||
//
|
||||
// The backend's FileResponse already handles Range requests natively (Starlette
|
||||
// 1.3.x), so no server-side changes are needed.
|
||||
//
|
||||
// Graph: per-stem AudioBufferSourceNode -> GainNode -> masterGain -> SoundTouchNode -> destination
|
||||
|
||||
const CHUNK_SEC = 5; // seconds of audio per chunk
|
||||
const LOOKAHEAD_SEC = 12; // schedule next chunk this far ahead of playhead
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WAV parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _parseWavHeader(buf) {
|
||||
const view = new DataView(buf);
|
||||
const tag = (off) => String.fromCharCode(...new Uint8Array(buf, off, 4));
|
||||
|
||||
if (tag(0) !== "RIFF" || tag(8) !== "WAVE") return null;
|
||||
|
||||
let audioFormat = 1, channels = 2, sampleRate = 44100, bitsPerSample = 16;
|
||||
let dataOffset = -1, dataSize = 0;
|
||||
|
||||
let off = 12;
|
||||
while (off + 8 <= buf.byteLength) {
|
||||
const id = tag(off);
|
||||
const size = view.getUint32(off + 4, true);
|
||||
if (id === "fmt ") {
|
||||
audioFormat = view.getUint16(off + 8, true);
|
||||
channels = view.getUint16(off + 10, true);
|
||||
sampleRate = view.getUint32(off + 12, true);
|
||||
bitsPerSample = view.getUint16(off + 22, true);
|
||||
} else if (id === "data") {
|
||||
dataOffset = off + 8;
|
||||
dataSize = size;
|
||||
break;
|
||||
}
|
||||
off += 8 + size + (size & 1); // chunks are word-aligned
|
||||
}
|
||||
|
||||
if (dataOffset < 0) return null;
|
||||
|
||||
const bytesPerFrame = channels * (bitsPerSample >> 3);
|
||||
return {
|
||||
audioFormat, channels, sampleRate, bitsPerSample,
|
||||
dataOffset, dataSize, bytesPerFrame,
|
||||
duration: dataSize / (bytesPerFrame * sampleRate),
|
||||
};
|
||||
}
|
||||
|
||||
// Convert raw interleaved PCM bytes to an AudioBuffer.
|
||||
// Fast paths for the common cases (stereo 16-bit, stereo float32).
|
||||
function _pcmToAudioBuffer(ctx, pcmData, header) {
|
||||
const { channels, sampleRate, bitsPerSample, audioFormat } = header;
|
||||
const totalSamples = Math.floor(pcmData.byteLength / (channels * (bitsPerSample >> 3)));
|
||||
if (totalSamples === 0) return null;
|
||||
|
||||
const ab = ctx.createBuffer(channels, totalSamples, sampleRate);
|
||||
|
||||
if (bitsPerSample === 16) {
|
||||
const src = new Int16Array(pcmData);
|
||||
const scale = 1 / 32768;
|
||||
if (channels === 2) {
|
||||
const ch0 = ab.getChannelData(0);
|
||||
const ch1 = ab.getChannelData(1);
|
||||
for (let i = 0, j = 0; i < totalSamples; i++, j += 2) {
|
||||
ch0[i] = src[j] * scale;
|
||||
ch1[i] = src[j + 1] * scale;
|
||||
}
|
||||
} else {
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const out = ab.getChannelData(ch);
|
||||
for (let i = 0; i < totalSamples; i++) out[i] = src[i * channels + ch] * scale;
|
||||
}
|
||||
}
|
||||
} else if (audioFormat === 3 && bitsPerSample === 32) {
|
||||
const src = new Float32Array(pcmData);
|
||||
if (channels === 2) {
|
||||
const ch0 = ab.getChannelData(0);
|
||||
const ch1 = ab.getChannelData(1);
|
||||
for (let i = 0, j = 0; i < totalSamples; i++, j += 2) {
|
||||
ch0[i] = src[j];
|
||||
ch1[i] = src[j + 1];
|
||||
}
|
||||
} else {
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const out = ab.getChannelData(ch);
|
||||
for (let i = 0; i < totalSamples; i++) out[i] = src[i * channels + ch];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null; // unsupported format
|
||||
}
|
||||
|
||||
return ab;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {{name:string,url:string}[]} stems Active stems (WAV URLs).
|
||||
* @param {{onTime?:(t:number)=>void, onEnded?:()=>void, context?:AudioContext}} opts
|
||||
*/
|
||||
export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {}) {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
const ctx = context || new AC();
|
||||
const ownsCtx = !context;
|
||||
const master = ctx.createGain();
|
||||
|
||||
let stNode = null;
|
||||
let _playbackRate = 1.0;
|
||||
const _workletReady = (ctx.audioWorklet
|
||||
? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => {
|
||||
stNode = new AudioWorkletNode(ctx, 'soundtouch-processor');
|
||||
master.connect(stNode);
|
||||
stNode.connect(ctx.destination);
|
||||
}).catch((err) => {
|
||||
console.warn('[chunkedEngine] SoundTouch worklet failed, tape-effect fallback:', err);
|
||||
master.connect(ctx.destination);
|
||||
})
|
||||
: Promise.resolve().then(() => { master.connect(ctx.destination); }));
|
||||
|
||||
// Per-stem state: url, parsed WAV header, gain node, currently playing nodes
|
||||
const stemMap = new Map();
|
||||
for (const s of stems) {
|
||||
if (!s?.url) continue;
|
||||
const gain = ctx.createGain();
|
||||
gain.connect(master);
|
||||
stemMap.set(s.name, { url: s.url, header: null, gain, activeNodes: [] });
|
||||
}
|
||||
|
||||
let _duration = 0;
|
||||
let playing = false;
|
||||
let destroyed = false;
|
||||
let rafId = null;
|
||||
|
||||
// Playback clock: getCurrentTime = ctx.currentTime - _startCtxTime + _startOffset
|
||||
let _startCtxTime = 0;
|
||||
let _startOffset = 0;
|
||||
// _scheduledTo: track position (seconds) up to which AudioBufferSourceNodes
|
||||
// have already been scheduled. Always sits at a chunk boundary after play().
|
||||
let _scheduledTo = 0;
|
||||
// True once the first AudioBufferSourceNode is actually queued; guards
|
||||
// getCurrentTime() from advancing during an async chunk fetch.
|
||||
let _audioStarted = false;
|
||||
let _filling = false; // prevents concurrent _scheduleNext() calls
|
||||
|
||||
// Chunk cache: chunkIdx -> { promise: Promise<Map>, result: Map|null }
|
||||
// result is set synchronously once the promise resolves so play() can
|
||||
// schedule chunk 0 without an async await after ready() completes.
|
||||
const _cache = new Map();
|
||||
|
||||
function _getCurrentTime() {
|
||||
if (!playing || !_audioStarted) return _startOffset;
|
||||
return Math.min((ctx.currentTime - _startCtxTime) * _playbackRate + _startOffset, _duration);
|
||||
}
|
||||
|
||||
// --- fetch helpers ---
|
||||
|
||||
async function _fetchHeader(url) {
|
||||
const res = await fetch(url, { headers: { Range: "bytes=0-1023" } });
|
||||
const buf = await res.arrayBuffer();
|
||||
return _parseWavHeader(buf);
|
||||
}
|
||||
|
||||
async function _fetchPcm(stem, chunkIdx) {
|
||||
const { url, header } = stem;
|
||||
const { dataOffset, dataSize, bytesPerFrame, sampleRate } = header;
|
||||
const chunkBytes = Math.floor(CHUNK_SEC * sampleRate) * bytesPerFrame;
|
||||
const byteStart = dataOffset + chunkIdx * chunkBytes;
|
||||
if (byteStart >= dataOffset + dataSize) return null; // past end of file
|
||||
const byteEnd = Math.min(byteStart + chunkBytes, dataOffset + dataSize) - 1;
|
||||
const res = await fetch(url, { headers: { Range: `bytes=${byteStart}-${byteEnd}` } });
|
||||
if (!res.ok && res.status !== 206) throw new Error(`Range fetch ${res.status}`);
|
||||
return res.arrayBuffer();
|
||||
}
|
||||
|
||||
// Returns a Promise<Map<name, AudioBuffer>>. Deduplicates: if a fetch for
|
||||
// chunkIdx is already in flight, returns the same promise.
|
||||
function _fetchChunk(chunkIdx) {
|
||||
const hit = _cache.get(chunkIdx);
|
||||
if (hit) return hit.promise;
|
||||
|
||||
const entry = { promise: null, result: null };
|
||||
entry.promise = (async () => {
|
||||
const pairs = await Promise.all(
|
||||
[...stemMap.entries()]
|
||||
.filter(([, s]) => s.header)
|
||||
.map(async ([name, stem]) => {
|
||||
try {
|
||||
const pcm = await _fetchPcm(stem, chunkIdx);
|
||||
if (!pcm) return [name, null];
|
||||
return [name, _pcmToAudioBuffer(ctx, pcm, stem.header)];
|
||||
} catch (e) {
|
||||
console.warn(`[chunked] chunk ${chunkIdx} stem ${name}:`, e);
|
||||
return [name, null];
|
||||
}
|
||||
})
|
||||
);
|
||||
const map = new Map(pairs.filter(([, b]) => b));
|
||||
entry.result = map;
|
||||
// Keep at most the previous chunk + current in cache to bound memory.
|
||||
for (const k of _cache.keys()) {
|
||||
if (k < chunkIdx - 1) _cache.delete(k);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
_cache.set(chunkIdx, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
// --- node lifecycle ---
|
||||
|
||||
function _stopNodes() {
|
||||
for (const stem of stemMap.values()) {
|
||||
for (const node of stem.activeNodes) {
|
||||
try { node.stop(); } catch { /* already stopped */ }
|
||||
try { node.disconnect(); } catch { /* noop */ }
|
||||
}
|
||||
stem.activeNodes = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule all stems' AudioBufferSourceNodes to start at `when` (AudioContext
|
||||
// time), beginning `startSecs` into each buffer. Returns the duration of audio
|
||||
// that will play (max over stems of buffer.duration - startSecs).
|
||||
function _scheduleChunk(buffers, when, startSecs) {
|
||||
let playDur = 0;
|
||||
for (const [name, stem] of stemMap) {
|
||||
const buf = buffers.get(name);
|
||||
if (!buf) continue;
|
||||
const node = ctx.createBufferSource();
|
||||
node.buffer = buf;
|
||||
if (!stNode) node.playbackRate.value = _playbackRate; // tape-effect fallback
|
||||
node.connect(stem.gain);
|
||||
const offset = Math.max(0, Math.min(startSecs, buf.duration - 0.001));
|
||||
node.start(when, offset);
|
||||
stem.activeNodes.push(node);
|
||||
playDur = Math.max(playDur, buf.duration - offset);
|
||||
}
|
||||
return playDur;
|
||||
}
|
||||
|
||||
// --- lookahead scheduler ---
|
||||
|
||||
async function _scheduleNext() {
|
||||
const chunkIdx = Math.floor(_scheduledTo / CHUNK_SEC);
|
||||
_fetchChunk(chunkIdx + 1); // fire-and-forget pre-fetch of the chunk after
|
||||
|
||||
// Use the synchronous result if already decoded, otherwise await.
|
||||
const hit = _cache.get(chunkIdx);
|
||||
const buffers = hit?.result ?? await _fetchChunk(chunkIdx);
|
||||
if (!playing || destroyed) return;
|
||||
if (!buffers || buffers.size === 0) return; // past end; tick() handles onEnded
|
||||
|
||||
// With SoundTouch, sources play at 1.0x so scheduled wall-clock time equals
|
||||
// audio time. With tape-effect (_playbackRate != 1, no stNode), sources play
|
||||
// faster/slower so wall-clock time = audio seconds / _playbackRate.
|
||||
const playFactor = stNode ? 1.0 : _playbackRate;
|
||||
const idealWhen = _startCtxTime + (_scheduledTo - _startOffset) / playFactor;
|
||||
const when = Math.max(idealWhen, ctx.currentTime + 0.01);
|
||||
// If we're late (slow network), skip the audio portion that already "passed".
|
||||
const firstBuf = buffers.values().next().value;
|
||||
const maxSkip = firstBuf ? Math.max(0, firstBuf.duration - 0.001) : 0;
|
||||
const bufOffset = Math.min(Math.max(0, when - idealWhen) * playFactor, maxSkip);
|
||||
|
||||
const dur = _scheduleChunk(buffers, when, bufOffset);
|
||||
_scheduledTo += bufOffset + dur; // always advances by ~CHUNK_SEC
|
||||
}
|
||||
|
||||
function _maybeSchedule() {
|
||||
if (_filling || !playing || destroyed) return;
|
||||
if (_scheduledTo >= _duration) return;
|
||||
if (_scheduledTo - _getCurrentTime() < LOOKAHEAD_SEC) {
|
||||
_filling = true;
|
||||
_scheduleNext().finally(() => { _filling = false; });
|
||||
}
|
||||
}
|
||||
|
||||
function _tick() {
|
||||
if (!playing) return;
|
||||
const t = _getCurrentTime();
|
||||
if (t >= _duration) {
|
||||
playing = false;
|
||||
_audioStarted = false;
|
||||
_startOffset = _duration;
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
||||
onTime?.(_duration);
|
||||
onEnded?.();
|
||||
return;
|
||||
}
|
||||
_maybeSchedule();
|
||||
onTime?.(t);
|
||||
rafId = requestAnimationFrame(_tick);
|
||||
}
|
||||
|
||||
// --- public API ---
|
||||
|
||||
function play() {
|
||||
if (playing || destroyed) return;
|
||||
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
||||
playing = true;
|
||||
|
||||
const chunkIdx = Math.floor(_startOffset / CHUNK_SEC);
|
||||
const offsetWithin = _startOffset - chunkIdx * CHUNK_SEC;
|
||||
|
||||
const startWith = (buffers) => {
|
||||
if (!playing || destroyed) return;
|
||||
const when = ctx.currentTime + 0.05;
|
||||
_startCtxTime = when;
|
||||
const dur = _scheduleChunk(buffers, when, offsetWithin);
|
||||
_scheduledTo = _startOffset + dur;
|
||||
_audioStarted = true;
|
||||
_fetchChunk(chunkIdx + 1); // pre-fetch next chunk
|
||||
rafId = requestAnimationFrame(_tick);
|
||||
};
|
||||
|
||||
// chunk 0 is pre-decoded during ready(), so the sync path is the hot path.
|
||||
const hit = _cache.get(chunkIdx);
|
||||
if (hit?.result) {
|
||||
startWith(hit.result);
|
||||
} else {
|
||||
_fetchChunk(chunkIdx)
|
||||
.then(startWith)
|
||||
.catch((e) => {
|
||||
console.warn("[chunked] play fetch failed:", e);
|
||||
playing = false;
|
||||
_audioStarted = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (!playing) return;
|
||||
_startOffset = _getCurrentTime();
|
||||
_stopNodes();
|
||||
playing = false;
|
||||
_audioStarted = false;
|
||||
_scheduledTo = _startOffset;
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
||||
}
|
||||
|
||||
function seek(t) {
|
||||
const clamped = Math.max(0, Math.min(t, _duration || 0));
|
||||
const wasPlaying = playing;
|
||||
if (wasPlaying) {
|
||||
_stopNodes();
|
||||
playing = false;
|
||||
_audioStarted = false;
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
||||
}
|
||||
_startOffset = clamped;
|
||||
_scheduledTo = clamped;
|
||||
// Evict cache for chunks before the new position.
|
||||
const newIdx = Math.floor(clamped / CHUNK_SEC);
|
||||
for (const k of _cache.keys()) {
|
||||
if (k < newIdx) _cache.delete(k);
|
||||
}
|
||||
onTime?.(clamped);
|
||||
if (wasPlaying) play();
|
||||
}
|
||||
|
||||
// Initialize: fetch all WAV headers in parallel and load the SoundTouch worklet.
|
||||
// Chunk 0 is kicked off in the background so ready() resolves quickly (headers
|
||||
// only, ~6 x 1 KB) instead of blocking on the full first-chunk download (~5 MB).
|
||||
// play() handles the case where chunk 0 is not yet cached.
|
||||
const ready = (async () => {
|
||||
if (!stemMap.size) return false;
|
||||
|
||||
await Promise.all([
|
||||
_workletReady,
|
||||
...[...stemMap.values()].map(async (stem) => {
|
||||
try { stem.header = await _fetchHeader(stem.url); }
|
||||
catch (e) { console.warn("[chunked] header fetch failed:", e); }
|
||||
}),
|
||||
]);
|
||||
|
||||
for (const stem of stemMap.values()) {
|
||||
if (stem.header) _duration = Math.max(_duration, stem.header.duration);
|
||||
}
|
||||
if (!_duration) return false;
|
||||
|
||||
// Kick off chunk 0 and 1 in the background; play() picks up the cached result.
|
||||
_fetchChunk(0);
|
||||
_fetchChunk(1);
|
||||
return true;
|
||||
})();
|
||||
|
||||
return {
|
||||
ready,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
setTime: seek,
|
||||
isPlaying: () => playing,
|
||||
getCurrentTime: _getCurrentTime,
|
||||
getDuration: () => _duration,
|
||||
setLoop: () => {},
|
||||
setGain(name, v) {
|
||||
const stem = stemMap.get(name);
|
||||
if (stem) stem.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
|
||||
},
|
||||
setMasterGain(v) {
|
||||
master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
|
||||
},
|
||||
setPlaybackRate(rate) {
|
||||
const t = _getCurrentTime(); // capture before updating rate
|
||||
_playbackRate = rate;
|
||||
if (stNode) {
|
||||
stNode.parameters.get('tempo').value = rate;
|
||||
} else if (playing) {
|
||||
// Tape-effect fallback: seek to current position so new source nodes
|
||||
// are created with the updated playbackRate and scheduling math resets.
|
||||
seek(t);
|
||||
}
|
||||
},
|
||||
getAnalyser: () => null,
|
||||
getBuffers: () => new Map(),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
if (playing) pause();
|
||||
if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } }
|
||||
_cache.clear();
|
||||
stemMap.clear();
|
||||
if (ownsCtx) ctx.close().catch(() => {});
|
||||
},
|
||||
audioContext: ctx,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Fallback list used before /api/config responds. Kept in sync with
|
||||
// STEM_NAMES in app/core/config.py — the API is the canonical source.
|
||||
export let STEM_NAMES = ["vocals", "drums", "bass", "guitar", "piano", "other"];
|
||||
export let TRACK_NAMES = ["original", ...STEM_NAMES];
|
||||
|
||||
export async function syncStemNamesFromAPI() {
|
||||
try {
|
||||
const res = await fetch("/api/config");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.stem_names) && data.stem_names.length > 0) {
|
||||
STEM_NAMES = data.stem_names;
|
||||
TRACK_NAMES = ["original", ...STEM_NAMES];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[constants] failed to sync stem names from API:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export const STEM_DISPLAY = {
|
||||
vocals: "Vocals",
|
||||
drums: "Drums",
|
||||
bass: "Bass",
|
||||
guitar: "Guitar",
|
||||
piano: "Piano",
|
||||
other: "Other",
|
||||
original: "Original",
|
||||
};
|
||||
|
||||
// FL Studio-style channel palette: saturated but slightly dusty, designed
|
||||
// to read well on a dark background.
|
||||
export const STEM_COLORS = {
|
||||
vocals: "#e85f6f",
|
||||
drums: "#e89048",
|
||||
bass: "#e8b848",
|
||||
guitar: "#88d878",
|
||||
piano: "#b88fe8",
|
||||
other: "#88a8c8",
|
||||
original: "#a8b0bd",
|
||||
};
|
||||
|
||||
export const PROGRESS_COLOR = "#3a3a3a";
|
||||
|
||||
export const LOOP_DEFAULT_START_FRAC = 0.25;
|
||||
export const LOOP_DEFAULT_END_FRAC = 0.5;
|
||||
|
||||
export const LANE_VOLUME_MAX = 2;
|
||||
@@ -0,0 +1,511 @@
|
||||
import {
|
||||
form, urlInput, submitBtn, errorEl, jobBox, jobTitleEl, jobStageEl,
|
||||
jobDetailEl, jobCancelBtn, progressEl, titleEl, bpmChip, keyChip,
|
||||
eventSource, setEventSource, setCurrentJobId, currentJobId,
|
||||
selectedStems,
|
||||
} from "./state.js";
|
||||
import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js";
|
||||
import { stagePhrases } from "./phrases.js";
|
||||
import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards } from "./catalog.js";
|
||||
import { initSections } from "./sections.js";
|
||||
|
||||
// Playful stage label rotation (Claude-Code-style flair). The backend
|
||||
// emits truthful stage strings; we surface them in the small #job-detail
|
||||
// line so progress is debuggable, while #job-stage rotates whimsy.
|
||||
const ROTATION_MS = 2500;
|
||||
let phraseTimerId = null;
|
||||
let lastStatus = null;
|
||||
let jobPollTimerId = null;
|
||||
const renderedJobs = new Set();
|
||||
const jobSources = new Map();
|
||||
|
||||
const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]);
|
||||
|
||||
function setSubmitProcessing(processing) {
|
||||
submitBtn.disabled = processing;
|
||||
submitBtn.classList.toggle("loading", processing);
|
||||
document.querySelector(".strip-sq-process")?.classList.toggle("loading", processing);
|
||||
const label = submitBtn.querySelector("span");
|
||||
if (label) label.textContent = processing ? "Processing" : "Process";
|
||||
}
|
||||
|
||||
function pickPhrase(status) {
|
||||
const pool = stagePhrases[status] || stagePhrases.default;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
function setOverlayPhrase(text) {
|
||||
const el = document.getElementById("waveLoadingPhrase");
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function startPhraseRotation(status) {
|
||||
stopPhraseRotation();
|
||||
const phrase = pickPhrase(status);
|
||||
jobStageEl.textContent = phrase;
|
||||
setOverlayPhrase(phrase);
|
||||
phraseTimerId = setInterval(() => {
|
||||
const p = pickPhrase(status);
|
||||
jobStageEl.textContent = p;
|
||||
setOverlayPhrase(p);
|
||||
}, ROTATION_MS);
|
||||
}
|
||||
|
||||
function stopPhraseRotation() {
|
||||
if (phraseTimerId) {
|
||||
clearInterval(phraseTimerId);
|
||||
phraseTimerId = null;
|
||||
}
|
||||
jobStageEl.textContent = "";
|
||||
}
|
||||
|
||||
function stopJobPolling() {
|
||||
if (jobPollTimerId) {
|
||||
clearInterval(jobPollTimerId);
|
||||
jobPollTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function showError(message) {
|
||||
errorEl.textContent = "";
|
||||
const msg = document.createElement("div");
|
||||
msg.className = "error-msg";
|
||||
msg.textContent = message;
|
||||
const retry = document.createElement("button");
|
||||
retry.className = "retry-btn";
|
||||
retry.type = "button";
|
||||
retry.textContent = "Try again";
|
||||
retry.addEventListener("click", () => {
|
||||
errorEl.classList.add("hidden");
|
||||
urlInput.focus();
|
||||
urlInput.select();
|
||||
});
|
||||
errorEl.append(msg, retry);
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
setEventSource(null);
|
||||
}
|
||||
stopJobPolling();
|
||||
stopPhraseRotation();
|
||||
lastStatus = null;
|
||||
destroyPlayer();
|
||||
errorEl.classList.add("hidden");
|
||||
errorEl.textContent = "";
|
||||
jobBox.classList.add("hidden");
|
||||
jobCancelBtn.classList.add("hidden");
|
||||
jobTitleEl.textContent = "";
|
||||
jobStageEl.textContent = "";
|
||||
jobDetailEl.textContent = "";
|
||||
progressEl.value = 0;
|
||||
setSubmitProcessing(false);
|
||||
setCurrentJobId(null);
|
||||
}
|
||||
|
||||
function applyState(state) {
|
||||
if (state.job_id) {
|
||||
addTrackToLibrary({
|
||||
id: state.job_id,
|
||||
title: state.title || urlInput.value || "Processing track",
|
||||
channel: state.status === "done" ? "Extracted" : "Processing",
|
||||
thumb: state.thumbnail,
|
||||
stems: state.selected_stems || state.stems?.map((stem) => stem.name) || [...selectedStems],
|
||||
selectedStems: state.selected_stems || [...selectedStems],
|
||||
audioStems: state.stems || [],
|
||||
status: state.status,
|
||||
duration: state.duration,
|
||||
bpm: state.bpm,
|
||||
key: state.key,
|
||||
scale: state.scale,
|
||||
keyConfidence: state.key_confidence,
|
||||
lufs: state.lufs,
|
||||
peakDb: state.peak_db,
|
||||
stemPresence: state.stem_presence,
|
||||
sourceUrl: jobSources.get(state.job_id) || urlInput.value,
|
||||
createdAt: state.created_at,
|
||||
});
|
||||
setCurrentTrack(state.job_id);
|
||||
}
|
||||
if (state.title) {
|
||||
jobTitleEl.textContent = state.title;
|
||||
titleEl.textContent = state.title;
|
||||
}
|
||||
if (state.bpm) bpmChip.textContent = `${state.bpm} BPM`;
|
||||
if (state.key) keyChip.textContent = state.key;
|
||||
if (state.title || state.bpm || state.key || state.thumbnail) {
|
||||
updateFooterTrack({
|
||||
title: state.title,
|
||||
thumbnail: state.thumbnail,
|
||||
key: state.key,
|
||||
bpm: state.bpm,
|
||||
stemCount: state.stems ? state.stems.filter((s) => s.name !== "original").length : null,
|
||||
});
|
||||
}
|
||||
const summaryKey = document.getElementById("summary-key");
|
||||
const summaryBpm = document.getElementById("summary-bpm");
|
||||
const summaryScale = document.getElementById("summary-scale");
|
||||
const summaryScaleName = document.getElementById("summary-scale-name");
|
||||
const summaryConfidence = document.getElementById("summary-confidence");
|
||||
const summaryConfidenceLabel = document.getElementById("summary-confidence-label");
|
||||
const summaryLufs = document.getElementById("summary-lufs");
|
||||
const summaryPeak = document.getElementById("summary-peak");
|
||||
const summaryDuration = document.getElementById("summary-duration");
|
||||
if (summaryKey && state.key) summaryKey.textContent = state.key;
|
||||
if (summaryBpm && state.bpm) summaryBpm.textContent = String(state.bpm);
|
||||
if (summaryScale && state.scale) summaryScale.textContent = state.scale;
|
||||
if (summaryScaleName && state.scale) summaryScaleName.textContent = state.scale;
|
||||
if (summaryLufs && state.lufs != null) summaryLufs.textContent = state.lufs.toFixed(1);
|
||||
if (summaryPeak && state.peak_db != null) summaryPeak.textContent = `Peak ${state.peak_db.toFixed(1)} dB`;
|
||||
if (summaryDuration && state.duration) {
|
||||
const m = Math.floor(state.duration / 60);
|
||||
const s = Math.floor(state.duration % 60).toString().padStart(2, "0");
|
||||
summaryDuration.textContent = `${m.toString().padStart(2, "0")}:${s}`;
|
||||
}
|
||||
if (summaryConfidence && state.key_confidence != null) {
|
||||
const confidence = Math.max(0, Math.min(100, Number(state.key_confidence)));
|
||||
const confSpan = document.createElement("span");
|
||||
confSpan.textContent = `${confidence}%`;
|
||||
summaryConfidence.textContent = "";
|
||||
summaryConfidence.appendChild(confSpan);
|
||||
summaryConfidence.style.setProperty("--confidence-pct", confidence);
|
||||
summaryConfidence.classList.remove("hidden");
|
||||
summaryConfidenceLabel?.classList.remove("hidden");
|
||||
}
|
||||
const summaryDr = document.getElementById("summary-dr");
|
||||
const summaryDrLabel = document.getElementById("summary-dr-label");
|
||||
const summaryStability = document.getElementById("summary-stability");
|
||||
const summaryStabilityLabel = document.getElementById("summary-stability-label");
|
||||
if (summaryDr && state.dynamic_range != null) summaryDr.textContent = String(state.dynamic_range);
|
||||
if (summaryDrLabel && state.dynamic_range != null) {
|
||||
const dr = state.dynamic_range;
|
||||
summaryDrLabel.textContent = dr < 7 ? "Compressed" : dr < 10 ? "Moderate" : dr < 14 ? "High" : "Wide";
|
||||
}
|
||||
if (summaryStability && state.tempo_stability != null) {
|
||||
summaryStability.textContent = `${state.tempo_stability}%`;
|
||||
summaryStability.className = "meta-card-value" + (state.tempo_stability >= 80 ? " stability-high" : "");
|
||||
}
|
||||
if (summaryStabilityLabel && state.tempo_stability != null) {
|
||||
const s = state.tempo_stability;
|
||||
summaryStabilityLabel.textContent = s >= 90 ? "Very Stable" : s >= 70 ? "Stable" : s >= 50 ? "Moderate" : "Variable";
|
||||
}
|
||||
if (state.stem_presence != null) {
|
||||
applyStemPresenceCards(state.stem_presence);
|
||||
}
|
||||
// Stage label is owned by the phrase-rotation timer below; we don't
|
||||
// overwrite it from each SSE tick. The truthful backend stage goes
|
||||
// to the small detail line instead.
|
||||
jobDetailEl.textContent = state.stage || "";
|
||||
progressEl.value = Math.round((state.progress || 0) * 100);
|
||||
|
||||
// Cancel button is visible exactly while the job is in a non-terminal state.
|
||||
const terminal = TERMINAL_STATUSES.has(state.status);
|
||||
jobCancelBtn.classList.toggle("hidden", terminal);
|
||||
|
||||
if (state.status !== lastStatus) {
|
||||
if (terminal) stopPhraseRotation();
|
||||
else startPhraseRotation(state.status);
|
||||
lastStatus = state.status;
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
stopJobPolling();
|
||||
updateTrackStatus(state.job_id, "error");
|
||||
setWaveformLoading(false);
|
||||
showError(state.error || "Unknown error");
|
||||
setSubmitProcessing(false);
|
||||
} else if (state.status === "cancelled") {
|
||||
stopJobPolling();
|
||||
updateTrackStatus(state.job_id, "cancelled");
|
||||
setWaveformLoading(false);
|
||||
jobBox.classList.add("hidden");
|
||||
setSubmitProcessing(false);
|
||||
} else if (state.status === "done") {
|
||||
stopJobPolling();
|
||||
updateTrackStatus(state.job_id, "done");
|
||||
jobBox.classList.add("hidden");
|
||||
if (!renderedJobs.has(state.job_id)) {
|
||||
renderedJobs.add(state.job_id);
|
||||
wireUpAudio(
|
||||
state.job_id,
|
||||
state.stems || [],
|
||||
state.duration || 0,
|
||||
state.thumbnail,
|
||||
state.mix_url ?? null,
|
||||
state.title || "",
|
||||
null,
|
||||
state.has_video ?? false,
|
||||
);
|
||||
initSections(state.job_id, state.sections, state.duration || 0);
|
||||
}
|
||||
setSubmitProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeJob(jobId) {
|
||||
const r = await fetch(`/api/jobs/${jobId}`);
|
||||
if (!r.ok) {
|
||||
if (r.status === 404) throw new Error("Job no longer exists on the server");
|
||||
throw new Error(`Job probe failed: ${r.status}`);
|
||||
}
|
||||
const s = await r.json();
|
||||
applyState(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
function startJobPolling(jobId) {
|
||||
stopJobPolling();
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = await probeJob(jobId);
|
||||
if (TERMINAL_STATUSES.has(s.status)) stopJobPolling();
|
||||
} catch (err) {
|
||||
console.warn("[job] REST fallback failed:", err);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
jobPollTimerId = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// Connect (or reconnect) to the SSE stream for a job. On unexpected
|
||||
// disconnect we probe /api/jobs/{id} to decide: if the job is already
|
||||
// terminal, accept its final state; otherwise reconnect with backoff.
|
||||
// Falls back to REST polling only after SSE exhausts its retry budget.
|
||||
function connectEvents(jobId) {
|
||||
let attempt = 0;
|
||||
let stopped = false;
|
||||
|
||||
const open = () => {
|
||||
const es = new EventSource(`/api/jobs/${jobId}/events`);
|
||||
setEventSource(es);
|
||||
|
||||
es.onmessage = (ev) => {
|
||||
attempt = 0; // any successful frame resets backoff
|
||||
let s;
|
||||
try { s = JSON.parse(ev.data); } catch { return; }
|
||||
// Defer by one tick so synchronous user event handlers (clicks,
|
||||
// input events) always complete before SSE state is applied.
|
||||
setTimeout(() => {
|
||||
applyState(s);
|
||||
if (TERMINAL_STATUSES.has(s.status)) {
|
||||
stopped = true;
|
||||
es.close();
|
||||
setEventSource(null);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
es.onerror = async () => {
|
||||
if (stopped) return;
|
||||
es.close();
|
||||
setEventSource(null);
|
||||
|
||||
// Probe REST once before declaring failure -- handles dev-server
|
||||
// reloads and brief network blips where the job is actually fine.
|
||||
try {
|
||||
const s = await probeJob(jobId);
|
||||
if (TERMINAL_STATUSES.has(s.status)) {
|
||||
stopped = true;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message === "Job no longer exists on the server") {
|
||||
stopped = true;
|
||||
showError(err.message);
|
||||
setSubmitProcessing(false);
|
||||
return;
|
||||
}
|
||||
// Network down -- fall through to backoff.
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
if (attempt > 6) {
|
||||
// SSE gave up — activate REST polling as the fallback.
|
||||
startJobPolling(jobId);
|
||||
return;
|
||||
}
|
||||
// 0.5s, 1s, 2s, 4s, 8s, 16s
|
||||
const delay = 500 * Math.pow(2, attempt - 1);
|
||||
setTimeout(() => { if (!stopped) open(); }, delay);
|
||||
};
|
||||
};
|
||||
|
||||
open();
|
||||
}
|
||||
|
||||
async function cancelCurrentJob() {
|
||||
const id = currentJobId;
|
||||
if (!id) return;
|
||||
jobCancelBtn.disabled = true;
|
||||
jobCancelBtn.textContent = "Cancelling…";
|
||||
try {
|
||||
await fetch(`/api/jobs/${id}/cancel`, { method: "POST" });
|
||||
// The next SSE frame (or the REST probe in connectEvents) will
|
||||
// surface the cancelled state and hide the button via applyState.
|
||||
} catch {
|
||||
/* SSE will reflect the result regardless */
|
||||
} finally {
|
||||
jobCancelBtn.disabled = false;
|
||||
jobCancelBtn.textContent = "Cancel";
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFilename(name) {
|
||||
// Strip extension, collapse whitespace, cap at 120 chars — mirrors the
|
||||
// backend _sanitize_title() so title and sourceUrl match on both sides.
|
||||
return name
|
||||
.replace(/\.[^.]+$/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
// Programmatic URL import — re-uses the full studio/SSE pipeline (same as the
|
||||
// import form's URL path). Used by the library "Sync again" auto-restore to
|
||||
// re-download + re-separate a track whose backend audio was swept. Takes over
|
||||
// the studio like a normal import. Returns the new job id, or null on failure.
|
||||
export async function importFromUrl(url, { title, stems } = {}) {
|
||||
if (!url || url.startsWith("local:")) return null; // local files can't auto-restore
|
||||
reset();
|
||||
setSubmitProcessing(true);
|
||||
setWaveformLoading(true, "");
|
||||
const stemSel = stems?.length ? stems : [...selectedStems];
|
||||
|
||||
let jobId;
|
||||
try {
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, stems: stemSel }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || res.statusText);
|
||||
jobId = data.job_id;
|
||||
} catch (err) {
|
||||
showError(`Failed to restore track: ${err.message}`);
|
||||
setSubmitProcessing(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
setCurrentJobId(jobId);
|
||||
jobSources.set(jobId, url);
|
||||
// Merges into the existing library entry by sourceUrl (replaceTrackId),
|
||||
// preserving its folder placement; status updates as SSE frames arrive.
|
||||
addTrackToLibrary({
|
||||
id: jobId,
|
||||
title: title || url || "Processing track",
|
||||
channel: "Processing",
|
||||
thumb: "",
|
||||
stems: stemSel,
|
||||
selectedStems: stemSel,
|
||||
audioStems: [],
|
||||
status: "processing",
|
||||
bpm: null,
|
||||
key: null,
|
||||
scale: null,
|
||||
keyConfidence: null,
|
||||
lufs: null,
|
||||
peakDb: null,
|
||||
sourceUrl: url,
|
||||
});
|
||||
setCurrentTrack(jobId);
|
||||
|
||||
jobBox.classList.add("hidden");
|
||||
jobCancelBtn.classList.add("hidden");
|
||||
startPhraseRotation("queued");
|
||||
lastStatus = "queued";
|
||||
connectEvents(jobId);
|
||||
return jobId;
|
||||
}
|
||||
|
||||
export function wireJobForm() {
|
||||
jobCancelBtn.addEventListener("click", cancelCurrentJob);
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
setSubmitProcessing(true);
|
||||
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
// Prefer _file cache: browsers (WKWebView, Chromium) silently clear
|
||||
// fileInput.files after a fetch() submission, breaking re-submits.
|
||||
const file = fileInput?._file ?? fileInput?.files?.[0] ?? null;
|
||||
const sanitized = file ? sanitizeFilename(file.name) : null;
|
||||
const sourceUrl = file ? `local:${sanitized}` : urlInput.value;
|
||||
const displayTitle = sanitized ?? (urlInput.value || "Processing track");
|
||||
|
||||
const postUrlText = document.getElementById("post-url-text");
|
||||
if (postUrlText) postUrlText.textContent = displayTitle;
|
||||
|
||||
// Show overlay immediately for both paths. File uploads show "Uploading…"
|
||||
// in the overlay phrase until the fetch completes and SSE takes over.
|
||||
setWaveformLoading(true, file ? "Uploading…" : "");
|
||||
if (file) {
|
||||
lastStatus = "queued";
|
||||
}
|
||||
|
||||
let fetchInit;
|
||||
if (file) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("stems", JSON.stringify([...selectedStems]));
|
||||
fetchInit = { method: "POST", body: fd };
|
||||
} else {
|
||||
fetchInit = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: urlInput.value,
|
||||
// Backend uses this to decide whether to ffmpeg-amix a
|
||||
// "selected stems" track (mix.wav) at the end of the pipeline.
|
||||
stems: [...selectedStems],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
let jobId;
|
||||
try {
|
||||
const res = await fetch("/api/jobs", fetchInit);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || res.statusText);
|
||||
jobId = data.job_id;
|
||||
} catch (err) {
|
||||
if (file) jobBox.classList.add("hidden");
|
||||
showError(`Failed to start job: ${err.message}`);
|
||||
setSubmitProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentJobId(jobId);
|
||||
jobSources.set(jobId, sourceUrl);
|
||||
addTrackToLibrary({
|
||||
id: jobId,
|
||||
title: displayTitle,
|
||||
channel: "Processing",
|
||||
thumb: "",
|
||||
stems: [...selectedStems],
|
||||
selectedStems: [...selectedStems],
|
||||
audioStems: [],
|
||||
status: "processing",
|
||||
bpm: null,
|
||||
key: null,
|
||||
scale: null,
|
||||
keyConfidence: null,
|
||||
lufs: null,
|
||||
peakDb: null,
|
||||
sourceUrl,
|
||||
});
|
||||
setCurrentTrack(jobId);
|
||||
|
||||
// Both paths: keep job box hidden, overlay drives the UI.
|
||||
// Start phrase rotation now that the job exists on the server.
|
||||
jobBox.classList.add("hidden");
|
||||
jobCancelBtn.classList.add("hidden");
|
||||
startPhraseRotation("queued");
|
||||
lastStatus = "queued";
|
||||
|
||||
connectEvents(jobId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import {
|
||||
playBtn, loopBtn, multitrack, totalDuration, loopEnabled, loopStart, loopEnd,
|
||||
setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady,
|
||||
} from "./state.js";
|
||||
import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js";
|
||||
import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js";
|
||||
import { wireJobForm, showError } from "./job.js";
|
||||
import { wireTransportButtons } from "./transport.js";
|
||||
import { togglePlayPause, updateLoopRegionVisual } from "./transport.js";
|
||||
import { wireStemListControls, wireMixerToolbar } from "./mixer.js";
|
||||
import { initCatalog } from "./catalog.js";
|
||||
import { runStoreMigrationIfNeeded } from "./utils.js";
|
||||
|
||||
// ─── Stem choice toggles on the import page ───
|
||||
//
|
||||
// Filter-chip semantics (Spotify-style). The natural mental model when
|
||||
// a user sees all 6 stems lit up is "everything is extracted"; when
|
||||
// they then click ONE chip, they expect "now only this one". A plain
|
||||
// toggle inverts the clicked chip and leaves the others on, which
|
||||
// reads as "I just deselected the one I wanted" -- exactly the user
|
||||
// confusion that prompted this fix.
|
||||
//
|
||||
// Algorithm:
|
||||
// - "All selected" is the implicit default (no filter applied).
|
||||
// - First click on a chip while in default state switches to
|
||||
// "only this stem" (clears all others).
|
||||
// - Subsequent clicks on inactive chips ADD them to the filter.
|
||||
// - Clicks on the only-selected chip clear it; if that empties the
|
||||
// selection, we revert to "all selected" (wraparound).
|
||||
//
|
||||
// Persisted across reloads so the next song honors the user's last
|
||||
// chosen subset, but a 0-selection state is normalized to all 6.
|
||||
function refreshStemChoiceVisuals() {
|
||||
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
|
||||
btn.setAttribute(
|
||||
"aria-pressed",
|
||||
String(selectedStems.has(btn.dataset.stem)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStemChoiceClick(stem) {
|
||||
const allSelected = selectedStems.size === STEM_NAMES.length;
|
||||
if (allSelected) {
|
||||
// Default state -> switch to "only this stem".
|
||||
selectedStems.clear();
|
||||
selectedStems.add(stem);
|
||||
} else if (selectedStems.has(stem)) {
|
||||
selectedStems.delete(stem);
|
||||
if (selectedStems.size === 0) {
|
||||
// Empty out wraps back to "all" so the user is never stuck.
|
||||
for (const n of STEM_NAMES) selectedStems.add(n);
|
||||
}
|
||||
} else {
|
||||
selectedStems.add(stem);
|
||||
}
|
||||
saveSelectedStems();
|
||||
refreshStemChoiceVisuals();
|
||||
buildStripStems();
|
||||
}
|
||||
|
||||
function wireStemChoiceButtons() {
|
||||
refreshStemChoiceVisuals();
|
||||
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
|
||||
btn.addEventListener("click", () => handleStemChoiceClick(btn.dataset.stem));
|
||||
}
|
||||
}
|
||||
|
||||
function wireAllButton() {
|
||||
const allBtn = document.getElementById("stemAllBtn");
|
||||
if (!allBtn) return;
|
||||
|
||||
function syncAllBtn() {
|
||||
allBtn.setAttribute("aria-pressed", String(selectedStems.size === STEM_NAMES.length));
|
||||
}
|
||||
|
||||
allBtn.addEventListener("click", () => {
|
||||
const allSelected = selectedStems.size === STEM_NAMES.length;
|
||||
if (allSelected) {
|
||||
selectedStems.clear();
|
||||
} else {
|
||||
for (const n of STEM_NAMES) selectedStems.add(n);
|
||||
}
|
||||
saveSelectedStems();
|
||||
refreshStemChoiceVisuals();
|
||||
buildStripStems();
|
||||
syncAllBtn();
|
||||
});
|
||||
|
||||
/* Keep All in sync when individual stems are toggled */
|
||||
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
|
||||
btn.addEventListener("click", syncAllBtn);
|
||||
}
|
||||
|
||||
syncAllBtn();
|
||||
}
|
||||
|
||||
// ─── Wire everything up ───
|
||||
|
||||
syncStemNamesFromAPI().then(() => buildStripStems());
|
||||
wireJobForm();
|
||||
wireTransportButtons();
|
||||
wireFooterControls();
|
||||
requestAnimationFrame(drawFooterPlaceholder);
|
||||
wireStemListControls();
|
||||
wireMixerToolbar();
|
||||
wireStemChoiceButtons();
|
||||
wireAllButton();
|
||||
wireFileDrop();
|
||||
wireAppShellControls();
|
||||
|
||||
(async () => {
|
||||
await runStoreMigrationIfNeeded();
|
||||
await stemSelectionReady;
|
||||
refreshStemChoiceVisuals();
|
||||
await initCatalog();
|
||||
})().catch(console.error);
|
||||
|
||||
// ─── Footer: speed dropdown, export dropdown, scrub seek ───
|
||||
|
||||
function wireFooterControls() {
|
||||
// ── Export split-button dropdown ──
|
||||
// The full button toggles the export menu. Export actions live inside
|
||||
// the dropdown so the hit target is predictable.
|
||||
// The menu offers Mix / All Stems / Current Region, with a WAV/MP3 toggle in
|
||||
// the header. All exports reuse the backend-served download helpers.
|
||||
const exportBtn = document.getElementById("t-export-btn");
|
||||
const exportPanel = document.getElementById("t-export-panel");
|
||||
const exportLabel = document.getElementById("t-export-label");
|
||||
const fmtWav = document.getElementById("t-fmt-wav");
|
||||
const fmtMp3 = document.getElementById("t-fmt-mp3");
|
||||
const fmtFlac = document.getElementById("t-fmt-flac");
|
||||
const fmtMp4 = document.getElementById("t-fmt-mp4");
|
||||
const exportWrap = document.getElementById("footer-export-wrap");
|
||||
const itemMix = document.getElementById("t-export-mix");
|
||||
const itemStems = document.getElementById("t-export-stems");
|
||||
const itemRegion = document.getElementById("t-export-region");
|
||||
const mixDescEl = itemMix?.querySelector(".chip-item-desc");
|
||||
// Only the rows actually visible in the current format mode (MP4 hides the
|
||||
// audio-only Stems/Region rows).
|
||||
const actionItems = () =>
|
||||
[itemMix, itemStems, itemRegion].filter((it) => it && it.offsetParent !== null);
|
||||
|
||||
// MP4 is a format choice, shown only for jobs with a preserved video track.
|
||||
// It applies to the mix only — stems/region are audio-only.
|
||||
const videoAvailable = () => !!exportWrap?.classList.contains("has-video");
|
||||
|
||||
let format = "wav";
|
||||
let busy = false;
|
||||
|
||||
const panelOpen = () => exportPanel && !exportPanel.classList.contains("hidden");
|
||||
function openPanel() {
|
||||
closeAllChipPanels();
|
||||
// A previous (video) job may have left MP4 selected; revert if unavailable now.
|
||||
if (format === "mp4" && !videoAvailable()) setFormat("wav");
|
||||
exportPanel?.classList.remove("hidden");
|
||||
exportBtn?.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
function closePanel() {
|
||||
exportPanel?.classList.add("hidden");
|
||||
exportBtn?.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function setFormat(f) {
|
||||
format = f;
|
||||
for (const [btn, val] of [[fmtWav, "wav"], [fmtMp3, "mp3"], [fmtFlac, "flac"], [fmtMp4, "mp4"]]) {
|
||||
btn?.classList.toggle("active", f === val);
|
||||
btn?.setAttribute("aria-checked", String(f === val));
|
||||
}
|
||||
applyFormatState();
|
||||
}
|
||||
fmtWav?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("wav"); });
|
||||
fmtMp3?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp3"); });
|
||||
fmtFlac?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("flac"); });
|
||||
fmtMp4?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp4"); });
|
||||
|
||||
// MP4 exports the mix muxed with the source video. Stems and region have no
|
||||
// video equivalent, so they're hidden (via .fmt-mp4) while MP4 is selected,
|
||||
// leaving just "Export Mix".
|
||||
function applyFormatState() {
|
||||
const video = format === "mp4";
|
||||
exportPanel?.classList.toggle("fmt-mp4", video);
|
||||
if (mixDescEl) {
|
||||
mixDescEl.textContent = video ? "Export mix with the original video" : "Export the mixed audio";
|
||||
}
|
||||
if (!video) updateLoopRegionVisual(); // restores the region item's disabled state
|
||||
}
|
||||
|
||||
function resetBusy() {
|
||||
busy = false;
|
||||
exportBtn?.classList.remove("is-busy");
|
||||
if (exportLabel) exportLabel.textContent = "Export Mix";
|
||||
itemMix?.removeAttribute("aria-disabled");
|
||||
applyFormatState(); // restores stems/region per the active format
|
||||
}
|
||||
|
||||
// Single-file (mix/region) downloads give no JS-observable byte progress, so
|
||||
// show a brief indeterminate "Exporting…" state, then reset.
|
||||
function flashBusy() {
|
||||
busy = true;
|
||||
exportBtn?.classList.add("is-busy");
|
||||
if (exportLabel) exportLabel.textContent = "Exporting…";
|
||||
actionItems().forEach((it) => it?.setAttribute("aria-disabled", "true"));
|
||||
closePanel();
|
||||
window.setTimeout(resetBusy, 1200);
|
||||
}
|
||||
|
||||
exportBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (busy) return;
|
||||
panelOpen() ? closePanel() : openPanel();
|
||||
});
|
||||
|
||||
// Export Mix: MP4 produces the video; any other format an audio mix.
|
||||
itemMix?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (busy) return;
|
||||
const ok = format === "mp4" ? downloadCurrentVideo() : downloadCurrentMix(format);
|
||||
if (!ok) { showError("All stems are muted - nothing to export."); return; }
|
||||
flashBusy();
|
||||
});
|
||||
|
||||
itemRegion?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (busy || itemRegion.getAttribute("aria-disabled") === "true") return;
|
||||
const ok = downloadRegionMix(format);
|
||||
if (!ok) { showError("All stems are muted - nothing to export."); return; }
|
||||
flashBusy();
|
||||
});
|
||||
|
||||
// All Stems = a single backend-built ZIP, named after the song. Audio-only,
|
||||
// so it's disabled (and inert) while MP4 is the selected format.
|
||||
itemStems?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (busy || itemStems.getAttribute("aria-disabled") === "true") return;
|
||||
downloadAllStemsZip(format);
|
||||
flashBusy();
|
||||
});
|
||||
|
||||
// Keyboard: ↓ opens/moves into the menu, ↑/↓ cycle rows, Esc closes + restores focus.
|
||||
exportBtn?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!panelOpen()) openPanel();
|
||||
actionItems().find((it) => it?.getAttribute("aria-disabled") !== "true")?.focus();
|
||||
}
|
||||
});
|
||||
exportPanel?.addEventListener("keydown", (e) => {
|
||||
const focusable = actionItems().filter((it) => it && it.getAttribute("aria-disabled") !== "true");
|
||||
const idx = focusable.indexOf(document.activeElement);
|
||||
if (e.key === "Escape") { closePanel(); exportBtn?.focus(); }
|
||||
else if (e.key === "ArrowDown") { e.preventDefault(); focusable[(idx + 1) % focusable.length]?.focus(); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); focusable[(idx - 1 + focusable.length) % focusable.length]?.focus(); }
|
||||
});
|
||||
|
||||
// ── Scrub bar seek ──
|
||||
const scrub = document.getElementById("footer-scrub");
|
||||
if (scrub) {
|
||||
function seekToX(clientX) {
|
||||
if (!multitrack || !totalDuration) return;
|
||||
const rect = scrub.getBoundingClientRect();
|
||||
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
multitrack.setTime(frac * totalDuration);
|
||||
}
|
||||
let _scrubbing = false;
|
||||
scrub.addEventListener("mousedown", (e) => {
|
||||
_scrubbing = true;
|
||||
seekToX(e.clientX);
|
||||
});
|
||||
document.addEventListener("mousemove", (e) => { if (_scrubbing) seekToX(e.clientX); });
|
||||
document.addEventListener("mouseup", () => { _scrubbing = false; });
|
||||
}
|
||||
|
||||
// ── Close panels on outside click ──
|
||||
document.addEventListener("click", closeAllChipPanels);
|
||||
}
|
||||
|
||||
function closeAllChipPanels() {
|
||||
document.querySelectorAll(".footer-chip-panel:not(.hidden)").forEach((p) => {
|
||||
p.classList.add("hidden");
|
||||
p.previousElementSibling?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
}
|
||||
|
||||
// ─── File drop on URL input ───
|
||||
|
||||
function wireFileDrop() {
|
||||
const urlWrap = document.querySelector(".url-wrap");
|
||||
const urlInput = document.getElementById("url");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const filePill = document.getElementById("filePill");
|
||||
const fileName = document.getElementById("fileName");
|
||||
const fileSize = document.getElementById("fileSize");
|
||||
const fileClear = document.getElementById("fileClear");
|
||||
if (!urlWrap || !urlInput || !fileInput || !filePill) return;
|
||||
|
||||
function formatBytes(n) {
|
||||
return n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const MAX_UPLOAD_BYTES = 400 * 1024 * 1024; // must match server _MAX_UPLOAD_BYTES
|
||||
|
||||
function applyFile(file) {
|
||||
if (!file) return;
|
||||
const lower = file.name.toLowerCase();
|
||||
if (!lower.endsWith(".mp3") && !lower.endsWith(".wav") && !lower.endsWith(".flac") &&
|
||||
!lower.endsWith(".mp4") && !lower.endsWith(".m4a")) {
|
||||
showError("Only MP3, WAV, FLAC, MP4, and M4A files are supported.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
showError(`File is too large (${formatBytes(file.size)}). Maximum is 400 MB.`);
|
||||
return;
|
||||
}
|
||||
if (fileName) fileName.textContent = file.name;
|
||||
if (fileSize) fileSize.textContent = formatBytes(file.size);
|
||||
filePill.classList.remove("hidden");
|
||||
urlWrap.classList.add("has-file");
|
||||
// Cache the File object directly on the element so job.js can always
|
||||
// retrieve it even after the browser clears fileInput.files following
|
||||
// a fetch() submission (known WKWebView / Chromium behaviour).
|
||||
fileInput._file = file;
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
fileInput.files = dt.files;
|
||||
urlInput.value = "";
|
||||
urlInput.removeAttribute("required");
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
filePill.classList.add("hidden");
|
||||
urlWrap.classList.remove("has-file");
|
||||
fileInput._file = null;
|
||||
fileInput.value = "";
|
||||
urlInput.setAttribute("required", "");
|
||||
}
|
||||
|
||||
fileClear?.addEventListener("click", clearFile);
|
||||
|
||||
urlWrap.addEventListener("dragover", (e) => {
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
urlWrap.classList.add("drag-over");
|
||||
});
|
||||
urlWrap.addEventListener("dragleave", (e) => {
|
||||
if (!urlWrap.contains(e.relatedTarget)) urlWrap.classList.remove("drag-over");
|
||||
});
|
||||
urlWrap.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
urlWrap.classList.remove("drag-over");
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) applyFile(file);
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files[0]) applyFile(fileInput.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── App shell controls ───
|
||||
|
||||
function wireAppShellControls() {
|
||||
document.getElementById("appMenuBtn")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const app = document.querySelector(".app");
|
||||
// In trash view: switch back to library.
|
||||
if (document.querySelector(".sidebar.trash-view, .sidebar.favorites-view")) {
|
||||
document.querySelector(".rail-library")?.click();
|
||||
}
|
||||
// If collapsed: open. Never collapse from the library button.
|
||||
if (app?.classList.contains("cat-collapsed")) {
|
||||
document.getElementById("catalogToggle")?.click();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// ─── Keyboard shortcuts ───
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!multitrack) return;
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
if (e.code === "Space") {
|
||||
e.preventDefault();
|
||||
togglePlayPause();
|
||||
} else if (e.code === "BracketLeft") {
|
||||
e.preventDefault();
|
||||
multitrack.setTime(Math.max(0, multitrack.getCurrentTime() - 5));
|
||||
} else if (e.code === "BracketRight") {
|
||||
e.preventDefault();
|
||||
multitrack.setTime(
|
||||
Math.min(multitrack.getDuration(), multitrack.getCurrentTime() + 5),
|
||||
);
|
||||
} else if (e.code === "KeyL") {
|
||||
e.preventDefault();
|
||||
loopBtn.click();
|
||||
} else if (e.code === "KeyI" && loopEnabled && multitrack) {
|
||||
e.preventDefault();
|
||||
setLoopStart(Math.min(multitrack.getCurrentTime(), loopEnd - 0.5));
|
||||
updateLoopRegionVisual();
|
||||
} else if (e.code === "KeyO" && loopEnabled && multitrack) {
|
||||
e.preventDefault();
|
||||
setLoopEnd(Math.max(multitrack.getCurrentTime(), loopStart + 0.5));
|
||||
updateLoopRegionVisual();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── External links ───
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const dl = e.target.closest("a.lane-dl");
|
||||
if (dl?.href) {
|
||||
const openUrl = window.__TAURI__?.core?.invoke;
|
||||
if (openUrl) {
|
||||
e.preventDefault();
|
||||
openUrl("open_url", { url: dl.href });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const anchor = e.target.closest('a[target="_blank"]');
|
||||
if (anchor?.href) {
|
||||
const openUrl = window.__TAURI__?.core?.invoke;
|
||||
if (openUrl) {
|
||||
e.preventDefault();
|
||||
openUrl("open_url", { url: anchor.href });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Global error logging ───
|
||||
|
||||
window.addEventListener("error", (e) => {
|
||||
console.error("[app:error]", e.message, "\n", e.filename, ":", e.lineno, "\n", e.error?.stack ?? "");
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
console.error("[app:unhandledrejection]", e.reason?.message ?? e.reason, "\n", e.reason?.stack ?? "");
|
||||
});
|
||||
|
||||
// ─── Bootstrap ───
|
||||
|
||||
buildStripStems();
|
||||
renderEmptyShell();
|
||||
@@ -0,0 +1,500 @@
|
||||
import {
|
||||
STEM_NAMES, TRACK_NAMES, STEM_COLORS, STEM_DISPLAY, LANE_VOLUME_MAX,
|
||||
} from "./constants.js";
|
||||
import {
|
||||
mixerState, mixerEl, stemListEl, currentJobId, multitrack, trackIndex,
|
||||
masterVolume, audioEngine,
|
||||
} from "./state.js";
|
||||
import { storeGet, storeSetDebounced } from "./utils.js";
|
||||
|
||||
function defaultMixerEntry() {
|
||||
return { volume: 1, muted: false, soloed: false };
|
||||
}
|
||||
|
||||
export function ensureMixerStateDefaults() {
|
||||
for (const name of TRACK_NAMES) {
|
||||
if (!mixerState[name]) mixerState[name] = defaultMixerEntry();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMixIntoState(jobId, loadedStemNames = STEM_NAMES) {
|
||||
let stored = {};
|
||||
try {
|
||||
const data = await storeGet(`stemdeck:mix:${jobId}`, {});
|
||||
if (data && typeof data === "object") stored = data;
|
||||
} catch (e) { console.warn("[mixer] failed to load mix state:", e); }
|
||||
for (const name of TRACK_NAMES) {
|
||||
Object.assign(mixerState[name], defaultMixerEntry(), stored[name] || {});
|
||||
}
|
||||
// If all loaded stems are muted the session is unplayable -- unmute as recovery.
|
||||
const loadedStems = loadedStemNames.filter((n) => mixerState[n]);
|
||||
if (loadedStems.length > 0 && loadedStems.every((n) => mixerState[n].muted)) {
|
||||
for (const name of loadedStems) mixerState[name].muted = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resetMixerState() {
|
||||
for (const name of TRACK_NAMES) {
|
||||
Object.assign(mixerState[name], defaultMixerEntry());
|
||||
}
|
||||
}
|
||||
|
||||
function saveMix() {
|
||||
if (!currentJobId) return;
|
||||
storeSetDebounced(`stemdeck:mix:${currentJobId}`, mixerState);
|
||||
}
|
||||
|
||||
export function applyMix() {
|
||||
if (!multitrack) return;
|
||||
const anySolo = TRACK_NAMES.some((name) => trackIndex[name] !== undefined && mixerState[name]?.soloed);
|
||||
for (const name of TRACK_NAMES) {
|
||||
const s = mixerState[name];
|
||||
if (!s) continue;
|
||||
let effective = s.volume;
|
||||
if (s.muted) effective = 0;
|
||||
else if (anySolo && !s.soloed) effective = 0;
|
||||
const idx = trackIndex[name];
|
||||
if (idx === undefined) continue;
|
||||
|
||||
const targetGain = effective * masterVolume;
|
||||
|
||||
if (audioEngine) {
|
||||
// Web Audio engine owns playback: set the per-stem gain directly (no 1.0
|
||||
// cap, so >1.0 lane boost works), and skip the streaming volume path —
|
||||
// the multitrack is mounted for visuals only and never plays.
|
||||
audioEngine.setGain(name, targetGain);
|
||||
continue;
|
||||
}
|
||||
|
||||
const audioEl = multitrack.audios?.[idx];
|
||||
if (audioEl instanceof HTMLMediaElement) {
|
||||
// WKWebView does not pass audio through MediaElementSource → GainNode →
|
||||
// destination. Use the native HTMLAudioElement volume path instead and
|
||||
// cap at 1.0 (the spec limit). Boost above unity is not supported on
|
||||
// this platform but basic volume/mute works reliably.
|
||||
audioEl.volume = Math.max(0, Math.min(1, targetGain));
|
||||
} else {
|
||||
multitrack.setTrackVolume(idx, targetGain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateLaneKnobVisual(knobEl, v) {
|
||||
const frac = Math.max(0, Math.min(1, v / LANE_VOLUME_MAX));
|
||||
knobEl.style.setProperty("--lane-pos", frac.toFixed(3));
|
||||
knobEl.setAttribute("aria-valuenow", v.toFixed(2));
|
||||
const input = knobEl.querySelector(".mx-fader-input");
|
||||
if (input) {
|
||||
input.value = String(v);
|
||||
// Set --lane-pos directly on the input so ::webkit-slider-runnable-track
|
||||
// can see it — WebKit shadow DOM pseudo-elements don't inherit vars from ancestors.
|
||||
input.style.setProperty("--lane-pos", frac.toFixed(3));
|
||||
}
|
||||
const val = knobEl.closest(".lane-header")?.querySelector(".mx-val");
|
||||
if (val) {
|
||||
const db = v <= 0 ? "-∞" : (20 * Math.log10(v)).toFixed(1);
|
||||
val.textContent = db === "-∞" ? "-∞" : `${parseFloat(db) > 0 ? "+" : ""}${db}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLaneVolume(name, v) {
|
||||
const state = mixerState[name];
|
||||
if (!state) return;
|
||||
state.volume = Math.max(0, Math.min(LANE_VOLUME_MAX, v));
|
||||
const knob = mixerEl.querySelector(`.lane-knob[data-stem="${name}"]`);
|
||||
if (knob) updateLaneKnobVisual(knob, state.volume);
|
||||
applyMix();
|
||||
saveMix();
|
||||
}
|
||||
|
||||
export function refreshMixerVisuals() {
|
||||
for (const name of TRACK_NAMES) {
|
||||
const state = mixerState[name];
|
||||
if (!state) continue;
|
||||
// Mixer-column lane header
|
||||
const row = mixerEl.querySelector(`.lane-header[data-stem="${name}"]`);
|
||||
if (row) {
|
||||
const muteBtn = row.querySelector(".mute");
|
||||
const soloBtn = row.querySelector(".solo");
|
||||
if (soloBtn) soloBtn.classList.toggle("active", state.soloed);
|
||||
const iconToggle = row.querySelector(".lane-icon-toggle");
|
||||
if (iconToggle) {
|
||||
iconToggle.classList.toggle("active", !state.muted);
|
||||
iconToggle.setAttribute("aria-pressed", String(!state.muted));
|
||||
}
|
||||
row.classList.toggle("muted", state.muted);
|
||||
const knob = row.querySelector(".lane-knob");
|
||||
if (knob) updateLaneKnobVisual(knob, state.volume);
|
||||
}
|
||||
// Stems-list panel row (mirrors the mixer column buttons)
|
||||
if (stemListEl) {
|
||||
const slRow = stemListEl.querySelector(`span[data-stem="${name}"]`);
|
||||
if (slRow) {
|
||||
const m = slRow.querySelector(".stem-mute");
|
||||
const s = slRow.querySelector(".stem-solo");
|
||||
const mon = slRow.querySelector(".stem-monitor");
|
||||
if (m) {
|
||||
m.classList.toggle("active", state.muted);
|
||||
m.setAttribute("aria-pressed", String(state.muted));
|
||||
}
|
||||
if (s) {
|
||||
s.classList.toggle("active", state.soloed);
|
||||
s.setAttribute("aria-pressed", String(state.soloed));
|
||||
}
|
||||
if (mon) {
|
||||
// Active when this stem is THE lone solo (the "monitor" target).
|
||||
const others = TRACK_NAMES.filter((n) => n !== name);
|
||||
const lone = state.soloed
|
||||
&& others.every((n) => !mixerState[n]?.soloed);
|
||||
mon.classList.toggle("active", lone);
|
||||
}
|
||||
slRow.classList.toggle("muted", state.muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setLaneControlsEnabled(enabled) {
|
||||
for (const b of mixerEl.querySelectorAll(".ms-btn")) b.disabled = !enabled;
|
||||
for (const b of mixerEl.querySelectorAll(".lane-icon-toggle")) b.disabled = !enabled;
|
||||
for (const a of mixerEl.querySelectorAll(".lane-dl")) {
|
||||
a.classList.toggle("disabled", !enabled);
|
||||
if (!enabled) {
|
||||
a.setAttribute("aria-disabled", "true");
|
||||
a.setAttribute("tabindex", "-1");
|
||||
} else {
|
||||
a.removeAttribute("aria-disabled");
|
||||
a.removeAttribute("tabindex");
|
||||
}
|
||||
}
|
||||
for (const k of mixerEl.querySelectorAll(".lane-knob")) {
|
||||
k.classList.toggle("disabled", !enabled);
|
||||
k.setAttribute("aria-disabled", String(!enabled));
|
||||
k.setAttribute("tabindex", enabled ? "0" : "-1");
|
||||
}
|
||||
}
|
||||
|
||||
const MINI_WAVE_BARS = 40;
|
||||
const MINI_WAVE_VIEWBOX_H = 26;
|
||||
|
||||
function emptyMiniWaveSvg(stemName) {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", "lane-mini-wave");
|
||||
svg.dataset.stem = stemName;
|
||||
svg.setAttribute("preserveAspectRatio", "none");
|
||||
svg.setAttribute("viewBox", `0 0 ${MINI_WAVE_BARS * 2} ${MINI_WAVE_VIEWBOX_H}`);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function makeMiniWaveSvg(stemName, color) {
|
||||
// Seeded placeholder bars used while real peaks haven't loaded yet.
|
||||
let s = 0;
|
||||
for (const c of stemName) s = (s * 31 + c.charCodeAt(0)) >>> 0;
|
||||
const rng = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
|
||||
const svg = emptyMiniWaveSvg(stemName);
|
||||
for (let i = 0; i < MINI_WAVE_BARS; i++) {
|
||||
const env = Math.sin((i / MINI_WAVE_BARS) * Math.PI) * 0.7 + 0.3;
|
||||
const h = Math.max(2, env * (rng() * 0.6 + 0.25) * MINI_WAVE_VIEWBOX_H);
|
||||
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
rect.setAttribute("x", `${i * 2}`);
|
||||
rect.setAttribute("y", `${(MINI_WAVE_VIEWBOX_H - h) / 2}`);
|
||||
rect.setAttribute("width", "1");
|
||||
rect.setAttribute("height", `${h}`);
|
||||
rect.setAttribute("fill", color);
|
||||
rect.setAttribute("opacity", "0.6");
|
||||
svg.appendChild(rect);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function renderRealMiniWave(stemName, audioBuffer, color) {
|
||||
const svg = mixerEl.querySelector(`.lane-mini-wave[data-stem="${stemName}"]`);
|
||||
if (!svg || !audioBuffer || typeof audioBuffer.getChannelData !== "function") return;
|
||||
const ch = audioBuffer.getChannelData(0);
|
||||
if (!ch || !ch.length) return;
|
||||
const binSize = Math.max(1, Math.floor(ch.length / MINI_WAVE_BARS));
|
||||
const peaks = new Array(MINI_WAVE_BARS);
|
||||
let max = 0;
|
||||
for (let i = 0; i < MINI_WAVE_BARS; i++) {
|
||||
const start = i * binSize;
|
||||
const end = i === MINI_WAVE_BARS - 1 ? ch.length : start + binSize;
|
||||
let p = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
const v = Math.abs(ch[j]);
|
||||
if (v > p) p = v;
|
||||
}
|
||||
peaks[i] = p;
|
||||
if (p > max) max = p;
|
||||
}
|
||||
const norm = max > 0 ? 1 / max : 0;
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
for (let i = 0; i < MINI_WAVE_BARS; i++) {
|
||||
const h = Math.max(1.5, peaks[i] * norm * MINI_WAVE_VIEWBOX_H);
|
||||
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
rect.setAttribute("x", `${i * 2}`);
|
||||
rect.setAttribute("y", `${(MINI_WAVE_VIEWBOX_H - h) / 2}`);
|
||||
rect.setAttribute("width", "1");
|
||||
rect.setAttribute("height", `${h}`);
|
||||
rect.setAttribute("fill", color);
|
||||
rect.setAttribute("opacity", "0.95");
|
||||
svg.appendChild(rect);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadIcon() {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("width", "18");
|
||||
svg.setAttribute("height", "18");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "2");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.innerHTML =
|
||||
'<path d="M12 3v11"></path>' +
|
||||
'<path d="m7.5 9.5 4.5 4.5 4.5-4.5"></path>' +
|
||||
'<rect x="5" y="17" width="14" height="4" rx="1.5"></rect>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
function makeVolumeKnob(stemName, color) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "lane-knob mx-fader";
|
||||
wrap.dataset.stem = stemName;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "range";
|
||||
input.className = "mx-fader-input";
|
||||
input.setAttribute("min", "0");
|
||||
input.setAttribute("max", String(LANE_VOLUME_MAX));
|
||||
input.setAttribute("step", "0.01");
|
||||
input.setAttribute("value", "1");
|
||||
input.style.setProperty("--fader-color", color);
|
||||
input.style.setProperty("--lane-pos", "0.5");
|
||||
input.setAttribute("aria-label", `${STEM_DISPLAY[stemName] || stemName} volume`);
|
||||
input.addEventListener("input", () => setLaneVolume(stemName, parseFloat(input.value)));
|
||||
input.addEventListener("dblclick", (e) => { e.stopPropagation(); setLaneVolume(stemName, 1); });
|
||||
wrap.appendChild(input);
|
||||
|
||||
wrap.addEventListener("dblclick", () => setLaneVolume(stemName, 1));
|
||||
wrap.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const cur = mixerState[stemName]?.volume ?? 1;
|
||||
setLaneVolume(stemName, cur - Math.sign(e.deltaY) * (e.shiftKey ? 0.2 : 0.04));
|
||||
}, { passive: false });
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function stemIconMarkup(stemName) {
|
||||
const common = 'class="lane-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"';
|
||||
const icons = {
|
||||
vocals: `<svg ${common}><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><path d="M12 19v3"></path></svg>`,
|
||||
drums: `<svg ${common}><path d="M7 13.5a5 5 0 0 0 10 0"></path><path d="M7 13.5h10"></path><circle cx="9" cy="10" r="2.5"></circle><circle cx="15" cy="10" r="2.5"></circle><path d="M4 6.5h5"></path><path d="M15 6.5h5"></path><path d="M6.5 6.5v5"></path><path d="M17.5 6.5v5"></path><path d="M10 18l-2 3"></path><path d="M14 18l2 3"></path><path d="M4 18l16-8"></path></svg>`,
|
||||
bass: `<svg ${common}><path d="M16.5 3h4v5h-3"></path><path d="M17.5 5.5 9.8 13.2"></path><path d="M10 13c1.6 2.2 1.1 5.1-1.2 6.5-2.1 1.3-5 .5-6-1.6-.9-1.9-.1-4.1 1.8-5 .9-.4 1.8-.4 2.8-.1.1-1.1.6-2.1 1.6-2.6 1.2-.6 2.6-.1 3.2 1.1"></path><path d="M6.7 16.4h.01"></path><path d="M13.5 9.5l3 3"></path><path d="M18.2 3v4.6"></path><path d="M20.5 3v4"></path></svg>`,
|
||||
guitar: `<svg ${common}><path d="M16 4.5 20 2l2 2-2.5 4"></path><path d="M18.2 5.8 10.2 13.8"></path><path d="M10.5 13.5c1.1 1.7.5 4.2-1.5 5.5-2.2 1.5-5.3.8-6.3-1.3-.8-1.7.1-3.6 1.9-4.2 1-.3 1.8-.1 2.7.5.1-1.1.6-2.1 1.6-2.6 1.4-.7 2.7.2 1.6 2.1Z"></path><path d="M6.5 15.1c1.3.6 2.2 1.5 2.9 2.8"></path><circle cx="7" cy="16.4" r="1.4"></circle><path d="M14 8l3 3"></path></svg>`,
|
||||
piano: `<svg ${common}><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M7 5v14"></path><path d="M12 5v14"></path><path d="M17 5v14"></path><path d="M9.5 5v7"></path><path d="M14.5 5v7"></path></svg>`,
|
||||
other: `<svg ${common}><path d="M4 13v-2"></path><path d="M8 17V7"></path><path d="M12 21V3"></path><path d="M16 17V7"></path><path d="M20 13v-2"></path></svg>`,
|
||||
original: `<svg ${common}><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
|
||||
};
|
||||
return icons[stemName] || icons.other;
|
||||
}
|
||||
|
||||
export function renderMixerRow(stem) {
|
||||
const state = mixerState[stem.name];
|
||||
const color = STEM_COLORS[stem.name] || "#a0a0a0";
|
||||
const display = STEM_DISPLAY[stem.name] || stem.name;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "lane-header mx-row";
|
||||
row.dataset.stem = stem.name;
|
||||
|
||||
// Col 1: stem icon
|
||||
const iconCell = document.createElement("div");
|
||||
iconCell.className = "mx-icon";
|
||||
iconCell.style.color = color;
|
||||
iconCell.innerHTML = stemIconMarkup(stem.name);
|
||||
|
||||
// Hidden lane-stripe kept for CSS compat
|
||||
const stripe = document.createElement("div");
|
||||
stripe.className = "lane-stripe";
|
||||
stripe.style.background = color;
|
||||
row.appendChild(stripe);
|
||||
|
||||
// Col 2: name
|
||||
const nameEl = document.createElement("span");
|
||||
nameEl.className = "mx-name";
|
||||
nameEl.style.color = color;
|
||||
nameEl.textContent = display;
|
||||
|
||||
// Col 3: horizontal fader
|
||||
const fader = makeVolumeKnob(stem.name, color);
|
||||
|
||||
// Col 4: VU meter
|
||||
const vu = document.createElement("div");
|
||||
vu.className = "lane-vu mx-meter";
|
||||
vu.dataset.stem = stem.name;
|
||||
vu.innerHTML = '<div class="lane-vu-bar mx-meter-fill"></div><div class="lane-vu-bar"></div>';
|
||||
|
||||
// Col 5: value label
|
||||
const val = document.createElement("span");
|
||||
val.className = "mx-val";
|
||||
const initFrac = Math.max(0, Math.min(1, (state?.volume ?? 1) / LANE_VOLUME_MAX));
|
||||
val.textContent = String(Math.round(initFrac * 100));
|
||||
|
||||
// Col 6: M button
|
||||
const muteBtn = document.createElement("button");
|
||||
muteBtn.type = "button";
|
||||
muteBtn.className = "lane-icon-toggle mx-btn mute";
|
||||
muteBtn.textContent = "M";
|
||||
muteBtn.setAttribute("aria-label", `Mute ${display}`);
|
||||
muteBtn.setAttribute("aria-pressed", String(state?.muted ?? false));
|
||||
if (!state?.muted) muteBtn.classList.add("active");
|
||||
|
||||
// Col 7: S button
|
||||
const soloBtn = document.createElement("button");
|
||||
soloBtn.type = "button";
|
||||
soloBtn.className = "solo ms-btn mx-btn";
|
||||
soloBtn.textContent = "S";
|
||||
soloBtn.setAttribute("aria-label", `Solo ${display}`);
|
||||
soloBtn.setAttribute("aria-pressed", String(state?.soloed ?? false));
|
||||
if (state?.soloed) soloBtn.classList.add("active");
|
||||
|
||||
// Col 8: download
|
||||
const dl = document.createElement("a");
|
||||
dl.className = "lane-dl mx-btn";
|
||||
dl.href = stem.url;
|
||||
dl.download = `${stem.name}.wav`;
|
||||
dl.title = `Download ${display}`;
|
||||
dl.appendChild(downloadIcon());
|
||||
|
||||
// Wrap name + VU in a column so VU appears below the name
|
||||
const nameVuCol = document.createElement("div");
|
||||
nameVuCol.className = "lane-name-vu";
|
||||
nameVuCol.append(nameEl, vu);
|
||||
|
||||
row.append(iconCell, nameVuCol, fader, val, muteBtn, soloBtn, dl);
|
||||
|
||||
muteBtn.addEventListener("click", () => toggleStemMute(stem.name));
|
||||
soloBtn.addEventListener("click", () => toggleStemSolo(stem.name));
|
||||
|
||||
row.classList.toggle("muted", state?.muted ?? false);
|
||||
if (state) updateLaneKnobVisual(fader, state.volume);
|
||||
|
||||
return { row, vuEl: vu };
|
||||
}
|
||||
|
||||
// ─── Stem-list panel (Stems sidebar) ───
|
||||
//
|
||||
// The stems-list panel renders a parallel set of M / S / Monitor controls
|
||||
// that share state with the mixer-column lane-header buttons. Both UIs
|
||||
// drive `mixerState`; either one updates the audio mix and both visuals
|
||||
// re-render via refreshMixerVisuals().
|
||||
|
||||
export function toggleStemMute(name) {
|
||||
const state = mixerState[name];
|
||||
if (!state) return;
|
||||
state.muted = !state.muted;
|
||||
refreshMixerVisuals();
|
||||
applyMix();
|
||||
saveMix();
|
||||
}
|
||||
|
||||
export function toggleStemSolo(name) {
|
||||
const state = mixerState[name];
|
||||
if (!state) return;
|
||||
state.soloed = !state.soloed;
|
||||
refreshMixerVisuals();
|
||||
applyMix();
|
||||
saveMix();
|
||||
}
|
||||
|
||||
// "Monitor" = solo only this stem. If already the lone solo, clear all
|
||||
// solos (toggle-style behavior, like Logic's "Solo Safe" / Reaper's
|
||||
// solo-exclusive). Also clears mute on the target so it's audible.
|
||||
export function soloOnlyStem(name) {
|
||||
const state = mixerState[name];
|
||||
if (!state) return;
|
||||
const others = TRACK_NAMES.filter((n) => n !== name);
|
||||
const isAlreadyAlone = state.soloed && others.every((n) => !mixerState[n]?.soloed);
|
||||
if (isAlreadyAlone) {
|
||||
state.soloed = false;
|
||||
} else {
|
||||
for (const n of TRACK_NAMES) {
|
||||
if (!mixerState[n]) continue;
|
||||
mixerState[n].soloed = (n === name);
|
||||
}
|
||||
state.muted = false;
|
||||
}
|
||||
refreshMixerVisuals();
|
||||
applyMix();
|
||||
saveMix();
|
||||
}
|
||||
|
||||
export function resetMixer() {
|
||||
for (const name of TRACK_NAMES) {
|
||||
const s = mixerState[name];
|
||||
if (!s) continue;
|
||||
s.volume = 1;
|
||||
s.muted = false;
|
||||
s.soloed = false;
|
||||
}
|
||||
refreshMixerVisuals();
|
||||
applyMix();
|
||||
saveMix();
|
||||
}
|
||||
|
||||
export function muteAll() {
|
||||
// Toggle: if every stem is muted, un-mute all; otherwise mute all.
|
||||
const allMuted = STEM_NAMES.every((n) => mixerState[n]?.muted);
|
||||
for (const name of TRACK_NAMES) {
|
||||
const s = mixerState[name];
|
||||
if (!s) continue;
|
||||
s.muted = !allMuted;
|
||||
}
|
||||
refreshMixerVisuals();
|
||||
applyMix();
|
||||
saveMix();
|
||||
}
|
||||
|
||||
export function clearAllSolos() {
|
||||
for (const name of TRACK_NAMES) {
|
||||
const s = mixerState[name];
|
||||
if (!s) continue;
|
||||
s.soloed = false;
|
||||
}
|
||||
refreshMixerVisuals();
|
||||
applyMix();
|
||||
saveMix();
|
||||
}
|
||||
|
||||
export function wireMixerToolbar() {
|
||||
document.getElementById("mixer-reset")?.addEventListener("click", resetMixer);
|
||||
document.getElementById("mixer-mute-all")?.addEventListener("click", muteAll);
|
||||
document.getElementById("mixer-clear-solo")?.addEventListener("click", clearAllSolos);
|
||||
}
|
||||
|
||||
export function wireStemListControls() {
|
||||
if (!stemListEl) return;
|
||||
for (const btn of stemListEl.querySelectorAll(".stem-mute")) {
|
||||
btn.addEventListener("click", () => toggleStemMute(btn.dataset.stem));
|
||||
btn.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space" || e.code === "Enter") {
|
||||
e.preventDefault();
|
||||
toggleStemMute(btn.dataset.stem);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const btn of stemListEl.querySelectorAll(".stem-solo")) {
|
||||
btn.addEventListener("click", () => toggleStemSolo(btn.dataset.stem));
|
||||
btn.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space" || e.code === "Enter") {
|
||||
e.preventDefault();
|
||||
toggleStemSolo(btn.dataset.stem);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const btn of stemListEl.querySelectorAll(".stem-monitor")) {
|
||||
btn.addEventListener("click", () => soloOnlyStem(btn.dataset.stem));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Playful labels rotated by job.js every ROTATION_MS while a job is in
|
||||
// progress. Keyed by Job.status values from the backend. The progress
|
||||
// bar carries the real percentage; this is purely UI personality.
|
||||
//
|
||||
// Edit freely — additions/removals don't require any code changes.
|
||||
|
||||
export const stagePhrases = {
|
||||
queued: [
|
||||
"Lacing up…",
|
||||
"Tuning forks…",
|
||||
"Spinning up…",
|
||||
"Warming the tubes…",
|
||||
],
|
||||
// Downloading is load-in: gear arriving, getting set up, before the
|
||||
// sound check starts.
|
||||
downloading: [
|
||||
"Load-in…",
|
||||
"Rolling in flight cases…",
|
||||
"Setting up the kit…",
|
||||
"Snaking the cables…",
|
||||
"Plugging into the patch bay…",
|
||||
],
|
||||
// Separating is the long stage — keep the user company with band /
|
||||
// sound-check vignettes. Short observational sentences, the kind of
|
||||
// thing you'd overhear at a rehearsal.
|
||||
separating: [
|
||||
"Tuning the bass…",
|
||||
"Guitarist checking himself in the mirror…",
|
||||
"Pick fell on the floor…",
|
||||
"In-ear check…",
|
||||
"Mic check, one two…",
|
||||
"\"More me in the monitor\"…",
|
||||
"Drummer adjusting the snare…",
|
||||
"Singer warming up… lalala",
|
||||
"Capo on, capo off…",
|
||||
"Tightening the lugs…",
|
||||
"Tuning the floor tom…",
|
||||
"Coiling a cable…",
|
||||
"Bassist plugged in backwards…",
|
||||
"Pedalboard wiggling…",
|
||||
"Tech swapping a 9V battery…",
|
||||
"Roadie taping down the setlist…",
|
||||
"\"Is this thing on?\"…",
|
||||
"Stepping on a fuzz pedal…",
|
||||
"Tuning the high E…",
|
||||
"Drummer twirling sticks…",
|
||||
"Singer sipping tea…",
|
||||
"Quick bathroom break…",
|
||||
"\"Can I get more vocals?\"…",
|
||||
"Snare too snappy…",
|
||||
"Levels look good…",
|
||||
],
|
||||
default: ["Working on it…"],
|
||||
};
|
||||
+1414
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
// sections.js — interactive sections bar above the waveform
|
||||
|
||||
const SECTION_COLORS = [
|
||||
"#4a7fff",
|
||||
"#2ab8e8",
|
||||
"#9a4aff",
|
||||
"#ff8a20",
|
||||
"#00c8a0",
|
||||
"#ff4a90",
|
||||
"#e8c840",
|
||||
"#00d4d4",
|
||||
];
|
||||
|
||||
const MIN_SEC = 0.5; // minimum section duration in seconds
|
||||
const DEFAULT_WIDTH_FRAC = 0.12; // default new section = 12% of track
|
||||
|
||||
let _trackId = null;
|
||||
let _duration = 0;
|
||||
let _sections = [];
|
||||
let _container = null;
|
||||
let _saveTimer = null;
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────
|
||||
|
||||
export function initSections(trackId, sections, duration) {
|
||||
_trackId = trackId;
|
||||
_duration = Math.max(1, duration || 0);
|
||||
_sections = (sections || []).map((s) => ({ ...s }));
|
||||
_container = document.getElementById("daw-sections");
|
||||
if (!_container) return;
|
||||
|
||||
// Wire the static "Add" button in the label area (may already be wired)
|
||||
const addBtn = document.getElementById("sectionsAddBtn");
|
||||
if (addBtn && !addBtn.dataset.sectionsWired) {
|
||||
addBtn.dataset.sectionsWired = "1";
|
||||
addBtn.addEventListener("click", () => _addSection());
|
||||
}
|
||||
|
||||
_render();
|
||||
}
|
||||
|
||||
export function destroySections() {
|
||||
// Flush any pending debounced save before clearing state so switching tracks
|
||||
// never drops unsaved sections. _save() serializes _sections synchronously
|
||||
// (JSON.stringify runs before the first await) so it's safe to clear state
|
||||
// after calling it.
|
||||
if (_saveTimer !== null) {
|
||||
clearTimeout(_saveTimer);
|
||||
_saveTimer = null;
|
||||
_save();
|
||||
}
|
||||
_hideSaveIndicator();
|
||||
_trackId = null;
|
||||
_sections = [];
|
||||
_duration = 0;
|
||||
if (_container) _container.innerHTML = "";
|
||||
_container = null;
|
||||
}
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────
|
||||
|
||||
function _render() {
|
||||
if (!_container) return;
|
||||
_container.innerHTML = "";
|
||||
|
||||
const sorted = [..._sections].sort((a, b) => a.start - b.start);
|
||||
|
||||
for (const section of sorted) {
|
||||
_container.appendChild(_makeSectionEl(section));
|
||||
}
|
||||
}
|
||||
|
||||
function _makeSectionEl(section) {
|
||||
const pctStart = (section.start / _duration) * 100;
|
||||
const pctWidth = ((section.end - section.start) / _duration) * 100;
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "section-block";
|
||||
el.dataset.id = section.id;
|
||||
el.style.cssText = `left:${pctStart.toFixed(4)}%;width:${pctWidth.toFixed(4)}%;--sc:${section.color}`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="section-handle section-handle-l" data-edge="left"></div>
|
||||
<span class="section-label">${_esc(section.name)}</span>
|
||||
<button class="section-del" type="button" aria-label="Delete section" tabindex="-1">×</button>
|
||||
<div class="section-handle section-handle-r" data-edge="right"></div>
|
||||
`;
|
||||
|
||||
el.querySelector(".section-del").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_deleteSection(section.id);
|
||||
});
|
||||
|
||||
el.querySelector(".section-label").addEventListener("dblclick", (e) => {
|
||||
e.stopPropagation();
|
||||
_openRename(section.id, el.querySelector(".section-label"));
|
||||
});
|
||||
|
||||
_wireDrag(el, section);
|
||||
for (const h of el.querySelectorAll(".section-handle")) {
|
||||
_wireResize(h, el, section);
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ─── Drag to move ─────────────────────────────────────────
|
||||
|
||||
function _wireDrag(el, section) {
|
||||
let active = false;
|
||||
let startX = 0;
|
||||
let origStart = 0;
|
||||
|
||||
el.addEventListener("pointerdown", (e) => {
|
||||
if (e.target.closest(".section-handle,.section-del")) return;
|
||||
active = true;
|
||||
startX = e.clientX;
|
||||
origStart = section.start;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
el.classList.add("sec-dragging");
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
el.addEventListener("pointermove", (e) => {
|
||||
if (!active) return;
|
||||
const cw = _container.getBoundingClientRect().width;
|
||||
if (!cw) return;
|
||||
const dt = ((e.clientX - startX) / cw) * _duration;
|
||||
const w = section.end - section.start;
|
||||
section.start = _clampMove(section.id, origStart + dt, w);
|
||||
section.end = section.start + w;
|
||||
el.style.left = `${(section.start / _duration) * 100}%`;
|
||||
});
|
||||
|
||||
el.addEventListener("pointerup", () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
el.classList.remove("sec-dragging");
|
||||
_scheduleSave();
|
||||
});
|
||||
|
||||
el.addEventListener("pointercancel", () => {
|
||||
active = false;
|
||||
el.classList.remove("sec-dragging");
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Resize handles ───────────────────────────────────────
|
||||
|
||||
function _wireResize(handle, el, section) {
|
||||
const edge = handle.dataset.edge;
|
||||
let active = false;
|
||||
let startX = 0;
|
||||
let origTime = 0;
|
||||
|
||||
handle.addEventListener("pointerdown", (e) => {
|
||||
active = true;
|
||||
startX = e.clientX;
|
||||
origTime = edge === "left" ? section.start : section.end;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
el.classList.add("sec-resizing");
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
handle.addEventListener("pointermove", (e) => {
|
||||
if (!active) return;
|
||||
const cw = _container.getBoundingClientRect().width;
|
||||
if (!cw) return;
|
||||
const dt = ((e.clientX - startX) / cw) * _duration;
|
||||
const desired = origTime + dt;
|
||||
|
||||
if (edge === "left") {
|
||||
const lbound = _leftNeighborEnd(section.id);
|
||||
const max = section.end - MIN_SEC;
|
||||
section.start = Math.max(lbound, Math.min(max, desired));
|
||||
} else {
|
||||
const rbound = _rightNeighborStart(section.id);
|
||||
const min = section.start + MIN_SEC;
|
||||
section.end = Math.min(rbound, Math.max(min, desired));
|
||||
}
|
||||
|
||||
const ps = (section.start / _duration) * 100;
|
||||
const pw = ((section.end - section.start) / _duration) * 100;
|
||||
el.style.left = `${ps}%`;
|
||||
el.style.width = `${pw}%`;
|
||||
});
|
||||
|
||||
handle.addEventListener("pointerup", () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
el.classList.remove("sec-resizing");
|
||||
_scheduleSave();
|
||||
});
|
||||
|
||||
handle.addEventListener("pointercancel", () => {
|
||||
active = false;
|
||||
el.classList.remove("sec-resizing");
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Collision helpers ────────────────────────────────────
|
||||
|
||||
function _clampMove(id, desiredStart, width) {
|
||||
let start = Math.max(0, Math.min(_duration - width, desiredStart));
|
||||
const end = () => start + width;
|
||||
const others = _sections.filter((s) => s.id !== id);
|
||||
|
||||
for (const o of others) {
|
||||
if (start < o.end && end() > o.start) {
|
||||
// Snap to whichever edge is closer to desired
|
||||
const snapRight = o.end;
|
||||
const snapLeft = o.start - width;
|
||||
const dr = Math.abs(desiredStart - snapRight);
|
||||
const dl = Math.abs(desiredStart - snapLeft);
|
||||
start = dl < dr ? Math.max(0, snapLeft) : Math.min(_duration - width, snapRight);
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
function _leftNeighborEnd(id) {
|
||||
const s = _sections.find((x) => x.id === id);
|
||||
let bound = 0;
|
||||
for (const o of _sections) {
|
||||
if (o.id === id) continue;
|
||||
if (o.end <= s.end) bound = Math.max(bound, o.end);
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
|
||||
function _rightNeighborStart(id) {
|
||||
const s = _sections.find((x) => x.id === id);
|
||||
let bound = _duration;
|
||||
for (const o of _sections) {
|
||||
if (o.id === id) continue;
|
||||
if (o.start >= s.start) bound = Math.min(bound, o.start);
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
|
||||
// ─── CRUD ─────────────────────────────────────────────────
|
||||
|
||||
function _addSection() {
|
||||
const defW = _duration * DEFAULT_WIDTH_FRAC;
|
||||
const sorted = [..._sections].sort((a, b) => a.start - b.start);
|
||||
|
||||
// Find first gap ≥ defW
|
||||
let start = 0;
|
||||
for (const s of sorted) {
|
||||
if (s.start - start >= defW) break;
|
||||
start = Math.max(start, s.end);
|
||||
}
|
||||
|
||||
// Clamp and verify room
|
||||
start = Math.min(start, _duration - MIN_SEC);
|
||||
if (start < 0) return;
|
||||
const end = Math.min(start + defW, _duration);
|
||||
if (end - start < MIN_SEC) return;
|
||||
|
||||
// Verify no overlap
|
||||
if (_sections.some((s) => start < s.end && end > s.start)) return;
|
||||
|
||||
const color = _nextColor();
|
||||
const section = { id: _nextId(), name: "Section", start, end, color };
|
||||
_sections.push(section);
|
||||
_render();
|
||||
_scheduleSave();
|
||||
|
||||
// Open rename immediately
|
||||
const el = _container?.querySelector(`[data-id="${section.id}"]`);
|
||||
if (el) _openRename(section.id, el.querySelector(".section-label"));
|
||||
}
|
||||
|
||||
function _deleteSection(id) {
|
||||
_sections = _sections.filter((s) => s.id !== id);
|
||||
_render();
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
function _openRename(id, labelEl) {
|
||||
if (!labelEl) return;
|
||||
const section = _sections.find((s) => s.id === id);
|
||||
if (!section) return;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "section-rename-input";
|
||||
input.type = "text";
|
||||
input.value = section.name;
|
||||
input.style.setProperty("--sc", section.color);
|
||||
labelEl.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const commit = () => {
|
||||
const n = input.value.trim();
|
||||
if (n) section.name = n;
|
||||
_render();
|
||||
_scheduleSave();
|
||||
};
|
||||
input.addEventListener("blur", commit, { once: true });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
|
||||
if (e.key === "Escape") { input.value = section.name; input.removeEventListener("blur", commit); input.blur(); }
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────
|
||||
|
||||
let _savedTimer = null;
|
||||
|
||||
function _showSaving() {
|
||||
const el = document.getElementById("sectionsSaveIndicator");
|
||||
if (!el) return;
|
||||
clearTimeout(_savedTimer);
|
||||
el.textContent = "Saving";
|
||||
el.className = "sections-save-indicator";
|
||||
}
|
||||
|
||||
function _showSaved() {
|
||||
const el = document.getElementById("sectionsSaveIndicator");
|
||||
if (!el) return;
|
||||
el.textContent = "Saved";
|
||||
el.className = "sections-save-indicator saved";
|
||||
_savedTimer = setTimeout(() => {
|
||||
el.className = "sections-save-indicator hidden";
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function _hideSaveIndicator() {
|
||||
const el = document.getElementById("sectionsSaveIndicator");
|
||||
if (el) el.className = "sections-save-indicator hidden";
|
||||
clearTimeout(_savedTimer);
|
||||
}
|
||||
|
||||
function _scheduleSave() {
|
||||
clearTimeout(_saveTimer);
|
||||
_showSaving();
|
||||
_saveTimer = setTimeout(_save, 600);
|
||||
}
|
||||
|
||||
async function _save() {
|
||||
if (!_trackId) return;
|
||||
const id = _trackId;
|
||||
const body = JSON.stringify({ sections: _sections });
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${id}/sections`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => String(res.status));
|
||||
console.warn("[sections] save failed:", res.status, detail);
|
||||
if (id === _trackId) _hideSaveIndicator();
|
||||
return;
|
||||
}
|
||||
if (id === _trackId) _showSaved();
|
||||
} catch (e) {
|
||||
console.warn("[sections] save failed:", e);
|
||||
if (id === _trackId) _hideSaveIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Utilities ────────────────────────────────────────────
|
||||
|
||||
function _nextColor() {
|
||||
const used = new Set(_sections.map((s) => s.color));
|
||||
return SECTION_COLORS.find((c) => !used.has(c)) ?? SECTION_COLORS[_sections.length % SECTION_COLORS.length];
|
||||
}
|
||||
|
||||
function _nextId() {
|
||||
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Shared, DOM-free job/library helpers used by both the mobile UI and
|
||||
// (incrementally) the desktop catalog. Anything in here must stay free of
|
||||
// document/window-element access so it can be imported from either entry
|
||||
// point. The API (`GET /api/jobs`) is the canonical data source.
|
||||
import { fmtTime } from "../utils.js";
|
||||
|
||||
// Mirror of catalog.js PROCESSING_STATUSES — keep in sync with the backend
|
||||
// job lifecycle. These map to the amber "working" dot.
|
||||
const PROCESSING_STATUSES = new Set(["queued", "downloading", "analyzing", "separating", "processing"]);
|
||||
|
||||
export async function fetchJobs() {
|
||||
const res = await fetch("/api/jobs", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`GET /api/jobs -> ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function deriveSource(sourceUrl) {
|
||||
if (!sourceUrl) return "Local file";
|
||||
if (sourceUrl.startsWith("local:")) return "Local file";
|
||||
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) return "YouTube";
|
||||
if (sourceUrl.includes("soundcloud.com")) return "SoundCloud";
|
||||
return "Web";
|
||||
}
|
||||
|
||||
// One of: "done" (green), "processing" (amber), "unavailable" (grey/red).
|
||||
export function statusKind(status) {
|
||||
if (PROCESSING_STATUSES.has(status)) return "processing";
|
||||
if (status === "done") return "done";
|
||||
return "unavailable"; // unavailable / error / failed / unknown
|
||||
}
|
||||
|
||||
export function coverInitial(title) {
|
||||
const t = (title || "").trim();
|
||||
return t ? t[0].toUpperCase() : "♪";
|
||||
}
|
||||
|
||||
// Deterministic cover gradient: the same track always gets the same color.
|
||||
// Palette matches the mobile design's aesthetic (design/mobile prototype).
|
||||
const GRADIENTS = [
|
||||
"linear-gradient(150deg,#7b46f0,#3f6ef0 55%,#1b245f)",
|
||||
"linear-gradient(140deg,#2bd4c4,#1a6d9e)",
|
||||
"linear-gradient(150deg,#c44ad0,#5a2b9e)",
|
||||
"linear-gradient(150deg,#f06a6a,#7a2b4e)",
|
||||
"linear-gradient(150deg,#4a9bf5,#2b3a7a)",
|
||||
"linear-gradient(150deg,#f5a516,#d2541f)",
|
||||
"linear-gradient(150deg,#3fcf6e,#1a6d4e)",
|
||||
];
|
||||
|
||||
export function coverGradient(seed) {
|
||||
const s = String(seed || "");
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return GRADIENTS[h % GRADIENTS.length];
|
||||
}
|
||||
|
||||
// Normalize an /api/jobs state object into the shape the mobile UI renders.
|
||||
export function jobToCard(state) {
|
||||
const id = state.job_id;
|
||||
const title = state.title || "Untitled";
|
||||
const stemCount = (state.stems || state.selected_stems || []).length;
|
||||
const parts = [];
|
||||
if (state.duration) parts.push(fmtTime(state.duration));
|
||||
if (stemCount) parts.push(`${stemCount} stem${stemCount === 1 ? "" : "s"}`);
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
sub: deriveSource(state.source_url),
|
||||
meta: parts.join(" · "),
|
||||
stemCount,
|
||||
status: statusKind(state.status),
|
||||
createdAt: state.created_at || 0,
|
||||
initial: coverInitial(title),
|
||||
gradient: coverGradient(id || title),
|
||||
thumb: typeof state.thumbnail === "string" ? state.thumbnail : "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { $, storeGet, storeSet } from "./utils.js";
|
||||
import { STEM_NAMES } from "./constants.js";
|
||||
|
||||
// ─── DOM refs ───
|
||||
|
||||
export const form = $("job-form");
|
||||
export const urlInput = $("url");
|
||||
export const submitBtn = $("submit");
|
||||
|
||||
export const playBtn = $("t-play");
|
||||
export const playMiniBtn = $("t-play-mini");
|
||||
export const stopBtn = $("t-stop");
|
||||
export const loopBtn = $("t-loop");
|
||||
export const titleEl = $("title");
|
||||
export const bpmChip = $("t-bpm");
|
||||
export const keyChip = $("t-key");
|
||||
export const stemsChip = $("t-stems-chip");
|
||||
export const timeEl = $("t-time");
|
||||
export const masterFader = $("t-master");
|
||||
export const speedEl = $("t-speed");
|
||||
export const speedLabelEl = $("t-speed-label");
|
||||
export const npArt = $("np-art");
|
||||
export const npThumb = $("np-thumb");
|
||||
|
||||
export const jobBox = $("job");
|
||||
export const jobTitleEl = $("job-title");
|
||||
export const jobStageEl = $("job-stage");
|
||||
export const jobDetailEl = $("job-detail");
|
||||
export const jobCancelBtn = $("job-cancel");
|
||||
export const progressEl = $("progress");
|
||||
|
||||
export const errorEl = $("error");
|
||||
export const lanesEl = $("lanes");
|
||||
export const mixerEl = $("mixer");
|
||||
export const multitrackContainer = $("multitrack-container");
|
||||
export const wavesGrid = $("waves-grid");
|
||||
export const rulerTime = $("ruler-time");
|
||||
export const loopRegionEl = $("loop-region");
|
||||
export const playheadMarker = document.querySelector(".playhead-marker");
|
||||
export const waveScroll = $("wave-scroll");
|
||||
export const waveCanvas = $("wave-canvas");
|
||||
export const presenceRulerEl = $("presence-ruler");
|
||||
export const presencePlayheadEl = $("presence-playhead");
|
||||
export const footerTimeElapsed = $("footer-time-elapsed");
|
||||
export const footerTimeTotal = $("footer-time-total");
|
||||
export const loopStartInput = $("t-loop-start");
|
||||
export const loopEndInput = $("t-loop-end");
|
||||
export const stemListEl = document.querySelector(".stem-list");
|
||||
export const npScrubEl = document.querySelector(".np-scrub");
|
||||
export const npScrubFill = $("footer-scrub-fill");
|
||||
export const footerTitle = $("footer-title");
|
||||
export const footerMeta = $("footer-meta");
|
||||
export const footerThumb = $("footer-thumb");
|
||||
|
||||
// ─── Mutable state ───
|
||||
|
||||
export let eventSource = null;
|
||||
export let multitrack = null;
|
||||
// Web Audio decode-and-mix engine (Safari-safe playback). Null = legacy streaming path.
|
||||
export let audioEngine = null;
|
||||
export let currentJobId = null;
|
||||
|
||||
// `mixerState` is mutated in place (never reassigned). renderMixerRow's
|
||||
// closures capture each entry by reference, so on a new job we merge
|
||||
// localStorage values into the existing objects rather than replacing them.
|
||||
export const mixerState = {};
|
||||
|
||||
export let trackIndex = {};
|
||||
export let totalDuration = 0;
|
||||
export let loopEnabled = false;
|
||||
export let loopStart = 0;
|
||||
export let loopEnd = 0;
|
||||
|
||||
// Selected stems for extraction. The set determines (a) which stem
|
||||
// rows render in the studio dashboard after a job completes and (b)
|
||||
// which stem audio gets loaded into the multitrack. Backend always
|
||||
// runs Demucs on all 6 stems regardless -- filtering happens entirely
|
||||
// client-side at render time. Persisted across reloads in localStorage
|
||||
// so a user who turns off "Vocals" stays set up that way for the
|
||||
// next song.
|
||||
const _STEM_SEL_KEY = "stemdeck:selected-stems";
|
||||
|
||||
// Start with all stems selected (safe default). The async store load below
|
||||
// updates this binding once the store is available; ES module live bindings
|
||||
// ensure all importers see the updated value on next read.
|
||||
export let selectedStems = new Set(STEM_NAMES);
|
||||
|
||||
// Resolves when the persisted stem selection has been loaded from the store.
|
||||
// Consumers that need the exact stored selection (e.g. the stem-choice UI)
|
||||
// should await this before reading selectedStems.
|
||||
export const stemSelectionReady = (async () => {
|
||||
try {
|
||||
const arr = await storeGet(_STEM_SEL_KEY, null);
|
||||
if (Array.isArray(arr) && arr.length > 0) {
|
||||
const valid = arr.filter((n) => STEM_NAMES.includes(n));
|
||||
if (valid.length > 0) {
|
||||
selectedStems = new Set(valid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn("[state] failed to load stem selection:", e); }
|
||||
// Keep the all-stems default.
|
||||
})();
|
||||
|
||||
export function saveSelectedStems() {
|
||||
storeSet(_STEM_SEL_KEY, [...selectedStems]).catch((e) =>
|
||||
console.warn("[state] failed to save stem selection:", e)
|
||||
);
|
||||
}
|
||||
export function setStemSelected(name, selected) {
|
||||
if (selected) selectedStems.add(name);
|
||||
else selectedStems.delete(name);
|
||||
saveSelectedStems();
|
||||
}
|
||||
|
||||
// Web Audio analysers for live VU meters.
|
||||
export let audioContext = null;
|
||||
export let masterVolume = 0.5; // mirrored from masterFader.value
|
||||
export const trackAnalysers = []; // index → { analyser, data, vuEl }
|
||||
export let vuRafId = null;
|
||||
|
||||
// Master bus nodes — created once in audio.js, shared across mixer.js.
|
||||
// masterBusGain is driven by the master fader; masterLimiter is a
|
||||
// transparent brickwall limiter that prevents inter-stem summing clipping.
|
||||
export let masterBusGain = null;
|
||||
export let masterLimiter = null;
|
||||
|
||||
// ─── Setter helpers for mutable state (so other modules can update) ───
|
||||
|
||||
export function setEventSource(v) { eventSource = v; }
|
||||
export function setMultitrack(v) { multitrack = v; }
|
||||
export function setAudioEngine(v) { audioEngine = v; }
|
||||
export function setCurrentJobId(v) { currentJobId = v; }
|
||||
export function setTrackIndex(v) { trackIndex = v; }
|
||||
export function setTotalDuration(v) { totalDuration = v; }
|
||||
export function setLoopEnabled(v) { loopEnabled = v; }
|
||||
export function setLoopStart(v) { loopStart = v; }
|
||||
export function setLoopEnd(v) { loopEnd = v; }
|
||||
export function setAudioContext(v) { audioContext = v; }
|
||||
export function setMasterVolume(v) { masterVolume = v; }
|
||||
export let playbackSpeed = 1.0;
|
||||
export function setPlaybackSpeed(v) { playbackSpeed = v; }
|
||||
export function setVuRafId(v) { vuRafId = v; }
|
||||
export function setMasterBusGain(v) { masterBusGain = v; }
|
||||
export function setMasterLimiter(v) { masterLimiter = v; }
|
||||
|
||||
// Footer waveform draw callback — set by player.js, called by transport.js
|
||||
export let footerWaveDrawFn = null;
|
||||
export function setFooterWaveDrawFn(fn) { footerWaveDrawFn = fn; }
|
||||
@@ -0,0 +1,536 @@
|
||||
import { fmtTime, fmtTickLabel, fmtTimeMs, parseTimecode } from "./utils.js";
|
||||
import {
|
||||
playBtn, playMiniBtn, stopBtn, loopBtn, timeEl, masterFader,
|
||||
speedEl, speedLabelEl,
|
||||
rulerTime, wavesGrid, loopRegionEl, playheadMarker,
|
||||
multitrack, audioEngine, totalDuration, loopEnabled, loopStart, loopEnd, masterVolume,
|
||||
waveScroll, waveCanvas, multitrackContainer,
|
||||
presenceRulerEl, presencePlayheadEl,
|
||||
footerTimeElapsed, footerTimeTotal, npScrubFill, footerWaveDrawFn,
|
||||
loopStartInput, loopEndInput,
|
||||
setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed,
|
||||
} from "./state.js";
|
||||
import { applyMix } from "./mixer.js";
|
||||
|
||||
const MIN_LOOP_SEC = 0.2;
|
||||
// Below this visible width the waveform stops compressing to fit and instead
|
||||
// keeps a minimum size, overflowing horizontally so .wave-scroll can scroll.
|
||||
const WAVE_MIN_WIDTH = 720;
|
||||
// rulerTime is the canonical timeline reference for both click->time
|
||||
// and time->pixel mapping. The wave-editor lays the ruler and the
|
||||
// waveform body out so they should be horizontally aligned (both gutter
|
||||
// 48 px on the left in studio mode), but using one element for both
|
||||
// halves of the round-trip eliminates any subtle CSS drift -- clicking
|
||||
// "1:00" on the ruler always lands a marker exactly under that tick,
|
||||
// regardless of how the waves layer below happens to size itself.
|
||||
function rulerRect() {
|
||||
return rulerTime?.getBoundingClientRect() || { left: 0, width: 1 };
|
||||
}
|
||||
|
||||
function loopOverlayParent() {
|
||||
return document.querySelector(".waves-column") || rulerTime;
|
||||
}
|
||||
|
||||
function ensureLoopRegionParent() {
|
||||
const parent = loopOverlayParent();
|
||||
if (parent && loopRegionEl.parentElement !== parent) {
|
||||
parent.appendChild(loopRegionEl);
|
||||
}
|
||||
}
|
||||
|
||||
function timeFromClientX(clientX) {
|
||||
if (!totalDuration) return null;
|
||||
const rect = rulerRect();
|
||||
const x = clientX - rect.left;
|
||||
const frac = Math.max(0, Math.min(1, x / Math.max(1, rect.width)));
|
||||
return frac * totalDuration;
|
||||
}
|
||||
|
||||
function setPlayheadTime(sec) {
|
||||
const tx = audioEngine ?? multitrack;
|
||||
if (!tx || !totalDuration) return;
|
||||
const next = Math.max(0, Math.min(totalDuration, sec));
|
||||
tx.setTime(next);
|
||||
updatePlayheadMarker(next);
|
||||
updateFooterTimes(next);
|
||||
updatePresencePlayhead(next);
|
||||
}
|
||||
|
||||
export function buildRuler(durationSec) {
|
||||
rulerTime.innerHTML = "";
|
||||
wavesGrid.innerHTML = "";
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "playhead-marker";
|
||||
marker.setAttribute("aria-hidden", "true");
|
||||
marker.innerHTML =
|
||||
'<svg viewBox="0 0 10 10" width="10" height="10"><polygon points="0,0 10,0 5,8" fill="#e54e4e"></polygon></svg>';
|
||||
rulerTime.appendChild(marker);
|
||||
|
||||
if (!durationSec || durationSec <= 0) return;
|
||||
const step = durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60;
|
||||
for (let t = 0; t <= durationSec; t += step) {
|
||||
const leftPct = (t / durationSec) * 100;
|
||||
const tick = document.createElement("div");
|
||||
tick.className = "tick";
|
||||
tick.style.left = `${leftPct}%`;
|
||||
tick.innerHTML = `<span class="tick-label">${fmtTickLabel(t)}</span>`;
|
||||
rulerTime.appendChild(tick);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "grid-line";
|
||||
grid.style.left = `${leftPct}%`;
|
||||
wavesGrid.appendChild(grid);
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePlayheadMarker(currentSec) {
|
||||
if (!playheadMarker || !totalDuration) return;
|
||||
const m = rulerTime.querySelector(".playhead-marker");
|
||||
if (m) {
|
||||
// Position relative to the ruler itself (the marker is a ruler
|
||||
// child) so the playhead always sits exactly under the tick at
|
||||
// the matching time. Use percent instead of px: app-level CSS
|
||||
// zoom scales getBoundingClientRect() values, while left/width
|
||||
// styles are interpreted in unzoomed layout pixels.
|
||||
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
|
||||
m.style.left = `${pct}%`;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the elapsed/total time into the transport-footer's two side
|
||||
// labels (which used to show hardcoded "00:00.000" / "03:38.000") and
|
||||
// drive the small scrub bar in the now-playing card. Driven from the
|
||||
// same wavesurfer "timeupdate" event that already updates #t-time, so
|
||||
// every label stays in sync without extra event plumbing.
|
||||
export function updateFooterTimes(currentSec) {
|
||||
if (!totalDuration) return;
|
||||
if (footerTimeElapsed) footerTimeElapsed.textContent = fmtTime(currentSec);
|
||||
if (footerTimeTotal) footerTimeTotal.textContent = fmtTime(totalDuration);
|
||||
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
|
||||
if (npScrubFill) npScrubFill.style.width = `${pct}%`;
|
||||
footerWaveDrawFn?.(pct / 100);
|
||||
}
|
||||
|
||||
// Build the presence-panel ruler labels from the actual track duration.
|
||||
// The HTML ships 8 placeholder <b> tags ("0:00 ... 3:38"); we replace
|
||||
// each label's text with a tick at evenly-spaced fractions of the song.
|
||||
export function buildPresenceRuler(durationSec) {
|
||||
if (!presenceRulerEl) return;
|
||||
const ticks = presenceRulerEl.querySelectorAll("b");
|
||||
if (!ticks.length) return;
|
||||
if (!durationSec || durationSec <= 0) {
|
||||
for (const t of ticks) t.textContent = "0:00";
|
||||
return;
|
||||
}
|
||||
// 8 ticks -- evenly distribute from 0 to duration.
|
||||
const n = ticks.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const frac = i / (n - 1);
|
||||
ticks[i].textContent = fmtTickLabel(frac * durationSec);
|
||||
}
|
||||
}
|
||||
|
||||
// Move the gold playhead line that overlays the presence-bars panel.
|
||||
// Uses left% within the .presence-bars container, which spans the full
|
||||
// duration -- matches the ruler ticks above it.
|
||||
export function updatePresencePlayhead(currentSec) {
|
||||
if (!presencePlayheadEl) return;
|
||||
if (!totalDuration || totalDuration <= 0) {
|
||||
presencePlayheadEl.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
const pct = Math.max(0, Math.min(100, (currentSec / totalDuration) * 100));
|
||||
presencePlayheadEl.style.left = `${pct}%`;
|
||||
presencePlayheadEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
export function updateLoopRegionVisual() {
|
||||
const regionItem = document.getElementById("t-export-region");
|
||||
const hasRegion = loopEnabled && totalDuration > 0 && loopEnd > loopStart;
|
||||
if (regionItem) regionItem.setAttribute("aria-disabled", String(!hasRegion));
|
||||
// Keep the engine's loop bounds in sync with every loop change (toggle/drag);
|
||||
// the engine wraps playback itself off these values. No-op on streaming path.
|
||||
audioEngine?.setLoop(loopEnabled, loopStart, loopEnd);
|
||||
// Mirror the bounds into the exact-loop text fields (skips fields being edited).
|
||||
syncLoopInputs();
|
||||
if (!loopEnabled || !totalDuration) {
|
||||
loopRegionEl.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
ensureLoopRegionParent();
|
||||
// Keep the loop overlay in the same normalized timeline coordinate
|
||||
// system as the ruler ticks. Percentages avoid CSS zoom mismatch:
|
||||
// pointer coordinates and getBoundingClientRect() are visual pixels,
|
||||
// but style.left/style.width in px are unzoomed layout pixels.
|
||||
const startPct = Math.max(0, Math.min(100, (loopStart / totalDuration) * 100));
|
||||
const endPct = Math.max(0, Math.min(100, (loopEnd / totalDuration) * 100));
|
||||
loopRegionEl.style.left = `${startPct}%`;
|
||||
loopRegionEl.style.width = `${Math.max(0, endPct - startPct)}%`;
|
||||
loopRegionEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Keep the exact-loop text fields in sync with loopStart/loopEnd after any
|
||||
// programmatic change (drag, toggle). Never overwrite a field the user is
|
||||
// actively editing, and disable both when no track is loaded.
|
||||
function syncLoopInputs() {
|
||||
const enabled = totalDuration > 0;
|
||||
for (const [input, value] of [
|
||||
[loopStartInput, loopStart],
|
||||
[loopEndInput, loopEnd],
|
||||
]) {
|
||||
if (!input) continue;
|
||||
input.disabled = !enabled;
|
||||
if (document.activeElement !== input) input.value = fmtTimeMs(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit a typed loop time. Invalid/out-of-range input reverts the field to the
|
||||
// current stored value (self-evident rejection) rather than raising an error;
|
||||
// showError lives in the import form and would surface in the wrong place.
|
||||
function commitLoopInput(which) {
|
||||
const input = which === "start" ? loopStartInput : loopEndInput;
|
||||
if (!input) return;
|
||||
const revert = () => {
|
||||
input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd);
|
||||
};
|
||||
const parsed = parseTimecode(input.value);
|
||||
if (parsed === null || totalDuration <= 0) {
|
||||
revert();
|
||||
return;
|
||||
}
|
||||
const v = Math.max(0, Math.min(totalDuration, parsed));
|
||||
const start = which === "start" ? v : loopStart;
|
||||
const end = which === "end" ? v : loopEnd;
|
||||
if (end - start < MIN_LOOP_SEC) {
|
||||
revert();
|
||||
return;
|
||||
}
|
||||
setLoopStart(start);
|
||||
setLoopEnd(end);
|
||||
setLoopEnabled(true);
|
||||
loopBtn.classList.add("active");
|
||||
updateLoopRegionVisual();
|
||||
}
|
||||
|
||||
function wireLoopInputs() {
|
||||
for (const [input, which] of [
|
||||
[loopStartInput, "start"],
|
||||
[loopEndInput, "end"],
|
||||
]) {
|
||||
if (!input) continue;
|
||||
input.addEventListener("blur", () => commitLoopInput(which));
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
input.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd);
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
syncLoopInputs();
|
||||
}
|
||||
|
||||
// Standard DAW transport state machine:
|
||||
// [stopped] (paused at start) ─Play→ [playing]
|
||||
// ↑ ↓ Play
|
||||
// Stop [paused] (paused mid-track)
|
||||
// Stop ↓
|
||||
// [stopped]
|
||||
//
|
||||
// Play button is a Play/Pause toggle. Stop both pauses and returns the
|
||||
// playhead to 0 (or loopStart if loop is on). Visual state is driven
|
||||
// from the multitrack lifecycle events in player.js (mt.on play/pause/
|
||||
// timeupdate) — click handlers only mutate the transport, never the
|
||||
// button's CSS class. That way manual seeks (e.g. clicking the ruler)
|
||||
// keep the button states in sync without extra plumbing.
|
||||
// WKWebView (Tauri desktop) has small audio buffers. After a seek, all
|
||||
// audio elements drop their buffers and issue new range requests simultaneously.
|
||||
// Calling play() before they reach HAVE_FUTURE_DATA (readyState >= 3) causes
|
||||
// choppiness. Wait for all elements to be ready, with a hard 1.5 s fallback so
|
||||
// the user is never stuck. Desktop browsers buffer aggressively enough that
|
||||
// this wait is skipped entirely (readyState is already >= 3 by the time play
|
||||
// is pressed after a seek).
|
||||
function _playWhenReady() {
|
||||
if (!multitrack) return;
|
||||
const inTauri = Boolean(window.__TAURI__?.core?.invoke);
|
||||
if (!inTauri) { multitrack.play(); return; }
|
||||
|
||||
const audios = (multitrack.audios ?? [])
|
||||
.filter((a) => a instanceof HTMLMediaElement && a.src);
|
||||
const notReady = audios.filter((a) => a.readyState < 3);
|
||||
if (!notReady.length) { multitrack.play(); return; }
|
||||
|
||||
let fired = false;
|
||||
const fire = () => { if (!fired && multitrack && !multitrack.isPlaying()) { fired = true; multitrack.play(); } };
|
||||
const waits = notReady.map((a) => new Promise((res) => {
|
||||
if (a.readyState >= 3) { res(); return; }
|
||||
const onReady = () => { a.removeEventListener("canplay", onReady); res(); };
|
||||
a.addEventListener("canplay", onReady);
|
||||
}));
|
||||
Promise.all(waits).then(fire);
|
||||
window.setTimeout(fire, 1500);
|
||||
}
|
||||
|
||||
export function togglePlayPause() {
|
||||
const eng = audioEngine;
|
||||
const tx = eng ?? multitrack;
|
||||
if (!tx) return;
|
||||
if (tx.isPlaying()) {
|
||||
tx.pause();
|
||||
// The engine emits no play/pause events (the multitrack stays silent), so
|
||||
// the play-button visual that the ws "pause" handler normally toggles must
|
||||
// be driven here directly.
|
||||
if (eng) playBtn.classList.remove("playing");
|
||||
return;
|
||||
}
|
||||
const ctx = tx.audioContext;
|
||||
// Safari requires play() to be called synchronously within the user-gesture
|
||||
// handler. Resume the AudioContext fire-and-forget so the context becomes
|
||||
// live, then call play() immediately on the same tick.
|
||||
if (ctx && ctx.state === "suspended") {
|
||||
ctx.resume().catch(() => {});
|
||||
}
|
||||
// Snap playhead to loopStart on play (DAW convention).
|
||||
if (loopEnabled && totalDuration > 0) {
|
||||
tx.setTime(loopStart);
|
||||
}
|
||||
if (eng) {
|
||||
eng.play();
|
||||
playBtn.classList.add("playing");
|
||||
stopBtn.classList.remove("stopped");
|
||||
} else {
|
||||
_playWhenReady();
|
||||
}
|
||||
}
|
||||
|
||||
export function stopTransport() {
|
||||
const eng = audioEngine;
|
||||
const tx = eng ?? multitrack;
|
||||
if (!tx) return;
|
||||
tx.pause();
|
||||
tx.setTime(loopEnabled ? loopStart : 0); // engine: setTime → onTime → stop visual
|
||||
if (eng) playBtn.classList.remove("playing");
|
||||
}
|
||||
|
||||
export function toggleLoop() {
|
||||
setLoopEnabled(!loopEnabled);
|
||||
loopBtn.classList.toggle("active", loopEnabled);
|
||||
updateLoopRegionVisual();
|
||||
}
|
||||
|
||||
// Click-drag on the timeline ruler or waveform body to define the loop
|
||||
// region. Drag direction doesn't matter -- start and end get sorted.
|
||||
// Tiny drags are treated as clicks and seek the playhead instead.
|
||||
function wireLoopDrag() {
|
||||
let dragging = false;
|
||||
let dragStartTime = 0;
|
||||
let activePointerId = null;
|
||||
let moved = false;
|
||||
|
||||
const startDrag = (e, surface) => {
|
||||
if (e.button !== 0 || e.target.closest(".loop-region")) return;
|
||||
const t = timeFromClientX(e.clientX);
|
||||
if (t === null) return;
|
||||
dragging = true;
|
||||
activePointerId = e.pointerId;
|
||||
moved = false;
|
||||
dragStartTime = t;
|
||||
setLoopStart(t);
|
||||
setLoopEnd(t);
|
||||
setLoopEnabled(true);
|
||||
loopBtn.classList.add("active");
|
||||
updateLoopRegionVisual();
|
||||
surface.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const moveDrag = (e) => {
|
||||
if (!dragging || e.pointerId !== activePointerId) return;
|
||||
const t = timeFromClientX(e.clientX);
|
||||
if (t === null) return;
|
||||
if (Math.abs(t - dragStartTime) >= MIN_LOOP_SEC) moved = true;
|
||||
if (t < dragStartTime) {
|
||||
setLoopStart(t);
|
||||
setLoopEnd(dragStartTime);
|
||||
} else {
|
||||
setLoopStart(dragStartTime);
|
||||
setLoopEnd(t);
|
||||
}
|
||||
updateLoopRegionVisual();
|
||||
};
|
||||
|
||||
const finishDrag = (e) => {
|
||||
if (!dragging || e.pointerId !== activePointerId) return;
|
||||
dragging = false;
|
||||
activePointerId = null;
|
||||
const clicked = !moved || loopEnd - loopStart < MIN_LOOP_SEC;
|
||||
if (clicked) {
|
||||
setLoopEnabled(false);
|
||||
loopBtn.classList.remove("active");
|
||||
updateLoopRegionVisual();
|
||||
setPlayheadTime(dragStartTime);
|
||||
}
|
||||
};
|
||||
|
||||
const wavesColumn = document.querySelector(".waves-column");
|
||||
const surfaces = [rulerTime, wavesColumn].filter(Boolean);
|
||||
for (const surface of surfaces) {
|
||||
surface.addEventListener("pointerdown", (e) => {
|
||||
if (surface === rulerTime && e.target !== rulerTime) return;
|
||||
startDrag(e, surface);
|
||||
});
|
||||
surface.addEventListener("pointermove", moveDrag);
|
||||
surface.addEventListener("pointerup", finishDrag);
|
||||
surface.addEventListener("pointercancel", finishDrag);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Zoom ───
|
||||
//
|
||||
// Single CSS variable `--zoom` on .wave-canvas drives the visual width
|
||||
// (canvas = 100% * zoom). Multitrack's pxPerSec is set to match so its
|
||||
// internal canvases stay the exact same pixel width as the canvas; that
|
||||
// way the bundle never adds its own internal horizontal scroll, which
|
||||
// historically broke alignment with our ruler/loop overlay.
|
||||
//
|
||||
// All percentage-positioned children (ruler ticks, playhead, grid lines,
|
||||
// loop region) automatically stretch with the canvas, so the loop drag
|
||||
// math stays correct without any per-element width logic.
|
||||
|
||||
// Keep the header ruler horizontally aligned with the (possibly wider, scrolled)
|
||||
// waveform body. The ruler lives outside .wave-scroll, so translate it by the
|
||||
// same scrollLeft; .daw-ruler-area uses overflow-x: clip to hide the spill while
|
||||
// leaving the vertical playhead line (overflow-y: visible) intact.
|
||||
export function syncRulerScroll() {
|
||||
if (rulerTime && waveScroll) {
|
||||
rulerTime.style.transform = `translateX(${-waveScroll.scrollLeft}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyWaveZoom() {
|
||||
const lanes = document.getElementById("lanes") || waveCanvas;
|
||||
const wavesColumn = document.querySelector(".waves-column");
|
||||
if (wavesColumn) {
|
||||
lanes?.style.setProperty("--wave-playhead-h", `${wavesColumn.clientHeight}px`);
|
||||
}
|
||||
if (multitrack && totalDuration > 0 && waveScroll) {
|
||||
const baseWidth = waveScroll.clientWidth;
|
||||
if (baseWidth > 0) {
|
||||
// Fit to the visible width, but never compress below WAVE_MIN_WIDTH.
|
||||
const contentWidth = Math.max(baseWidth, WAVE_MIN_WIDTH);
|
||||
const zoom = contentWidth / baseWidth;
|
||||
// Widen the container via --zoom FIRST. Then, after the browser has
|
||||
// reflowed it, zoom WaveSurfer to fit the container's *actual* width.
|
||||
// Measuring post-reflow avoids the resize race where WaveSurfer renders
|
||||
// wider than its container and exposes its own (unstyled, light) internal
|
||||
// horizontal scrollbar — the only horizontal scroll must come from the
|
||||
// outer .wave-scroll, which also keeps the ruler/playhead aligned.
|
||||
lanes?.style.setProperty("--zoom", String(zoom));
|
||||
requestAnimationFrame(() => {
|
||||
if (!multitrack || totalDuration <= 0) return;
|
||||
const w = multitrackContainer?.clientWidth || contentWidth;
|
||||
try { multitrack.zoom(w / totalDuration); } catch { /* ignore -- pre-canplay */ }
|
||||
syncRulerScroll();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireZoomButtons() {
|
||||
if (waveScroll) {
|
||||
let rafId = null;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!multitrack || totalDuration <= 0) return;
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => { rafId = null; applyWaveZoom(); });
|
||||
});
|
||||
ro.observe(waveScroll);
|
||||
}
|
||||
if (waveScroll) {
|
||||
waveScroll.addEventListener("wheel", (e) => {
|
||||
if (waveScroll.scrollWidth <= waveScroll.clientWidth) return;
|
||||
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||
e.preventDefault();
|
||||
waveScroll.scrollLeft += e.deltaY;
|
||||
}
|
||||
}, { passive: false });
|
||||
waveScroll.addEventListener("scroll", syncRulerScroll, { passive: true });
|
||||
}
|
||||
applyWaveZoom();
|
||||
}
|
||||
|
||||
// Keep the mixer column and the waveform area scrolled in lockstep so stem
|
||||
// controls stay aligned with their lanes when the stack overflows (#159).
|
||||
function wireLaneScrollSync() {
|
||||
const mixer = document.getElementById("mixer");
|
||||
if (!mixer || !waveScroll) return;
|
||||
// Mirror scrollTop between the two panes by assigning only when the values
|
||||
// differ. The echo stops on its own (once equal, the partner's handler is a
|
||||
// no-op), so no reentrancy guard / rAF is needed — that frame-delayed guard
|
||||
// was what made inertial scrolling stutter (#163).
|
||||
const link = (src, dst) =>
|
||||
src.addEventListener("scroll", () => {
|
||||
if (dst.scrollTop !== src.scrollTop) dst.scrollTop = src.scrollTop;
|
||||
}, { passive: true });
|
||||
link(mixer, waveScroll);
|
||||
link(waveScroll, mixer);
|
||||
}
|
||||
|
||||
// ─── Wire transport buttons ───
|
||||
|
||||
export function wireTransportButtons() {
|
||||
playBtn.addEventListener("click", togglePlayPause);
|
||||
playMiniBtn?.addEventListener("click", togglePlayPause);
|
||||
stopBtn.addEventListener("click", stopTransport);
|
||||
loopBtn.addEventListener("click", toggleLoop);
|
||||
wireLoopDrag();
|
||||
wireLoopInputs();
|
||||
wireZoomButtons();
|
||||
wireLaneScrollSync();
|
||||
masterFader?.addEventListener("input", () => {
|
||||
setMasterVolume(parseFloat(masterFader.value));
|
||||
applyMix();
|
||||
});
|
||||
masterFader?.addEventListener("dblclick", () => {
|
||||
masterFader.value = "0.5";
|
||||
setMasterVolume(0.5);
|
||||
applyMix();
|
||||
});
|
||||
wireSpeedControl();
|
||||
}
|
||||
|
||||
function applySpeed(rate) {
|
||||
const clamped = Math.max(0.25, Math.min(2, rate));
|
||||
setPlaybackSpeed(clamped);
|
||||
if (speedEl) {
|
||||
speedEl.value = String(clamped);
|
||||
// range is 0-2; 1.0 sits at exactly 50%
|
||||
const pct = (clamped / 2) * 100;
|
||||
speedEl.style.setProperty("--speed-pct", `${pct.toFixed(1)}%`);
|
||||
}
|
||||
if (speedLabelEl) speedLabelEl.textContent = `${clamped % 1 === 0 ? clamped.toFixed(1) : clamped}x`;
|
||||
audioEngine?.setPlaybackRate?.(clamped);
|
||||
if (multitrack) {
|
||||
for (const a of (multitrack.audios ?? [])) {
|
||||
try { a.playbackRate = clamped; } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSpeed() {
|
||||
applySpeed(1.0);
|
||||
}
|
||||
|
||||
function wireSpeedControl() {
|
||||
if (!speedEl) return;
|
||||
speedEl.addEventListener("input", () => applySpeed(parseFloat(speedEl.value)));
|
||||
speedEl.addEventListener("dblclick", () => applySpeed(1.0));
|
||||
speedEl.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY < 0 ? 0.25 : -0.25;
|
||||
applySpeed(parseFloat(speedEl.value) + delta);
|
||||
}, { passive: false });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Small UI-chrome handlers extracted from inline index.html scripts / onclick
|
||||
// attributes so the Content-Security-Policy can forbid inline script (#171).
|
||||
// Loaded as a module (deferred), so the DOM is parsed before this runs.
|
||||
|
||||
// Upload button → trigger the hidden file input.
|
||||
document.getElementById("uploadFileBtn")?.addEventListener("click", () => {
|
||||
document.getElementById("fileInput")?.click();
|
||||
});
|
||||
|
||||
// Notification panel: toggle / close / close-on-outside-click.
|
||||
const notifBtn = document.getElementById("notifBtn");
|
||||
const notifWrap = notifBtn?.closest(".daw-notif-wrap");
|
||||
|
||||
function setNotifOpen(open) {
|
||||
notifWrap?.classList.toggle("open", open);
|
||||
notifBtn?.setAttribute("aria-expanded", String(open));
|
||||
}
|
||||
|
||||
notifBtn?.addEventListener("click", () => {
|
||||
setNotifOpen(!notifWrap?.classList.contains("open"));
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector(".daw-notif-close")
|
||||
?.addEventListener("click", () => setNotifOpen(false));
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (notifWrap?.classList.contains("open") && !notifWrap.contains(e.target)) {
|
||||
setNotifOpen(false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// ─── Persistent store (tauri-plugin-store via custom commands) ───
|
||||
//
|
||||
// Falls back to localStorage when running outside Tauri (browser dev mode).
|
||||
// The store is backed by ~/Library/Application Support/app.stemdeck.desktop/user-data.json
|
||||
// on macOS — outside WebKit's reach, so WebView resets can never destroy user data.
|
||||
|
||||
export async function storeGet(key, fallback = null) {
|
||||
if (window.__TAURI__?.core?.invoke) {
|
||||
try {
|
||||
const val = await window.__TAURI__.core.invoke("store_get", { key });
|
||||
return val ?? fallback;
|
||||
} catch (e) { console.warn("[store] get failed for", key, e); return fallback; }
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw !== null ? JSON.parse(raw) : fallback;
|
||||
} catch (e) { console.warn("[store] localStorage get failed for", key, e); return fallback; }
|
||||
}
|
||||
|
||||
export async function storeSet(key, value) {
|
||||
if (window.__TAURI__?.core?.invoke) {
|
||||
try {
|
||||
await window.__TAURI__.core.invoke("store_set", { key, value });
|
||||
} catch (e) { console.warn("[store] set failed for", key, e); }
|
||||
return;
|
||||
}
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.warn("[store] localStorage set failed", e); }
|
||||
}
|
||||
|
||||
// Debounced variant — coalesces rapid writes (e.g. mixer slider moves) into
|
||||
// a single store write ~300ms after the last call. Each call snapshots the
|
||||
// value so no mutation races occur.
|
||||
const _storePending = new Map();
|
||||
export function storeSetDebounced(key, value, delayMs = 300) {
|
||||
if (_storePending.has(key)) clearTimeout(_storePending.get(key));
|
||||
const snapshot = structuredClone(value);
|
||||
_storePending.set(key, setTimeout(() => {
|
||||
_storePending.delete(key);
|
||||
storeSet(key, snapshot);
|
||||
}, delayMs));
|
||||
}
|
||||
|
||||
// Keys that hold critical user data and must be migrated from localStorage
|
||||
// to the store on first launch after this feature ships.
|
||||
const _MIGRATE_KEYS = [
|
||||
"stemdeck.folders",
|
||||
"stemdeck.deleted_jobs",
|
||||
"stemdeck:selected-stems",
|
||||
];
|
||||
|
||||
// One-time bootstrap: copy localStorage → store for existing users.
|
||||
// On fresh installs (no localStorage data) this is a no-op that just writes
|
||||
// the migration flag so future upgrades can safely clear WebKit.
|
||||
export async function runStoreMigrationIfNeeded() {
|
||||
if (!window.__TAURI__?.core?.invoke) return;
|
||||
try {
|
||||
// Check if any critical key is absent from the store but present in localStorage.
|
||||
const needs = await Promise.all(
|
||||
_MIGRATE_KEYS.map(async (k) => {
|
||||
const inStore = await window.__TAURI__.core.invoke("store_get", { key: k });
|
||||
return inStore === null && localStorage.getItem(k) !== null;
|
||||
})
|
||||
).then((r) => r.some(Boolean));
|
||||
|
||||
if (needs) {
|
||||
// Migrate fixed keys.
|
||||
for (const k of _MIGRATE_KEYS) {
|
||||
try {
|
||||
const raw = localStorage.getItem(k);
|
||||
if (raw !== null) {
|
||||
await window.__TAURI__.core.invoke("store_set", { key: k, value: JSON.parse(raw) });
|
||||
}
|
||||
} catch (e) { console.warn("[store] migration failed for key", k, e); }
|
||||
}
|
||||
// Migrate per-job mix keys (stemdeck:mix:<jobId>).
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith("stemdeck:mix:")) {
|
||||
try {
|
||||
const raw = localStorage.getItem(k);
|
||||
if (raw !== null) {
|
||||
await window.__TAURI__.core.invoke("store_set", { key: k, value: JSON.parse(raw) });
|
||||
}
|
||||
} catch (e) { console.warn("[store] migration failed for key", k, e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write the flag regardless of whether migration was needed. This covers
|
||||
// fresh installs (no localStorage data) so future upgrades clear WebKit.
|
||||
await window.__TAURI__.core.invoke("mark_store_migration_done").catch((e) =>
|
||||
console.warn("[store] mark_store_migration_done failed:", e)
|
||||
);
|
||||
} catch (e) { console.warn("[store] migration error:", e); }
|
||||
}
|
||||
|
||||
export function fmtTime(s) {
|
||||
if (!isFinite(s) || s < 0) return "00:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60).toString().padStart(2, "0");
|
||||
return `${m.toString().padStart(2, "0")}:${sec}`;
|
||||
}
|
||||
|
||||
// Ruler tick: M:SS with no leading zero on the minutes digit ("0:30", "1:00", "12:30").
|
||||
export function fmtTickLabel(s) {
|
||||
if (!isFinite(s) || s < 0) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60).toString().padStart(2, "0");
|
||||
return `${m}:${sec}`;
|
||||
}
|
||||
|
||||
// Millisecond-precise timecode "mm:ss.mmm" for the exact-loop inputs. Integer-ms
|
||||
// math avoids a rounding carry bug (e.g. 0.9999s -> "00:01.000", not "00:00.1000").
|
||||
export function fmtTimeMs(s) {
|
||||
if (!isFinite(s) || s < 0) return "00:00.000";
|
||||
const totalMs = Math.round(s * 1000);
|
||||
const m = Math.floor(totalMs / 60000);
|
||||
const sec = Math.floor((totalMs % 60000) / 1000);
|
||||
const ms = totalMs % 1000;
|
||||
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}.${ms
|
||||
.toString()
|
||||
.padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
// Parse a user-typed loop time. Accepts "mm:ss(.mmm)" (seconds field 0-59) or a
|
||||
// plain decimal-seconds value ("12.48"). Returns seconds, or null if unparseable.
|
||||
export function parseTimecode(str) {
|
||||
const t = String(str ?? "").trim();
|
||||
const colon = /^(\d+):([0-5]?\d(?:\.\d{1,3})?)$/.exec(t);
|
||||
if (colon) return parseInt(colon[1], 10) * 60 + parseFloat(colon[2]);
|
||||
if (/^\d+(?:\.\d+)?$/.test(t)) return parseFloat(t);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
@@ -0,0 +1,939 @@
|
||||
// StemDeck mobile — vanilla port of the Claude Design prototype
|
||||
// (design/mobile/StemDeck-Mobile.dc.html). No framework, no build step,
|
||||
// to match the rest of static/. Library + Mixer are wired to the real API
|
||||
// (GET /api/jobs, /api/jobs/{id}, the Web Audio engine, mixdown export).
|
||||
// Extract is still mock pending the SSE/upload wiring (next step).
|
||||
import { fetchJobs, jobToCard } from "../js/shared/jobs.js";
|
||||
import { createChunkedAudioEngine } from "../js/chunkedAudioEngine.js";
|
||||
// Per-stem label + color, keyed by the backend stem name. Unknown names fall
|
||||
// back to a rotating palette so non-standard models still render sensibly.
|
||||
const STEM_META = {
|
||||
vocals: { label: "Vocals", color: "#f0506e" },
|
||||
drums: { label: "Drums", color: "#f5862b" },
|
||||
bass: { label: "Bass", color: "#f5c518" },
|
||||
guitar: { label: "Guitar", color: "#3fcf6e" },
|
||||
piano: { label: "Piano", color: "#9b6cf0" },
|
||||
other: { label: "Other", color: "#4a9bf5" },
|
||||
};
|
||||
const FALLBACK_COLORS = ["#f0506e", "#f5862b", "#f5c518", "#3fcf6e", "#9b6cf0", "#4a9bf5", "#2bd4c4", "#c44ad0"];
|
||||
function stemMeta(name, idx) {
|
||||
return STEM_META[name] || { label: name.charAt(0).toUpperCase() + name.slice(1), color: FALLBACK_COLORS[idx % FALLBACK_COLORS.length] };
|
||||
}
|
||||
|
||||
// Stem chips shown on the (still-mock) Extract screen.
|
||||
const EXTRACT_STEMS = Object.entries(STEM_META).map(([id, m]) => ({ id, name: m.label, color: m.color }));
|
||||
|
||||
const DEFAULT_GRADIENT = "linear-gradient(150deg,#3a3a42,#202026)";
|
||||
const FILTERS = ["All", "Favorites"];
|
||||
|
||||
// ─── Web Audio engine (reused from desktop: ../js/audioEngine.js) ───
|
||||
let engine = null;
|
||||
let engineTrackId = null;
|
||||
let engineToken = 0; // guards against races when switching tracks mid-decode
|
||||
let engineReady = false; // true once the current engine has decodable stems
|
||||
let pendingPlay = false;
|
||||
|
||||
// Shared AudioContext, created + resumed inside a user gesture (mobile/iOS
|
||||
// won't start audio otherwise). Once running it stays running, so later
|
||||
// programmatic play() calls work even outside a gesture.
|
||||
let audioCtx = null;
|
||||
function ensureAudioCtx() {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
if (!audioCtx) audioCtx = new AC();
|
||||
if (audioCtx.state === "suspended") audioCtx.resume().catch(() => {});
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
|
||||
const ICON = {
|
||||
chevron: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>',
|
||||
dots: '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
|
||||
prev: '<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor"><path d="M7 6v12M19 6l-9 6 9 6z"/></svg>',
|
||||
next: '<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor"><path d="M17 6v12M5 6l9 6-9 6z"/></svg>',
|
||||
play: (sz, fill) => `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="${fill}"><path d="M8 5.5v13l11-6.5z"/></svg>`,
|
||||
pause: (sz, fill) => `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="${fill}"><rect x="6" y="5" width="4" height="14" rx="1.3"/><rect x="14" y="5" width="4" height="14" rx="1.3"/></svg>`,
|
||||
download: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M8 11l4 4 4-4"/><path d="M4 21h16"/></svg>',
|
||||
search: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#65656d" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>',
|
||||
link: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#65656d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
|
||||
upload: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/><path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>',
|
||||
check: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>',
|
||||
scissors: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4L8.12 15.88M14.47 14.48L20 20M8.12 8.12L12 12"/></svg>',
|
||||
tabLib: '<svg width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>',
|
||||
tabMix: '<svg width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 8h16M4 16h16"/><circle cx="9" cy="8" r="2.4" fill="currentColor" stroke="none"/><circle cx="15" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>',
|
||||
tabExt: '<svg width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/><path d="M20 4L8.5 15.5M14 14.5L20 20M8.5 8.5L11.5 11.5"/></svg>',
|
||||
};
|
||||
|
||||
const state = {
|
||||
tab: "mixer",
|
||||
mixerView: "stems",
|
||||
playing: false,
|
||||
progress: 0,
|
||||
vols: {}, // per-stem gain 0..1, keyed by backend stem name
|
||||
muted: {},
|
||||
solo: {},
|
||||
speed: 1.0,
|
||||
selected: { vocals: true, drums: true, bass: true, guitar: true, piano: true, other: true },
|
||||
quality: "High",
|
||||
filter: "All",
|
||||
// Library data from /api/jobs. libState: "loading" | "ready" | "empty" | "error".
|
||||
tracks: [],
|
||||
libState: "loading",
|
||||
swipedTrackId: null, // track whose swipe-to-delete is revealed
|
||||
current: null, // selected track card (with .detail once loaded), or null
|
||||
// Extract screen.
|
||||
extractUrl: "",
|
||||
extractFile: null, // File chosen for upload
|
||||
extractJob: null, // { id, title, status, progress, stage } while a job runs
|
||||
};
|
||||
|
||||
let extractES = null; // EventSource for the active extraction
|
||||
let extractPoll = null; // REST poll fallback timer
|
||||
|
||||
const app = document.getElementById("app");
|
||||
|
||||
function fmt(sec) {
|
||||
if (!isFinite(sec) || sec < 0) sec = 0;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return m + ":" + String(s).padStart(2, "0");
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
}
|
||||
|
||||
// Cover/thumbnail art. Uses the real YouTube/SoundCloud thumbnail when present
|
||||
// (layered over the gradient as a fallback if it fails to load); otherwise the
|
||||
// gradient + initial. The URL is constrained to a clean https URL so it can't
|
||||
// break out of the CSS url().
|
||||
function safeThumb(url) {
|
||||
return typeof url === "string" && /^https:\/\/[^"'()\\\s]+$/.test(url) ? url : "";
|
||||
}
|
||||
function artStyle(card) {
|
||||
const g = (card && card.gradient) || DEFAULT_GRADIENT;
|
||||
const t = card && safeThumb(card.thumb);
|
||||
return t
|
||||
? `background-image:url('${t}'), ${g};background-size:cover;background-position:center;`
|
||||
: `background:${g};`;
|
||||
}
|
||||
function artLabel(card) {
|
||||
return card && safeThumb(card.thumb) ? "" : esc((card && card.initial) || "♪");
|
||||
}
|
||||
|
||||
// ─── Mixer / engine helpers ───
|
||||
function lanes() {
|
||||
return state.current?.detail?.lanes || [];
|
||||
}
|
||||
function curDuration() {
|
||||
return state.current?.detail?.duration || 0;
|
||||
}
|
||||
function anySolo() {
|
||||
return Object.values(state.solo).some(Boolean);
|
||||
}
|
||||
function laneActive(name) {
|
||||
const muted = !!state.muted[name];
|
||||
const soloed = !!state.solo[name];
|
||||
return !(muted || (anySolo() && !soloed));
|
||||
}
|
||||
function effectiveGain(name) {
|
||||
return laneActive(name) ? (state.vols[name] ?? 1) : 0;
|
||||
}
|
||||
function applyMix() {
|
||||
if (!engine) return;
|
||||
for (const l of lanes()) engine.setGain(l.name, effectiveGain(l.name));
|
||||
}
|
||||
|
||||
function cap(s) {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
function drLabel(dr) {
|
||||
if (dr < 7) return "Compressed";
|
||||
if (dr < 10) return "Moderate";
|
||||
if (dr < 14) return "High";
|
||||
return "Wide";
|
||||
}
|
||||
function stabilityLabel(pct) {
|
||||
if (pct >= 90) return "Very stable";
|
||||
if (pct >= 70) return "Stable";
|
||||
if (pct >= 40) return "Variable";
|
||||
return "Unstable";
|
||||
}
|
||||
|
||||
// Build the Analysis tab's stat cards + stem-presence rows from a job detail.
|
||||
function extractAnalysis(d, laneList) {
|
||||
const stats = [];
|
||||
if (d.key) stats.push({ k: "KEY", v: d.key, s: d.scale ? cap(d.scale) : "" });
|
||||
if (d.bpm) stats.push({ k: "TEMPO", v: String(Math.round(d.bpm)), s: "BPM" });
|
||||
if (d.lufs != null) stats.push({ k: "LUFS", v: Number(d.lufs).toFixed(1), s: d.peak_db != null ? `Peak ${Number(d.peak_db).toFixed(1)} dB` : "Integrated" });
|
||||
if (d.duration) stats.push({ k: "DURATION", v: fmt(d.duration), s: "Full length" });
|
||||
if (d.dynamic_range != null) stats.push({ k: "DYN RANGE", v: Number(d.dynamic_range).toFixed(1), s: drLabel(d.dynamic_range) });
|
||||
if (d.tempo_stability != null) {
|
||||
const pct = d.tempo_stability > 1 ? d.tempo_stability : d.tempo_stability * 100;
|
||||
stats.push({ k: "STABILITY", v: Math.round(pct) + "%", s: stabilityLabel(pct) });
|
||||
}
|
||||
const sp = d.stem_presence || {};
|
||||
const presence = laneList
|
||||
.filter((l) => sp[l.name] != null)
|
||||
.map((l) => ({ name: l.label, color: l.color, val: Math.round(sp[l.name]) }));
|
||||
return { stats, presence };
|
||||
}
|
||||
|
||||
// Repaint the main waveform's played (yellow) vs. remaining bars as playback
|
||||
// advances. Only touches the DOM when the played-bar count actually changes
|
||||
// (≤ N times over the whole track), not every animation frame.
|
||||
let _lastPlayedBar = -1;
|
||||
function paintWaveProgress() {
|
||||
const bars = app.querySelectorAll(".wave-bars > i");
|
||||
if (!bars.length) return;
|
||||
const played = Math.round(state.progress * bars.length);
|
||||
if (played === _lastPlayedBar) return;
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
bars[i].style.background = i <= played ? "#f5b417" : "#34343c";
|
||||
}
|
||||
_lastPlayedBar = played;
|
||||
}
|
||||
|
||||
function onEngineTime(t) {
|
||||
const dur = curDuration() || 1;
|
||||
state.progress = Math.max(0, Math.min(1, t / dur));
|
||||
const head = app.querySelector(".playhead");
|
||||
const cur = app.querySelector(".wave-times .cur");
|
||||
if (head) head.style.left = state.progress * 100 + "%";
|
||||
if (cur) cur.textContent = fmt(t);
|
||||
paintWaveProgress();
|
||||
}
|
||||
|
||||
// Load a track: fetch detail, build lanes, spin up the Web Audio engine.
|
||||
async function openTrack(card, { autoplay = false } = {}) {
|
||||
state.tab = "mixer";
|
||||
state.current = { ...card, detail: null, loading: true, error: null };
|
||||
state.playing = false;
|
||||
state.progress = 0;
|
||||
state.speed = 1.0;
|
||||
render();
|
||||
|
||||
if (engine) { engine.destroy(); engine = null; engineTrackId = null; }
|
||||
engineReady = false;
|
||||
const token = ++engineToken;
|
||||
pendingPlay = autoplay;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${card.id}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`GET /api/jobs/${card.id} -> ${res.status}`);
|
||||
detail = await res.json();
|
||||
} catch (e) {
|
||||
if (token !== engineToken) return;
|
||||
console.warn("[mobile] track detail failed:", e);
|
||||
state.current.loading = false;
|
||||
state.current.error = "Couldn't load this track.";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (token !== engineToken) return;
|
||||
|
||||
// Use WAV stems with range-request chunking (chunkedAudioEngine.js): fetches
|
||||
// 10-second windows at a time, so the first audio starts after ~7 MB instead
|
||||
// of the full file, and peak RAM stays around 28 MB regardless of track length.
|
||||
const laneList = (detail.stems || [])
|
||||
.filter((s) => s && s.name !== "original" && s.url)
|
||||
.map((s, i) => ({ name: s.name, url: s.url, ...stemMeta(s.name, i) }));
|
||||
|
||||
state.vols = {};
|
||||
state.muted = {};
|
||||
state.solo = {};
|
||||
for (const l of laneList) state.vols[l.name] = 1;
|
||||
|
||||
state.current.detail = {
|
||||
duration: detail.duration || 0,
|
||||
lanes: laneList,
|
||||
hasVideo: !!detail.has_video,
|
||||
analysis: extractAnalysis(detail, laneList),
|
||||
};
|
||||
state.current.loading = false;
|
||||
state.current.stemCount = laneList.length;
|
||||
|
||||
if (!laneList.length) {
|
||||
state.current.error = "This track has no playable stems yet.";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
const engineOpts = {
|
||||
onTime: onEngineTime,
|
||||
onEnded: () => { state.playing = false; render(); },
|
||||
context: ensureAudioCtx(),
|
||||
};
|
||||
engine = createChunkedAudioEngine(laneList.map((l) => ({ name: l.name, url: l.url })), engineOpts);
|
||||
engineTrackId = card.id;
|
||||
render();
|
||||
|
||||
const ok = await engine.ready;
|
||||
if (token !== engineToken) return;
|
||||
if (!ok) {
|
||||
state.current.error = "Couldn't load this track's audio.";
|
||||
pendingPlay = false;
|
||||
toast("Couldn't load this track's audio.");
|
||||
render();
|
||||
return;
|
||||
}
|
||||
engineReady = true;
|
||||
applyMix();
|
||||
render();
|
||||
if (pendingPlay) { pendingPlay = false; engine.play(); state.playing = engine.isPlaying(); render(); }
|
||||
}
|
||||
|
||||
function prevTrack() {
|
||||
if (!state.tracks.length || !state.current) return;
|
||||
const idx = state.tracks.findIndex((t) => t.id === state.current.id);
|
||||
const target = state.tracks[idx - 1];
|
||||
if (target) openTrack(target, { autoplay: state.playing });
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
if (!state.tracks.length || !state.current) return;
|
||||
const idx = state.tracks.findIndex((t) => t.id === state.current.id);
|
||||
const target = state.tracks[idx + 1];
|
||||
if (target) openTrack(target, { autoplay: state.playing });
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!state.current) return;
|
||||
ensureAudioCtx(); // unlock audio within this gesture
|
||||
// Engine not loaded for this track yet → load then autoplay.
|
||||
if (!engine || engineTrackId !== state.current.id) {
|
||||
openTrack(state.current, { autoplay: true });
|
||||
return;
|
||||
}
|
||||
// Decode still in flight → remember the intent and play once ready.
|
||||
if (!engineReady) {
|
||||
pendingPlay = true;
|
||||
return;
|
||||
}
|
||||
if (engine.isPlaying()) engine.pause();
|
||||
else engine.play();
|
||||
state.playing = engine.isPlaying();
|
||||
render();
|
||||
}
|
||||
|
||||
function seekToFraction(frac) {
|
||||
if (!engine) return;
|
||||
engine.seek(Math.max(0, Math.min(1, frac)) * curDuration());
|
||||
}
|
||||
|
||||
function triggerDownload(url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
function exportMix(kind) {
|
||||
const id = state.current?.id;
|
||||
if (!id || !lanes().length) return;
|
||||
const names = [];
|
||||
const gains = [];
|
||||
for (const l of lanes()) {
|
||||
const g = effectiveGain(l.name);
|
||||
if (g > 0) { names.push(l.name); gains.push(g); }
|
||||
}
|
||||
if (!names.length) { toast("Every stem is muted — nothing to export."); return; }
|
||||
const q = new URLSearchParams({ stems: names.join(","), gains: gains.map((g) => g.toFixed(3)).join(",") });
|
||||
const path = kind === "mp4" ? `video.mp4` : `mixdown.wav`;
|
||||
triggerDownload(`/api/jobs/${id}/${path}?${q}`);
|
||||
}
|
||||
|
||||
let _toastTimer = null;
|
||||
function toast(msg) {
|
||||
let el = document.getElementById("m-toast");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "m-toast";
|
||||
el.className = "m-toast";
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent = msg;
|
||||
el.classList.add("show");
|
||||
clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(() => el.classList.remove("show"), 2400);
|
||||
}
|
||||
|
||||
function mainWaveform() {
|
||||
const N = 56;
|
||||
const played = Math.round(state.progress * N);
|
||||
let bars = "";
|
||||
for (let i = 0; i < N; i++) {
|
||||
let h = 24 + Math.abs(Math.sin(i * 0.7) + Math.sin(i * 0.21) * 0.7) * 46 + ((i * 37) % 11);
|
||||
h = Math.max(14, Math.min(98, h));
|
||||
const bg = i <= played ? "#f5b417" : "#34343c";
|
||||
bars += `<i style="height:${h}%;background:${bg}"></i>`;
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
|
||||
function stemWave(idx, color, vol, eff) {
|
||||
const wn = 28;
|
||||
const wplayed = Math.round(state.progress * wn);
|
||||
let out = "";
|
||||
for (let i = 0; i < wn; i++) {
|
||||
let wh = 16 + Math.abs(Math.sin(i * (0.55 + idx * 0.13)) + Math.sin(i * 0.33 + idx) * 0.6) * 62 + ((i * (7 + idx * 3)) % 9);
|
||||
wh = Math.max(7, Math.min(98, wh)) * (0.3 + 0.7 * vol);
|
||||
const wc = eff ? "#34343c" : i <= wplayed ? color : color + "3d";
|
||||
out += `<i style="height:${wh}%;background:${wc}"></i>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stemsBody() {
|
||||
const c = state.current;
|
||||
if (!c) return `<div class="lib-note">Pick a track from your Library to start mixing.</div>`;
|
||||
if (c.loading) return `<div class="lib-note">Loading stems…</div>`;
|
||||
if (c.error) return `<div class="lib-note">${esc(c.error)}</div>`;
|
||||
const ll = lanes();
|
||||
if (!ll.length) return `<div class="lib-note">No stems available.</div>`;
|
||||
return `<div class="stems-grid">${ll.map((l, idx) => {
|
||||
const vol = state.vols[l.name] ?? 1;
|
||||
const muted = !!state.muted[l.name];
|
||||
const soloed = !!state.solo[l.name];
|
||||
const eff = !laneActive(l.name);
|
||||
const pct = Math.round(vol * 100) + "%";
|
||||
const mOn = muted ? `background:${l.color};color:#15100a;border-color:${l.color};` : "";
|
||||
const sOn = soloed ? "background:#f5b417;color:#1a1206;border-color:#f5b417;" : "";
|
||||
return `<div class="stem">
|
||||
<div class="stem-top">
|
||||
<div class="stem-dot" style="background:${l.color};box-shadow:0 0 8px ${l.color}88;opacity:${eff ? 0.4 : 1}"></div>
|
||||
<div class="stem-name" style="color:${eff ? "#6a6a72" : l.color}">${esc(l.label)}</div>
|
||||
<button class="ms-btn" style="${mOn}" data-action="mute" data-id="${l.name}">M</button>
|
||||
<button class="ms-btn" style="${sOn}" data-action="solo" data-id="${l.name}">S</button>
|
||||
</div>
|
||||
<div class="stem-wave">${stemWave(idx, l.color, vol, eff)}</div>
|
||||
<div class="fader" data-fader data-id="${l.name}">
|
||||
<div class="fader-track"></div>
|
||||
<div class="fader-fill" style="width:${pct};background:${eff ? "#46464e" : l.color}"></div>
|
||||
<div class="fader-knob" style="left:${pct};background:${eff ? "#8a8a90" : "#fff"}"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
function analysisBody() {
|
||||
const a = state.current?.detail?.analysis;
|
||||
const hasVideo = state.current?.detail?.hasVideo;
|
||||
const statsHtml = a && a.stats.length
|
||||
? `<div class="stats">${a.stats.map((x) => `<div class="stat"><div class="stat-k">${x.k}</div><div class="stat-v">${esc(x.v)}</div><div class="stat-s">${esc(x.s)}</div></div>`).join("")}</div>`
|
||||
: `<div class="lib-note" style="margin-top:8px">Analysis not available for this track.</div>`;
|
||||
const presenceHtml = a && a.presence.length
|
||||
? `<div class="eyebrow">STEM PRESENCE</div>${a.presence.map((p) => `<div class="presence-row"><div class="presence-name">${esc(p.name)}</div><div class="presence-bar"><i style="width:${p.val}%;background:${p.color}"></i></div><span class="presence-val">${p.val}%</span></div>`).join("")}`
|
||||
: "";
|
||||
const exportBtns = `<button class="cta sm" data-action="export" data-kind="wav">${ICON.download}Export mix</button>${
|
||||
hasVideo ? `<button class="cta sm" data-action="export" data-kind="mp4" style="margin-top:10px">${ICON.download}Export MP4 (with video)</button>` : ""
|
||||
}`;
|
||||
return `<div class="pad" style="padding-top:16px">${statsHtml}${presenceHtml}${exportBtns}</div>`;
|
||||
}
|
||||
|
||||
function mixerScreen() {
|
||||
const c = state.current || { title: "No track selected", sub: "Pick one from your Library", initial: "♪", gradient: DEFAULT_GRADIENT, stemCount: 0 };
|
||||
const sourceTag = c.sub || "—";
|
||||
const stemTag = c.stemCount ? `${c.stemCount} stems` : "";
|
||||
const dur = curDuration();
|
||||
const body = state.mixerView === "stems" ? stemsBody() : analysisBody();
|
||||
const ready = engineReady && engineTrackId === state.current?.id;
|
||||
const preparing = !!state.current && !state.current.loading && !state.current.error && !ready;
|
||||
const canPlay = ready;
|
||||
const curIdx = state.current ? state.tracks.findIndex((t) => t.id === state.current.id) : -1;
|
||||
const hasPrev = curIdx > 0;
|
||||
const hasNext = curIdx >= 0 && curIdx < state.tracks.length - 1;
|
||||
|
||||
return `<div class="screen scrl">
|
||||
<div class="pad">
|
||||
<div class="mx-head">
|
||||
<button class="icon-btn" data-action="tab" data-tab="library">${ICON.chevron}</button>
|
||||
<span class="now-playing">NOW PLAYING</span>
|
||||
<button class="icon-btn">${ICON.dots}</button>
|
||||
</div>
|
||||
<div class="cover-wrap">
|
||||
<div class="cover" style="${artStyle(c)}"><span>${artLabel(c)}</span></div>
|
||||
<div class="track-title">${esc(c.title)}</div>
|
||||
<div class="track-sub">${esc(c.sub)}</div>
|
||||
<div class="tags"><span class="tag">${esc(sourceTag)}</span>${stemTag ? `<span class="tag">${stemTag}</span>` : ""}</div>
|
||||
</div>
|
||||
<div class="wave">
|
||||
<div class="wave-bars" data-seek>${mainWaveform()}<div class="playhead" style="left:${state.progress * 100}%"></div></div>
|
||||
<div class="wave-times"><span class="cur">${fmt(state.progress * dur)}</span><span class="dur">${fmt(dur)}</span></div>
|
||||
</div>
|
||||
<div class="transport">
|
||||
<button class="t-step" data-action="prev" ${hasPrev ? "" : "disabled"}>${ICON.prev}</button>
|
||||
<button class="t-play" data-action="play" data-playing="${state.playing}" ${canPlay ? "" : "disabled style=opacity:.45"}>${state.playing ? ICON.pause(26, "#1a1206") : ICON.play(28, "#1a1206")}</button>
|
||||
<button class="t-step" data-action="next" ${hasNext ? "" : "disabled"}>${ICON.next}</button>
|
||||
</div>
|
||||
<div class="speed-row">
|
||||
<span class="speed-row-label">Speed</span>
|
||||
<input type="range" class="speed-slider" data-speed min="0" max="2" step="0.25" value="${state.speed}">
|
||||
<span class="speed-row-val">${state.speed % 1 === 0 ? state.speed.toFixed(1) : state.speed}x</span>
|
||||
</div>
|
||||
${preparing ? '<div class="mx-prep">Preparing audio…</div>' : ""}
|
||||
<div class="segmented">
|
||||
<button class="${state.mixerView === "stems" ? "on" : ""}" data-action="mixview" data-view="stems">Stems</button>
|
||||
<button class="${state.mixerView === "analysis" ? "on" : ""}" data-action="mixview" data-view="analysis">Analysis</button>
|
||||
</div>
|
||||
</div>
|
||||
${body}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function libraryBody() {
|
||||
if (state.libState === "loading") {
|
||||
return `<div class="lib-note">Loading your library…</div>`;
|
||||
}
|
||||
if (state.libState === "error") {
|
||||
return `<div class="lib-note">Couldn't reach the server. <button class="lib-retry" data-action="reload">Retry</button></div>`;
|
||||
}
|
||||
if (state.libState === "empty") {
|
||||
return `<div class="lib-note">No tracks yet. Head to <b>Extract</b> to split your first song.</div>`;
|
||||
}
|
||||
return `<div class="eyebrow">RECENT</div>
|
||||
${state.tracks.map((t) => `<div class="track-wrap${state.swipedTrackId === t.id ? " swiped" : ""}">
|
||||
<button class="track-delete" data-action="delete" data-id="${esc(t.id)}">Delete</button>
|
||||
<div class="track" data-action="open" data-id="${esc(t.id)}">
|
||||
<div class="track-art" style="${artStyle(t)}">${artLabel(t)}</div>
|
||||
<div class="track-info"><div class="t">${esc(t.title)}</div><div class="s">${esc(t.sub)}</div><div class="m">${esc(t.meta)}</div></div>
|
||||
<div class="track-dot ${t.status}"></div>
|
||||
<button class="track-load" data-action="open" data-id="${esc(t.id)}">Load</button>
|
||||
</div>
|
||||
</div>`).join("")}`;
|
||||
}
|
||||
|
||||
function libraryScreen() {
|
||||
return `<div class="screen scrl">
|
||||
<div class="pad">
|
||||
<div class="lib-head">
|
||||
<span class="h1">Library</span>
|
||||
<div class="avatar">JS</div>
|
||||
</div>
|
||||
<div class="search">${ICON.search}<span>Search your library</span></div>
|
||||
<div class="filters scrl">${FILTERS.map((f) => `<span class="filter ${f === state.filter ? "on" : ""}" data-action="filter" data-filter="${f}">${f}</span>`).join("")}</div>
|
||||
${libraryBody()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function extractProgressCard() {
|
||||
const j = state.extractJob;
|
||||
if (!j) return "";
|
||||
const pct = Math.round((j.progress || 0) * 100);
|
||||
const failed = j.status === "error";
|
||||
const cancelled = j.status === "cancelled";
|
||||
const done = j.status === "done";
|
||||
const line = failed ? "Failed" : cancelled ? "Cancelled" : done ? "Done" : `${j.stage || j.status}…`;
|
||||
return `<div class="eyebrow">IN PROGRESS</div>
|
||||
<div class="progress-card">
|
||||
<div class="progress-top">
|
||||
<div class="progress-art"></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:14px;font-weight:600;color:#eaeaec;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(j.title || "Track")}</div>
|
||||
<div style="font-size:12px;color:${failed ? "#f0506e" : "#7a7a82"};margin-top:1px">${esc(line)}</div>
|
||||
</div>
|
||||
<span class="progress-pct">${done ? "100" : pct}%</span>
|
||||
</div>
|
||||
<div class="progress-bar"><i style="width:${done ? 100 : pct}%"></i></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function extractScreen() {
|
||||
const fileName = state.extractFile?.name;
|
||||
return `<div class="screen scrl">
|
||||
<div class="pad">
|
||||
<div class="h1">Extract stems</div>
|
||||
<div class="sub">Paste a link or upload audio to split into stems.</div>
|
||||
<div class="paste">${ICON.link}<input id="ext-url" class="ext-input" type="url" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="Paste YouTube or audio URL" value="${esc(state.extractUrl || "")}"></div>
|
||||
<button class="upload" data-action="pick-file">${ICON.upload}${fileName ? esc(fileName) : "Upload audio file"}</button>
|
||||
<input id="ext-file" type="file" accept="audio/*,video/mp4,.mp3,.wav,.flac,.m4a,.ogg" style="display:none">
|
||||
<div class="eyebrow">STEMS TO EXTRACT</div>
|
||||
<div class="chips">${EXTRACT_STEMS.map((s) => {
|
||||
const on = !!state.selected[s.id];
|
||||
const onStyle = on ? `border-color:${s.color};background:${s.color}1c;` : "";
|
||||
return `<button class="chip-btn ${on ? "on" : ""}" style="${onStyle}" data-action="chip" data-id="${s.id}"><div class="dot" style="background:${s.color}"></div><span class="nm">${s.name}</span>${on ? ICON.check : ""}</button>`;
|
||||
}).join("")}</div>
|
||||
<button class="cta" style="margin-top:22px" data-action="split">${ICON.scissors}Split stems</button>
|
||||
${extractProgressCard()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Submit an extraction (URL or file) and follow it to completion.
|
||||
async function startExtraction() {
|
||||
const stems = Object.keys(state.selected).filter((k) => state.selected[k]);
|
||||
if (!stems.length) { toast("Pick at least one stem."); return; }
|
||||
const file = state.extractFile;
|
||||
const url = (state.extractUrl || "").trim();
|
||||
if (!file && !url) { toast("Paste a URL or choose a file first."); return; }
|
||||
|
||||
let init, title;
|
||||
if (file) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("stems", JSON.stringify(stems));
|
||||
init = { method: "POST", body: fd };
|
||||
title = file.name;
|
||||
} else {
|
||||
init = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, stems }) };
|
||||
title = url;
|
||||
}
|
||||
|
||||
state.extractJob = { id: null, title, status: "queued", progress: 0, stage: "Queued" };
|
||||
render();
|
||||
|
||||
let jobId;
|
||||
try {
|
||||
const res = await fetch("/api/jobs", init);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || res.statusText);
|
||||
jobId = data.job_id;
|
||||
} catch (e) {
|
||||
console.warn("[mobile] extraction submit failed:", e);
|
||||
state.extractJob = null;
|
||||
toast(`Couldn't start: ${e.message}`);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
state.extractJob.id = jobId;
|
||||
state.extractFile = null;
|
||||
state.extractUrl = "";
|
||||
followExtraction(jobId);
|
||||
render();
|
||||
}
|
||||
|
||||
function _onExtractState(jobId, s) {
|
||||
if (!state.extractJob || state.extractJob.id !== jobId) return false;
|
||||
state.extractJob.status = s.status;
|
||||
state.extractJob.progress = s.progress || 0;
|
||||
state.extractJob.stage = s.stage || s.status;
|
||||
if (state.tab === "extract") render();
|
||||
if (s.status === "done" || s.status === "error" || s.status === "cancelled") {
|
||||
if (s.status === "done") { toast("Stems ready!"); loadLibrary(); }
|
||||
else if (s.status === "error") toast("Extraction failed.");
|
||||
const finishedId = jobId;
|
||||
setTimeout(() => {
|
||||
if (state.extractJob && state.extractJob.id === finishedId) {
|
||||
state.extractJob = null;
|
||||
if (state.tab === "extract") render();
|
||||
}
|
||||
}, 4000);
|
||||
return true; // terminal
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// SSE with a REST-poll fallback (mirrors the desktop's resilience, simplified).
|
||||
function followExtraction(jobId) {
|
||||
if (extractES) { extractES.close(); extractES = null; }
|
||||
if (extractPoll) { clearInterval(extractPoll); extractPoll = null; }
|
||||
|
||||
const startPolling = () => {
|
||||
if (extractPoll) return;
|
||||
extractPoll = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/jobs/${jobId}`, { cache: "no-store" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
if (_onExtractState(jobId, await r.json())) { clearInterval(extractPoll); extractPoll = null; }
|
||||
} catch { /* keep polling */ }
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
try {
|
||||
const es = new EventSource(`/api/jobs/${jobId}/events`);
|
||||
extractES = es;
|
||||
es.onmessage = (ev) => {
|
||||
let s;
|
||||
try { s = JSON.parse(ev.data); } catch { return; }
|
||||
if (_onExtractState(jobId, s)) { es.close(); extractES = null; }
|
||||
};
|
||||
es.onerror = () => { es.close(); extractES = null; startPolling(); };
|
||||
} catch {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function miniPlayer() {
|
||||
if (state.tab === "mixer" || !state.current) return "";
|
||||
const c = state.current;
|
||||
return `<div class="mini" data-action="tab" data-tab="mixer">
|
||||
<div class="mini-art" style="${artStyle(c)}">${artLabel(c)}</div>
|
||||
<div class="mini-info"><div class="t">${esc(c.title)}</div><div class="s">${esc(c.sub)}</div></div>
|
||||
<button class="mini-play" data-action="play-mini">${state.playing ? ICON.pause(17, "#1a1206") : ICON.play(18, "#1a1206")}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function tabBar() {
|
||||
const t = (tab, icon, label) => `<button class="tab ${state.tab === tab ? "on" : ""}" data-action="tab" data-tab="${tab}">${icon}${label}</button>`;
|
||||
return `<div class="tabbar">${t("extract", ICON.tabExt, "Extract")}${t("mixer", ICON.tabMix, "Mixer")}${t("library", ICON.tabLib, "Library")}</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
let screen = mixerScreen();
|
||||
if (state.tab === "library") screen = libraryScreen();
|
||||
else if (state.tab === "extract") screen = extractScreen();
|
||||
app.innerHTML = screen + miniPlayer() + tabBar();
|
||||
_lastPlayedBar = -1; // bars were just rebuilt; force a repaint on next tick
|
||||
wireFaders();
|
||||
wireSwipe();
|
||||
}
|
||||
|
||||
// Suppresses the synthetic click that follows a swipe gesture, so swiping a
|
||||
// row open doesn't immediately count as a tap.
|
||||
let suppressClick = false;
|
||||
|
||||
// Swipe a library row left to reveal its Delete button (iOS-style). Wired
|
||||
// imperatively after each render.
|
||||
function wireSwipe() {
|
||||
app.querySelectorAll(".track-wrap .track").forEach((row) => {
|
||||
const id = row.dataset.id;
|
||||
let x0 = 0, y0 = 0, active = false, horizontal = false;
|
||||
const base = () => (state.swipedTrackId === id ? -84 : 0);
|
||||
|
||||
row.addEventListener("pointerdown", (e) => {
|
||||
x0 = e.clientX; y0 = e.clientY; active = true; horizontal = false;
|
||||
row.style.transition = "none";
|
||||
});
|
||||
row.addEventListener("pointermove", (e) => {
|
||||
if (!active) return;
|
||||
const dx = e.clientX - x0;
|
||||
const dy = e.clientY - y0;
|
||||
if (!horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) horizontal = true;
|
||||
if (horizontal) {
|
||||
e.preventDefault();
|
||||
const tx = Math.max(-84, Math.min(0, base() + dx));
|
||||
row.style.transform = `translateX(${tx}px)`;
|
||||
}
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
if (!horizontal) { row.style.transition = ""; row.style.transform = ""; return; }
|
||||
suppressClick = true;
|
||||
setTimeout(() => { suppressClick = false; }, 60);
|
||||
const dx = e.clientX - x0;
|
||||
const open = dx < -40 ? true : dx > 40 ? false : state.swipedTrackId === id;
|
||||
// Animate to the snap position by toggling the class (no DOM rebuild, so
|
||||
// it transitions smoothly from wherever the finger let go).
|
||||
closeOtherSwipes(row.closest(".track-wrap"));
|
||||
row.style.transition = "transform 0.22s cubic-bezier(0.22,1,0.36,1)";
|
||||
row.closest(".track-wrap").classList.toggle("swiped", open);
|
||||
row.style.transform = "";
|
||||
state.swipedTrackId = open ? id : null;
|
||||
};
|
||||
row.addEventListener("pointerup", end);
|
||||
row.addEventListener("pointercancel", end);
|
||||
});
|
||||
}
|
||||
|
||||
// Smoothly close any revealed row (optionally except `keep`), without a full
|
||||
// re-render so the close animates.
|
||||
function closeOtherSwipes(keep) {
|
||||
app.querySelectorAll(".track-wrap.swiped").forEach((w) => {
|
||||
if (w === keep) return;
|
||||
const r = w.querySelector(".track");
|
||||
if (r) { r.style.transition = "transform 0.22s cubic-bezier(0.22,1,0.36,1)"; r.style.transform = ""; }
|
||||
w.classList.remove("swiped");
|
||||
});
|
||||
}
|
||||
|
||||
function closeSwipe() {
|
||||
state.swipedTrackId = null;
|
||||
closeOtherSwipes(null);
|
||||
}
|
||||
|
||||
async function deleteTrack(id) {
|
||||
state.swipedTrackId = null;
|
||||
try {
|
||||
await fetch(`/api/jobs/${id}`, { method: "DELETE" });
|
||||
} catch (e) {
|
||||
console.warn("[mobile] delete failed:", e);
|
||||
}
|
||||
state.tracks = state.tracks.filter((t) => t.id !== id);
|
||||
if (!state.tracks.length) state.libState = "empty";
|
||||
if (state.current?.id === id) {
|
||||
if (engine) { engine.destroy(); engine = null; engineTrackId = null; engineReady = false; }
|
||||
state.current = null;
|
||||
state.playing = false;
|
||||
}
|
||||
toast("Track deleted");
|
||||
render();
|
||||
}
|
||||
|
||||
// Faders + waveform seek need live pointer drag, so they're wired imperatively
|
||||
// after each render (the rest of the UI uses click delegation below).
|
||||
function wireFaders() {
|
||||
app.querySelectorAll("[data-fader]").forEach((el) => {
|
||||
el.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
const id = el.dataset.id;
|
||||
const fill = el.querySelector(".fader-fill");
|
||||
const knob = el.querySelector(".fader-knob");
|
||||
const rect = el.getBoundingClientRect();
|
||||
const set = (cx) => {
|
||||
let v = (cx - rect.left) / rect.width;
|
||||
v = Math.max(0, Math.min(1, v));
|
||||
state.vols[id] = v;
|
||||
const pct = Math.round(v * 100) + "%";
|
||||
fill.style.width = pct;
|
||||
knob.style.left = pct;
|
||||
if (engine && laneActive(id)) engine.setGain(id, v);
|
||||
};
|
||||
set(e.clientX);
|
||||
el.setPointerCapture(e.pointerId);
|
||||
const mv = (ev) => set(ev.clientX);
|
||||
const up = () => {
|
||||
el.removeEventListener("pointermove", mv);
|
||||
el.removeEventListener("pointerup", up);
|
||||
};
|
||||
el.addEventListener("pointermove", mv);
|
||||
el.addEventListener("pointerup", up);
|
||||
});
|
||||
});
|
||||
|
||||
const speedSlider = app.querySelector("[data-speed]");
|
||||
if (speedSlider) {
|
||||
speedSlider.addEventListener("input", () => {
|
||||
const rate = parseFloat(speedSlider.value);
|
||||
state.speed = rate;
|
||||
const valEl = speedSlider.parentElement?.querySelector(".speed-row-val");
|
||||
if (valEl) valEl.textContent = `${rate % 1 === 0 ? rate.toFixed(1) : rate}x`;
|
||||
if (engine) engine.setPlaybackRate(rate);
|
||||
});
|
||||
}
|
||||
|
||||
const bars = app.querySelector("[data-seek]");
|
||||
if (bars) {
|
||||
bars.addEventListener("pointerdown", (e) => {
|
||||
if (!engine || !curDuration()) return;
|
||||
e.preventDefault();
|
||||
const rect = bars.getBoundingClientRect();
|
||||
const head = bars.querySelector(".playhead");
|
||||
const seek = (cx) => {
|
||||
const frac = Math.max(0, Math.min(1, (cx - rect.left) / rect.width));
|
||||
if (head) head.style.left = frac * 100 + "%";
|
||||
const cur = app.querySelector(".wave-times .cur");
|
||||
if (cur) cur.textContent = fmt(frac * curDuration());
|
||||
state.progress = frac;
|
||||
paintWaveProgress();
|
||||
seekToFraction(frac);
|
||||
};
|
||||
seek(e.clientX);
|
||||
bars.setPointerCapture(e.pointerId);
|
||||
const mv = (ev) => seek(ev.clientX);
|
||||
const up = () => {
|
||||
bars.removeEventListener("pointermove", mv);
|
||||
bars.removeEventListener("pointerup", up);
|
||||
};
|
||||
bars.addEventListener("pointermove", mv);
|
||||
bars.addEventListener("pointerup", up);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
app.addEventListener("click", (e) => {
|
||||
if (suppressClick) { suppressClick = false; return; }
|
||||
const t = e.target.closest("[data-action]");
|
||||
if (!t) return;
|
||||
ensureAudioCtx(); // any in-app tap unlocks audio on mobile
|
||||
const a = t.dataset.action;
|
||||
// A row is swiped open: any tap other than its Delete just closes it.
|
||||
if (state.swipedTrackId && a !== "delete") {
|
||||
closeSwipe();
|
||||
return;
|
||||
}
|
||||
switch (a) {
|
||||
case "delete":
|
||||
deleteTrack(t.dataset.id);
|
||||
return;
|
||||
case "tab":
|
||||
state.tab = t.dataset.tab;
|
||||
break;
|
||||
case "mixview":
|
||||
state.mixerView = t.dataset.view;
|
||||
break;
|
||||
case "prev":
|
||||
prevTrack();
|
||||
return;
|
||||
case "next":
|
||||
nextTrack();
|
||||
return;
|
||||
case "play":
|
||||
case "play-mini":
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
return;
|
||||
case "mute":
|
||||
state.muted[t.dataset.id] = !state.muted[t.dataset.id];
|
||||
applyMix();
|
||||
break;
|
||||
case "solo":
|
||||
state.solo[t.dataset.id] = !state.solo[t.dataset.id];
|
||||
applyMix();
|
||||
break;
|
||||
case "chip":
|
||||
state.selected[t.dataset.id] = !state.selected[t.dataset.id];
|
||||
break;
|
||||
case "qual":
|
||||
state.quality = t.dataset.q;
|
||||
break;
|
||||
case "filter":
|
||||
state.filter = t.dataset.filter;
|
||||
break;
|
||||
case "export":
|
||||
exportMix(t.dataset.kind || "wav");
|
||||
return;
|
||||
case "split":
|
||||
startExtraction();
|
||||
return;
|
||||
case "pick-file":
|
||||
document.getElementById("ext-file")?.click();
|
||||
return;
|
||||
case "open": {
|
||||
const track = state.tracks.find((x) => x.id === t.dataset.id);
|
||||
if (track) openTrack(track);
|
||||
return;
|
||||
}
|
||||
case "reload":
|
||||
loadLibrary();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
// URL field is uncontrolled-but-tracked: store keystrokes in state without
|
||||
// re-rendering (which would steal focus), so the value survives later renders.
|
||||
app.addEventListener("input", (e) => {
|
||||
if (e.target.id === "ext-url") state.extractUrl = e.target.value;
|
||||
});
|
||||
app.addEventListener("change", (e) => {
|
||||
if (e.target.id === "ext-file") {
|
||||
state.extractFile = e.target.files?.[0] || null;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
// Load the real library from /api/jobs (newest first). On success, seed the
|
||||
// Mixer with the most recent track if nothing is selected yet.
|
||||
async function loadLibrary() {
|
||||
state.libState = "loading";
|
||||
render();
|
||||
try {
|
||||
const jobs = await fetchJobs();
|
||||
state.tracks = jobs.map(jobToCard).sort((a, b) => b.createdAt - a.createdAt);
|
||||
state.libState = state.tracks.length ? "ready" : "empty";
|
||||
} catch (e) {
|
||||
console.warn("[mobile] failed to load library:", e);
|
||||
state.libState = "error";
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
render();
|
||||
loadLibrary();
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1"
|
||||
/>
|
||||
<meta name="theme-color" content="#0b0b0d" />
|
||||
<title>StemDeck</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/mobile/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- The whole UI is rendered by app.js into #app. This is a static
|
||||
visual shell on mock data (issue: mobile redesign). Screens and
|
||||
interactions are real; data is not yet wired to the API. -->
|
||||
<div id="app" class="m-app"></div>
|
||||
<script type="module" src="/mobile/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+15
File diff suppressed because one or more lines are too long
+160
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
// SoundTouch WSOLA time-stretcher - AudioWorkletProcessor
|
||||
// Adapted from SoundTouch C++ by Olli Parviainen. MIT License.
|
||||
// Self-contained; no imports required.
|
||||
|
||||
const SEQUENCE_MS = 82;
|
||||
const SEEK_MS = 28;
|
||||
const OVERLAP_MS = 12;
|
||||
|
||||
class FloatFifo {
|
||||
constructor() {
|
||||
this._d = new Float32Array(65536);
|
||||
this._r = 0;
|
||||
this._w = 0;
|
||||
}
|
||||
get avail() { return this._w - this._r; }
|
||||
clear() { this._r = this._w = 0; }
|
||||
peek(offset) { return this._d[this._r + offset]; }
|
||||
consume(n) { this._r = Math.min(this._r + n, this._w); }
|
||||
shift(dst, dstOff, n) {
|
||||
n = Math.min(n, this.avail);
|
||||
for (let i = 0; i < n; i++) dst[dstOff + i] = this._d[this._r++];
|
||||
return n;
|
||||
}
|
||||
push(src, srcOff, n) {
|
||||
this._ensureRoom(n);
|
||||
for (let i = 0; i < n; i++) this._d[this._w++] = src[srcOff + i];
|
||||
}
|
||||
_compact() {
|
||||
const av = this.avail;
|
||||
this._d.copyWithin(0, this._r, this._w);
|
||||
this._r = 0;
|
||||
this._w = av;
|
||||
}
|
||||
_ensureRoom(n) {
|
||||
if (this._r > 32768) this._compact();
|
||||
if (this._w + n > this._d.length) {
|
||||
this._compact();
|
||||
if (this._w + n > this._d.length) {
|
||||
const nd = new Float32Array(Math.max(this._d.length * 2, this._w + n + 4096));
|
||||
nd.set(this._d.subarray(0, this._w));
|
||||
this._d = nd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findBestOffset(ref, refLen, fifo, seekLen) {
|
||||
let bestOff = 0, bestCorr = -Infinity;
|
||||
for (let off = 0; off < seekLen; off++) {
|
||||
let corr = 0;
|
||||
for (let i = 0; i < refLen; i++) corr += ref[i] * fifo.peek(off + i);
|
||||
if (corr > bestCorr) { bestCorr = corr; bestOff = off; }
|
||||
}
|
||||
return bestOff;
|
||||
}
|
||||
|
||||
class SoundTouchProcessor extends AudioWorkletProcessor {
|
||||
static get parameterDescriptors() {
|
||||
return [{
|
||||
name: 'tempo',
|
||||
defaultValue: 1.0,
|
||||
minValue: 0.25,
|
||||
maxValue: 4.0,
|
||||
automationRate: 'k-rate',
|
||||
}];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sr = sampleRate;
|
||||
this._ovLen = Math.round(OVERLAP_MS * sr / 1000);
|
||||
this._seekLen = Math.round(SEEK_MS * sr / 1000);
|
||||
this._seqLen = Math.round(SEQUENCE_MS * sr / 1000);
|
||||
this._midLen = this._seqLen - 2 * this._ovLen;
|
||||
|
||||
this._inL = new FloatFifo();
|
||||
this._inR = new FloatFifo();
|
||||
this._outL = new FloatFifo();
|
||||
this._outR = new FloatFifo();
|
||||
|
||||
// Overlap carry-over buffers (crossfade region saved from previous sequence)
|
||||
this._carryL = new Float32Array(this._ovLen);
|
||||
this._carryR = new Float32Array(this._ovLen);
|
||||
|
||||
// Pre-allocated temp output (seqLen - ovLen samples per sequence max)
|
||||
const outPerSeq = this._ovLen + this._midLen;
|
||||
this._tmpL = new Float32Array(outPerSeq);
|
||||
this._tmpR = new Float32Array(outPerSeq);
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const inp = inputs[0];
|
||||
const outp = outputs[0];
|
||||
const frames = 128;
|
||||
const tempo = parameters.tempo[0];
|
||||
|
||||
const inL = inp?.[0] || new Float32Array(frames);
|
||||
const inR = inp?.[1] || inL;
|
||||
const outL = outp[0];
|
||||
const outR = outp[1] || outL;
|
||||
|
||||
// Passthrough at 1.0 - zero DSP cost
|
||||
if (Math.abs(tempo - 1.0) < 0.001) {
|
||||
outL.set(inL);
|
||||
if (outR !== outL) outR.set(inR);
|
||||
return true;
|
||||
}
|
||||
|
||||
this._inL.push(inL, 0, frames);
|
||||
this._inR.push(inR, 0, frames);
|
||||
|
||||
const needed = this._ovLen + this._seekLen + this._seqLen;
|
||||
while (this._inL.avail >= needed) this._processSeq(tempo);
|
||||
|
||||
const got = this._outL.shift(outL, 0, frames);
|
||||
this._outR.shift(outR, 0, frames);
|
||||
for (let i = got; i < frames; i++) { outL[i] = 0; if (outR !== outL) outR[i] = 0; }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_processSeq(tempo) {
|
||||
const ovLen = this._ovLen;
|
||||
const midLen = this._midLen;
|
||||
const seqLen = this._seqLen;
|
||||
const outLen = ovLen + midLen;
|
||||
|
||||
const bestOff = findBestOffset(this._carryL, ovLen, this._inL, this._seekLen);
|
||||
|
||||
// Crossfade carry-over with the new sequence start
|
||||
for (let i = 0; i < ovLen; i++) {
|
||||
const w = i / ovLen;
|
||||
this._tmpL[i] = this._carryL[i] * (1 - w) + this._inL.peek(bestOff + i) * w;
|
||||
this._tmpR[i] = this._carryR[i] * (1 - w) + this._inR.peek(bestOff + i) * w;
|
||||
}
|
||||
|
||||
// Copy middle section verbatim
|
||||
for (let i = 0; i < midLen; i++) {
|
||||
this._tmpL[ovLen + i] = this._inL.peek(bestOff + ovLen + i);
|
||||
this._tmpR[ovLen + i] = this._inR.peek(bestOff + ovLen + i);
|
||||
}
|
||||
|
||||
// Save last ovLen samples as new carry-over for next crossfade
|
||||
for (let i = 0; i < ovLen; i++) {
|
||||
this._carryL[i] = this._inL.peek(bestOff + ovLen + midLen + i);
|
||||
this._carryR[i] = this._inR.peek(bestOff + ovLen + midLen + i);
|
||||
}
|
||||
|
||||
this._outL.push(this._tmpL, 0, outLen);
|
||||
this._outR.push(this._tmpR, 0, outLen);
|
||||
|
||||
// Advance input: tempo controls how many input samples map to one output sequence
|
||||
const advance = Math.round((seqLen - ovLen) * tempo) + bestOff;
|
||||
this._inL.consume(advance);
|
||||
this._inR.consume(advance);
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('soundtouch-processor', SoundTouchProcessor);
|
||||
Reference in New Issue
Block a user