From 37daf447aa6a575fa7032fa109338093df2ff768 Mon Sep 17 00:00:00 2001 From: Rafiuzzaman Khan Date: Wed, 15 Apr 2026 23:43:10 -0400 Subject: [PATCH 1/5] add feature/transition-modal --- src/audio/Scheduler.ts | 21 +- src/components/Timeline/Timeline.tsx | 134 ++++++++++ src/components/Timeline/TimelineClip.tsx | 9 + src/components/TransitionModal.tsx | 139 ++++++++++ src/styles/transition-modal.css | 318 +++++++++++++++++++++++ types/Fade.ts | 1 + 6 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 src/components/TransitionModal.tsx create mode 100644 src/styles/transition-modal.css diff --git a/src/audio/Scheduler.ts b/src/audio/Scheduler.ts index 10a89ff..f1eeba1 100644 --- a/src/audio/Scheduler.ts +++ b/src/audio/Scheduler.ts @@ -2,6 +2,8 @@ import { FadeType } from '../../types/Fade'; import type { ID, Seconds, Fade, ScheduledEntry, SfxClip, ActiveNode } from './types'; import type { BufferCache } from './BufferCache'; +const EQUAL_POWER_CURVE_LENGTH = 128; + /** how often the scheduling loop fires (ms) */ const LOOKAHEAD_INTERVAL_MS = 25; @@ -383,10 +385,25 @@ export class Scheduler { gainNode.gain.setValueAtTime(Math.max(startGain, 0.0001), fadeStartCtx); - if (fade.type === FadeType.LINEAR) { + if (fade.type === FadeType.EQUAL_POWER) { + const duration = fadeEndCtx - fadeStartCtx; + if (duration > 0) { + const curve = new Float32Array(EQUAL_POWER_CURVE_LENGTH); + const isFadeOut = fade.endGain < fade.startGain; + const safeEnd = Math.max(fade.endGain, 0.0001); + const safeStart = Math.max(startGain, 0.0001); + for (let i = 0; i < EQUAL_POWER_CURVE_LENGTH; i++) { + const t = i / (EQUAL_POWER_CURVE_LENGTH - 1); + curve[i] = isFadeOut + ? safeStart * Math.cos(t * Math.PI * 0.5) + : safeEnd * Math.sin(t * Math.PI * 0.5); + curve[i] = Math.max(curve[i], 0.0001); + } + gainNode.gain.setValueCurveAtTime(curve, fadeStartCtx, duration); + } + } else if (fade.type === FadeType.LINEAR) { gainNode.gain.linearRampToValueAtTime(Math.max(fade.endGain, 0.0001), fadeEndCtx); } else { - // exponentialRamp can't reach 0, so we use an epsilon gainNode.gain.exponentialRampToValueAtTime(Math.max(fade.endGain, 0.0001), fadeEndCtx); } } diff --git a/src/components/Timeline/Timeline.tsx b/src/components/Timeline/Timeline.tsx index 69b0dcf..7d5dc07 100644 --- a/src/components/Timeline/Timeline.tsx +++ b/src/components/Timeline/Timeline.tsx @@ -1,8 +1,11 @@ import { useRef, useEffect, useCallback, useState } from 'react'; +import { FadeType } from '../../../types/Fade'; +import type { FadeType as FadeTypeValue } from '../../../types/Fade'; import { useAudioEngine } from '../../audio/UseAudioEngine'; import TimelineControls from './TimelineControls'; import TimelineTicks from './TimelineTicks'; import TimelineClip from './TimelineClip'; +import TransitionModal from '../TransitionModal'; import '../../styles/timeline.css'; // Constants @@ -66,6 +69,15 @@ export default function Timeline() { // without needing to be recreated on every render. const dragRef = useRef(null); + // Transition modal state + const [transitionTarget, setTransitionTarget] = useState<{ + fromEntryId: string; + fromTitle: string; + toEntryId: string; + toTitle: string; + maxDuration: number; + } | null>(null); + const onClipMouseDown = useCallback( (e: React.MouseEvent, entryId: string) => { e.preventDefault(); @@ -159,6 +171,104 @@ export default function Timeline() { [engine, timeline], ); + const onClipContextMenu = useCallback( + (e: React.MouseEvent, entryId: string) => { + e.preventDefault(); + e.stopPropagation(); + + const idx = timeline.findIndex((en) => en.entryId === entryId); + if (idx === -1 || timeline.length < 2) return; + + const entry = timeline[idx]; + const scrollLeft = scrollRef.current?.scrollLeft ?? 0; + const rectLeft = scrollRef.current?.getBoundingClientRect().left ?? 0; + const clickCanvasPx = e.clientX - rectLeft + scrollLeft; + const clipLeftPx = entry.absoluteStart * PX_PER_SEC; + const clipWidthPx = (entry.absoluteEnd - entry.absoluteStart) * PX_PER_SEC; + const clickedLeftHalf = clickCanvasPx - clipLeftPx < clipWidthPx / 2; + + let fromIdx: number; + let toIdx: number; + + if (clickedLeftHalf && idx > 0) { + fromIdx = idx - 1; + toIdx = idx; + } else if (!clickedLeftHalf && idx < timeline.length - 1) { + fromIdx = idx; + toIdx = idx + 1; + } else if (idx > 0) { + fromIdx = idx - 1; + toIdx = idx; + } else { + fromIdx = idx; + toIdx = idx + 1; + } + + const fromEntry = timeline[fromIdx]; + const toEntry = timeline[toIdx]; + const maxDur = Math.min( + fromEntry.absoluteEnd - fromEntry.absoluteStart, + toEntry.absoluteEnd - toEntry.absoluteStart, + ); + + setTransitionTarget({ + fromEntryId: fromEntry.entryId, + fromTitle: fromEntry.title, + toEntryId: toEntry.entryId, + toTitle: toEntry.title, + maxDuration: maxDur, + }); + }, + [timeline], + ); + + const handleTransitionApply = useCallback( + (duration: number, fadeOutType: FadeTypeValue, fadeInType: FadeTypeValue) => { + if (!transitionTarget) return; + engine.playlist.setTransition( + transitionTarget.fromEntryId, + transitionTarget.toEntryId, + duration, + fadeOutType, + fadeInType, + ); + setTransitionTarget(null); + }, + [engine, transitionTarget], + ); + + const handleTransitionRemove = useCallback(() => { + if (!transitionTarget) return; + engine.playlist.removeTransition( + transitionTarget.fromEntryId, + transitionTarget.toEntryId, + ); + setTransitionTarget(null); + }, [engine, transitionTarget]); + + const onOverlapClick = useCallback( + (entryId: string) => { + const idx = timeline.findIndex((en) => en.entryId === entryId); + if (idx <= 0) return; + + const fromEntry = timeline[idx - 1]; + const toEntry = timeline[idx]; + const maxDur = Math.min( + fromEntry.absoluteEnd - fromEntry.absoluteStart, + toEntry.absoluteEnd - toEntry.absoluteStart, + ); + + setTransitionTarget({ + fromEntryId: fromEntry.entryId, + fromTitle: fromEntry.title, + toEntryId: toEntry.entryId, + toTitle: toEntry.title, + maxDuration: maxDur, + }); + }, + [timeline], + ); + useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (!dragRef.current || !scrollRef.current) return; @@ -246,6 +356,8 @@ export default function Timeline() { isDragging={isDragging} overlapWidthPx={overlapWidthPx} onMouseDown={onClipMouseDown} + onContextMenu={onClipContextMenu} + onOverlapClick={onOverlapClick} /> ); })} @@ -260,6 +372,28 @@ export default function Timeline() { + + {transitionTarget && (() => { + const existing = engine.playlist.getTransitions().find( + (t) => + t.fromEntryId === transitionTarget.fromEntryId && + t.toEntryId === transitionTarget.toEntryId, + ); + return ( + setTransitionTarget(null)} + /> + ); + })()} ); } diff --git a/src/components/Timeline/TimelineClip.tsx b/src/components/Timeline/TimelineClip.tsx index d6e72f7..edee296 100644 --- a/src/components/Timeline/TimelineClip.tsx +++ b/src/components/Timeline/TimelineClip.tsx @@ -13,6 +13,8 @@ interface TimelineClipProps { isDragging: boolean; overlapWidthPx: number; onMouseDown: (e: React.MouseEvent, entryId: string) => void; + onContextMenu: (e: React.MouseEvent, entryId: string) => void; + onOverlapClick: (entryId: string) => void; } export default function TimelineClip({ @@ -25,6 +27,8 @@ export default function TimelineClip({ isDragging, overlapWidthPx, onMouseDown, + onContextMenu, + onOverlapClick, }: TimelineClipProps) { const clipDuration = widthPx / pxPerSecond; // widthPx = duration * pxPerSecond @@ -33,6 +37,7 @@ export default function TimelineClip({
onMouseDown(e, entryId)} + onContextMenu={(e) => onContextMenu(e, entryId)} style={{ left: leftPx, top: HEADER_HEIGHT + 8, @@ -58,6 +63,10 @@ export default function TimelineClip({ cursor: isDragging ? 'grabbing' : 'pointer', }} title={`Crossfade: ${formatDuration(overlapWidthPx / 4)}`} + onClick={(e) => { + e.stopPropagation(); + onOverlapClick(entryId); + }} /> )} diff --git a/src/components/TransitionModal.tsx b/src/components/TransitionModal.tsx new file mode 100644 index 0000000..c94b79c --- /dev/null +++ b/src/components/TransitionModal.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect, type CSSProperties } from 'react'; +import { FadeType } from '../../types/Fade'; +import type { FadeType as FadeTypeValue } from '../../types/Fade'; +import '../styles/transition-modal.css'; + +interface TransitionModalProps { + fromTitle: string; + toTitle: string; + currentDuration: number; + currentFadeOutType: FadeTypeValue; + currentFadeInType: FadeTypeValue; + maxDuration: number; + hasExistingTransition: boolean; + onApply: (duration: number, fadeOutType: FadeTypeValue, fadeInType: FadeTypeValue) => void; + onRemove: () => void; + onClose: () => void; +} + +const FADE_OPTIONS: { value: FadeTypeValue; label: string; description: string }[] = [ + { value: FadeType.LINEAR, label: 'Linear', description: 'Constant-rate crossfade' }, + { value: FadeType.EXPONENTIAL, label: 'Exponential', description: 'Fast attack, slow tail' }, + { value: FadeType.EQUAL_POWER, label: 'Equal Power', description: 'Natural-sounding, no volume dip' }, +]; + +export default function TransitionModal({ + fromTitle, + toTitle, + currentDuration, + currentFadeOutType, + maxDuration, + hasExistingTransition, + onApply, + onRemove, + onClose, +}: TransitionModalProps) { + const [fadeType, setFadeType] = useState(currentFadeOutType); + const [duration, setDuration] = useState(currentDuration); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [onClose]); + + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = 'unset'; + }; + }, []); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + const handleApply = () => { + onApply(duration, fadeType, fadeType); + }; + + const clampedMax = Math.max(maxDuration, 0.5); + const durationPercent = ((duration - 0.5) / (clampedMax - 0.5)) * 100; + + return ( +
+
+
+

Transition

+ +
+ +
+
+ {fromTitle} + → + {toTitle} +
+ +
+

Transition Type

+
+ {FADE_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+

+ Duration + {duration.toFixed(1)}s +

+ setDuration(Number(e.target.value))} + aria-label="Transition duration" + style={{ '--duration-percent': `${durationPercent}%` } as CSSProperties} + /> +
+ 0.5s + {clampedMax.toFixed(1)}s +
+
+
+ +
+ {hasExistingTransition && ( + + )} + +
+
+
+ ); +} diff --git a/src/styles/transition-modal.css b/src/styles/transition-modal.css new file mode 100644 index 0000000..293cf5a --- /dev/null +++ b/src/styles/transition-modal.css @@ -0,0 +1,318 @@ +/* Transition Modal Component Styles */ +@import './design-system.css'; + +.transition-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-modal-backdrop); + backdrop-filter: blur(5px); + animation: transitionFadeIn var(--transition-base); +} + +@keyframes transitionFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.transition-modal { + background: linear-gradient(135deg, #0a0e27 0%, #0f1829 100%); + border: 2px solid #da8707; + border-radius: var(--br-2xl); + box-shadow: + 0 20px 60px rgba(218, 135, 7, 0.25), + 0 0 30px rgba(218, 135, 7, 0.15); + max-width: 460px; + width: 90%; + display: flex; + flex-direction: column; + z-index: var(--z-modal); + animation: transitionSlideUp var(--transition-base); +} + +@keyframes transitionSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Header */ + +.transition-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5) var(--space-6); + border-bottom: 1px solid #da8707; + flex-shrink: 0; +} + +.transition-modal-title { + font-size: var(--fs-xl); + font-weight: var(--fw-bold); + color: #ffffff; + margin: 0; + letter-spacing: var(--ls-wide); +} + +.transition-modal-close { + width: 36px; + height: 36px; + border: none; + background: transparent; + color: #da8707; + font-size: var(--fs-lg); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--br-lg); + transition: all var(--transition-base); +} + +.transition-modal-close:hover { + color: #f5a834; + background-color: rgba(218, 135, 7, 0.1); +} + +/* Content */ + +.transition-modal-content { + padding: var(--space-5) var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.transition-modal-songs { + display: flex; + align-items: center; + gap: var(--space-3); + justify-content: center; + padding: var(--space-3) var(--space-4); + background: rgba(218, 135, 7, 0.08); + border-radius: var(--br-lg); + border: 1px solid rgba(218, 135, 7, 0.2); +} + +.transition-modal-song-name { + font-size: var(--fs-sm); + font-weight: var(--fw-semibold); + color: #f5a834; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transition-modal-arrow { + font-size: var(--fs-lg); + color: #da8707; + flex-shrink: 0; +} + +/* Sections */ + +.transition-section { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.transition-section-title { + font-size: var(--fs-sm); + font-weight: var(--fw-bold); + color: #f5a834; + margin: 0; + letter-spacing: var(--ls-wider); + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: space-between; +} + +.transition-duration-value { + font-size: var(--fs-base); + color: #ffffff; + font-weight: var(--fw-bold); + font-family: var(--font-mono); +} + +/* Type selector cards */ + +.transition-type-grid { + display: flex; + gap: var(--space-2); +} + +.transition-type-card { + flex: 1; + padding: var(--space-3) var(--space-2); + background: rgba(255, 255, 255, 0.03); + border: 2px solid rgba(218, 135, 7, 0.25); + border-radius: var(--br-lg); + cursor: pointer; + transition: all var(--transition-base); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + text-align: center; +} + +.transition-type-card:hover { + border-color: rgba(245, 168, 52, 0.5); + background: rgba(218, 135, 7, 0.08); +} + +.transition-type-card.selected { + border-color: #f5a834; + background: rgba(218, 135, 7, 0.15); + box-shadow: 0 0 12px rgba(245, 168, 52, 0.2); +} + +.transition-type-label { + font-size: var(--fs-sm); + font-weight: var(--fw-bold); + color: #ffffff; +} + +.transition-type-card.selected .transition-type-label { + color: #f5a834; +} + +.transition-type-desc { + font-size: var(--fs-xs); + color: #808080; + line-height: var(--lh-normal); +} + +.transition-type-card.selected .transition-type-desc { + color: #b0b0b0; +} + +/* Duration slider */ + +.transition-duration-slider { + --duration-percent: 50%; + width: 100%; + height: 12px; + border-radius: 999px; + appearance: none; + cursor: pointer; + border: none; + padding: 0; + background: + linear-gradient( + to right, + #da8707 0%, + #ffb85e var(--duration-percent), + rgba(255, 184, 94, 0.18) var(--duration-percent), + rgba(255, 184, 94, 0.18) 100% + ); + box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.25); +} + +.transition-duration-slider::-webkit-slider-runnable-track { + height: 6px; + background: transparent; +} + +.transition-duration-slider::-webkit-slider-thumb { + appearance: none; + width: 12px; + height: 6px; + background: transparent; + border: none; + box-shadow: none; + cursor: pointer; +} + +.transition-duration-slider::-moz-range-track { + height: 6px; + border: none; + background: transparent; +} + +.transition-duration-slider::-moz-range-thumb { + width: 12px; + height: 6px; + background: transparent; + border: none; + box-shadow: none; + cursor: pointer; +} + +.transition-duration-labels { + display: flex; + justify-content: space-between; + font-size: var(--fs-xs); + color: #808080; +} + +/* Footer */ + +.transition-modal-footer { + padding: var(--space-4) var(--space-6); + border-top: 1px solid #da8707; + display: flex; + gap: var(--space-3); + flex-shrink: 0; +} + +.transition-modal-btn-apply { + flex: 1; + padding: var(--space-3) var(--space-5); + background: linear-gradient(135deg, #da8707 0%, #f5a834 100%); + border: 2px solid #f5a834; + color: #0f1829; + font-size: var(--fs-base); + font-weight: var(--fw-bold); + border-radius: var(--br-lg); + cursor: pointer; + transition: all var(--transition-base); +} + +.transition-modal-btn-apply:hover { + background: linear-gradient(135deg, #f5a834 0%, #ffb85e 100%); + border-color: #33ffbb; + box-shadow: 0 0 20px rgba(0, 255, 153, 0.4); + transform: scale(1.02); +} + +.transition-modal-btn-apply:active { + transform: scale(0.98); +} + +.transition-modal-btn-remove { + padding: var(--space-3) var(--space-4); + background: transparent; + border: 2px solid rgba(234, 67, 53, 0.5); + color: #ea4335; + font-size: var(--fs-sm); + font-weight: var(--fw-semibold); + border-radius: var(--br-lg); + cursor: pointer; + transition: all var(--transition-base); +} + +.transition-modal-btn-remove:hover { + border-color: #ea4335; + background: rgba(234, 67, 53, 0.1); + box-shadow: 0 0 12px rgba(234, 67, 53, 0.3); +} + +.transition-modal-btn-remove:active { + transform: scale(0.98); +} diff --git a/types/Fade.ts b/types/Fade.ts index 6bd7e00..f989cb3 100644 --- a/types/Fade.ts +++ b/types/Fade.ts @@ -3,6 +3,7 @@ import type {Seconds, Gain} from "./UtilTypes" export const FadeType = { LINEAR: 0, EXPONENTIAL: 1, + EQUAL_POWER: 2, } as const; export type FadeType = (typeof FadeType)[keyof typeof FadeType]; From e112ca20e6273b5621488eea10a766702938ac21 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 03:45:44 +0000 Subject: [PATCH 2/5] style: auto-fix formatting and lint issues --- src/components/Timeline/Timeline.tsx | 50 ++++++++++++++-------------- src/components/TransitionModal.tsx | 6 +++- src/styles/transition-modal.css | 23 +++++++------ 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/components/Timeline/Timeline.tsx b/src/components/Timeline/Timeline.tsx index 7d5dc07..5e61f80 100644 --- a/src/components/Timeline/Timeline.tsx +++ b/src/components/Timeline/Timeline.tsx @@ -239,10 +239,7 @@ export default function Timeline() { const handleTransitionRemove = useCallback(() => { if (!transitionTarget) return; - engine.playlist.removeTransition( - transitionTarget.fromEntryId, - transitionTarget.toEntryId, - ); + engine.playlist.removeTransition(transitionTarget.fromEntryId, transitionTarget.toEntryId); setTransitionTarget(null); }, [engine, transitionTarget]); @@ -373,27 +370,30 @@ export default function Timeline() {
- {transitionTarget && (() => { - const existing = engine.playlist.getTransitions().find( - (t) => - t.fromEntryId === transitionTarget.fromEntryId && - t.toEntryId === transitionTarget.toEntryId, - ); - return ( - setTransitionTarget(null)} - /> - ); - })()} + {transitionTarget && + (() => { + const existing = engine.playlist + .getTransitions() + .find( + (t) => + t.fromEntryId === transitionTarget.fromEntryId && + t.toEntryId === transitionTarget.toEntryId, + ); + return ( + setTransitionTarget(null)} + /> + ); + })()} ); } diff --git a/src/components/TransitionModal.tsx b/src/components/TransitionModal.tsx index c94b79c..6d337e2 100644 --- a/src/components/TransitionModal.tsx +++ b/src/components/TransitionModal.tsx @@ -19,7 +19,11 @@ interface TransitionModalProps { const FADE_OPTIONS: { value: FadeTypeValue; label: string; description: string }[] = [ { value: FadeType.LINEAR, label: 'Linear', description: 'Constant-rate crossfade' }, { value: FadeType.EXPONENTIAL, label: 'Exponential', description: 'Fast attack, slow tail' }, - { value: FadeType.EQUAL_POWER, label: 'Equal Power', description: 'Natural-sounding, no volume dip' }, + { + value: FadeType.EQUAL_POWER, + label: 'Equal Power', + description: 'Natural-sounding, no volume dip', + }, ]; export default function TransitionModal({ diff --git a/src/styles/transition-modal.css b/src/styles/transition-modal.css index 293cf5a..0d28495 100644 --- a/src/styles/transition-modal.css +++ b/src/styles/transition-modal.css @@ -17,8 +17,12 @@ } @keyframes transitionFadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .transition-modal { @@ -213,14 +217,13 @@ cursor: pointer; border: none; padding: 0; - background: - linear-gradient( - to right, - #da8707 0%, - #ffb85e var(--duration-percent), - rgba(255, 184, 94, 0.18) var(--duration-percent), - rgba(255, 184, 94, 0.18) 100% - ); + background: linear-gradient( + to right, + #da8707 0%, + #ffb85e var(--duration-percent), + rgba(255, 184, 94, 0.18) var(--duration-percent), + rgba(255, 184, 94, 0.18) 100% + ); box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.25); } From 7519a3f149722540b4ac47692f2195d60af1f9fa Mon Sep 17 00:00:00 2001 From: hashmapenjoyer Date: Thu, 16 Apr 2026 13:15:43 -0400 Subject: [PATCH 3/5] corrected enum and added fade-type guards --- src/audio/PlaylistManager.ts | 5 +++-- types/Fade.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/audio/PlaylistManager.ts b/src/audio/PlaylistManager.ts index 89f095c..e624fdb 100644 --- a/src/audio/PlaylistManager.ts +++ b/src/audio/PlaylistManager.ts @@ -1,4 +1,5 @@ import type { Fade } from '../../types/Fade'; +import { FadeType } from '../../types/Fade'; import type { ID, Seconds, PlaylistEntry, Transition, ScheduledEntry } from './types'; /** @@ -118,7 +119,7 @@ export class PlaylistManager { if (i > 0) { const prevEntry = this.entries[i - 1]; const incomingTransition = this.getTransitionBetween(prevEntry.id, entry.id); - if (incomingTransition) { + if (incomingTransition && incomingTransition.fadeInType !== FadeType.NONE) { fades.push({ type: incomingTransition.fadeInType, startOffset: 0, @@ -133,7 +134,7 @@ export class PlaylistManager { if (i < this.entries.length - 1) { const nextEntry = this.entries[i + 1]; const outgoingTransition = this.getTransitionBetween(entry.id, nextEntry.id); - if (outgoingTransition) { + if (outgoingTransition && outgoingTransition.fadeOutType !== FadeType.NONE) { fades.push({ type: outgoingTransition.fadeOutType, startOffset: entry.duration - outgoingTransition.duration, diff --git a/types/Fade.ts b/types/Fade.ts index f989cb3..5e28ec7 100644 --- a/types/Fade.ts +++ b/types/Fade.ts @@ -1,9 +1,10 @@ import type {Seconds, Gain} from "./UtilTypes" export const FadeType = { - LINEAR: 0, - EXPONENTIAL: 1, - EQUAL_POWER: 2, + NONE: 0, + LINEAR: 1, + EXPONENTIAL: 2, + EQUAL_POWER: 3, } as const; export type FadeType = (typeof FadeType)[keyof typeof FadeType]; From 6c7b52ef52070c7624e4484e1346913bbc95c376 Mon Sep 17 00:00:00 2001 From: hashmapenjoyer Date: Thu, 16 Apr 2026 13:46:46 -0400 Subject: [PATCH 4/5] added NONE fade type to the modal --- src/components/TransitionModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/TransitionModal.tsx b/src/components/TransitionModal.tsx index 6d337e2..a7fbab1 100644 --- a/src/components/TransitionModal.tsx +++ b/src/components/TransitionModal.tsx @@ -17,6 +17,7 @@ interface TransitionModalProps { } const FADE_OPTIONS: { value: FadeTypeValue; label: string; description: string }[] = [ + { value: FadeType.NONE, label: 'None', description: 'Hard cut, no crossfade' }, { value: FadeType.LINEAR, label: 'Linear', description: 'Constant-rate crossfade' }, { value: FadeType.EXPONENTIAL, label: 'Exponential', description: 'Fast attack, slow tail' }, { From a10c548830791bd2669533b6934626d5c13d3a28 Mon Sep 17 00:00:00 2001 From: hashmapenjoyer Date: Thu, 16 Apr 2026 21:13:14 -0400 Subject: [PATCH 5/5] make none fade type info actually display correctly --- src/components/TransitionModal.tsx | 162 ++++++++++++++++++++++++++++- src/styles/transition-modal.css | 18 +++- 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/components/TransitionModal.tsx b/src/components/TransitionModal.tsx index a7fbab1..674ed09 100644 --- a/src/components/TransitionModal.tsx +++ b/src/components/TransitionModal.tsx @@ -17,7 +17,7 @@ interface TransitionModalProps { } const FADE_OPTIONS: { value: FadeTypeValue; label: string; description: string }[] = [ - { value: FadeType.NONE, label: 'None', description: 'Hard cut, no crossfade' }, + { value: FadeType.NONE, label: 'None', description: 'Clips overlap, no gain curve' }, { value: FadeType.LINEAR, label: 'Linear', description: 'Constant-rate crossfade' }, { value: FadeType.EXPONENTIAL, label: 'Exponential', description: 'Fast attack, slow tail' }, { @@ -27,6 +27,164 @@ const FADE_OPTIONS: { value: FadeTypeValue; label: string; description: string } }, ]; +// ---- Curve helpers ---- + +function fadeGain(type: FadeTypeValue, t: number): number { + switch (type) { + case FadeType.LINEAR: + return 1 - t; + case FadeType.EXPONENTIAL: + return Math.pow(1 - t, 2); + case FadeType.EQUAL_POWER: + return Math.cos((t * Math.PI) / 2); + default: // NONE + return 1; + } +} + +function curvePath( + type: FadeTypeValue, + w: number, + h: number, + invert: boolean, + pad: number, + steps: number, +): string { + const inner = h - 2 * pad; + return Array.from({ length: steps + 1 }, (_, i) => { + const t = i / steps; + const g = invert ? fadeGain(type, 1 - t) : fadeGain(type, t); + const x = (t * w).toFixed(1); + const y = (pad + (1 - g) * inner).toFixed(1); + return `${i === 0 ? 'M' : 'L'} ${x},${y}`; + }).join(' '); +} + +// ---- SVG components ---- + +function MiniCurveIcon({ type }: { type: FadeTypeValue }) { + const w = 48, + h = 26, + pad = 3, + steps = 40; + + if (type === FadeType.NONE) { + const overlapStart = w / 3; + const overlapEnd = (w * 2) / 3; + return ( + + ); + } + + const outPath = curvePath(type, w, h, false, pad, steps); + const inPath = curvePath(type, w, h, true, pad, steps); + + return ( + + ); +} + +function CrossfadePreview({ type }: { type: FadeTypeValue }) { + const w = 280, + h = 70, + pad = 6, + steps = 60; + + if (type === FadeType.NONE) { + const overlapStart = w / 3; + const overlapEnd = (w * 2) / 3; + const lineY = pad + 8; + return ( + + ); + } + + const outFull = curvePath(type, w, h, false, pad, steps); + const inFull = curvePath(type, w, h, true, pad, steps); + const aFill = `${outFull} L ${w},${h - pad} L 0,${h - pad} Z`; + const bFill = `${inFull} L ${w},${h - pad} L 0,${h - pad} Z`; + + return ( + + ); +} + export default function TransitionModal({ fromTitle, toTitle, @@ -98,11 +256,13 @@ export default function TransitionModal({ className={`transition-type-card${fadeType === opt.value ? ' selected' : ''}`} onClick={() => setFadeType(opt.value)} > + {opt.label} {opt.description} ))} +
diff --git a/src/styles/transition-modal.css b/src/styles/transition-modal.css index 0d28495..963dfd0 100644 --- a/src/styles/transition-modal.css +++ b/src/styles/transition-modal.css @@ -32,7 +32,7 @@ box-shadow: 0 20px 60px rgba(218, 135, 7, 0.25), 0 0 30px rgba(218, 135, 7, 0.15); - max-width: 460px; + max-width: 560px; width: 90%; display: flex; flex-direction: column; @@ -153,6 +153,22 @@ font-family: var(--font-mono); } +/* Curve visualizations */ + +.fade-mini-icon { + width: 48px; + height: 26px; + flex-shrink: 0; +} + +.fade-crossfade-preview { + width: 100%; + height: auto; + display: block; + border-radius: var(--br-lg); + overflow: hidden; +} + /* Type selector cards */ .transition-type-grid {