a30cfb39-1b53-4097-990f-27d01fb42f1f
|
|
@ -17,8 +17,9 @@ import { VisualStage } from '@/components/ava/visual-stage'
|
|||
import { ControlPanel } from '@/components/ava/control-panel'
|
||||
import { FooterBar } from '@/components/ava/footer-bar'
|
||||
import { QueueBar } from '@/components/ava/queue-bar'
|
||||
import { applyScriptText, tapBeat } from '@/components/ava/audio-source'
|
||||
import { applyScriptText, setBootScriptSnapshot, tapBeat } from '@/components/ava/audio-source'
|
||||
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
|
||||
import { releaseWakeLock, syncWakeLock, watchWakeLockVisibility } from '@/lib/ava/wake-lock'
|
||||
import {
|
||||
lsGetString,
|
||||
readStoredBandConfig,
|
||||
|
|
@ -105,6 +106,7 @@ function useInitialLoad() {
|
|||
// (Zai-Magic "Bass Bloom" — the megabuf particle bloom that remembers
|
||||
// every beat's hue) → any Zai-Magic preset → template
|
||||
let text: string | null = lsGetString('ava.defaultScript')
|
||||
const usedSavedDefault = text !== null && text.trim() !== ''
|
||||
let startupPreset: (typeof presetsList)[number] | undefined
|
||||
if (!text) {
|
||||
startupPreset =
|
||||
|
|
@ -117,6 +119,12 @@ function useInitialLoad() {
|
|||
}
|
||||
if (!text) text = STARTER_TEMPLATE
|
||||
applyScriptText(text)
|
||||
// v7.11: remember the untouched startup script — the Demo showcase
|
||||
// only auto-engages while the editor still matches it (any difference
|
||||
// is the visitor's own material). A SAVED default is the visitor's
|
||||
// own material from the start, so it counts as their choice.
|
||||
setBootScriptSnapshot(text)
|
||||
if (usedSavedDefault) store.markManualChoice()
|
||||
if (startupPreset) store.setActivePresetId(startupPreset.id)
|
||||
|
||||
// club mode: bring back the random-preset timer if it was on
|
||||
|
|
@ -146,10 +154,28 @@ function useSpaceToTap() {
|
|||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* v7.11 — keep the phone's screen on while music is playing. Any source but
|
||||
* idle holds a screen wake lock (see lib/ava/wake-lock.ts); the moment the
|
||||
* engine goes idle the lock is released and the device's normal inactivity
|
||||
* dimming / sleep takes over again. Browsers silently drop the sentinel when
|
||||
* the page is hidden, so a visibilitychange watcher re-acquires it on return
|
||||
* while music is still going. Unsupported browsers degrade silently.
|
||||
*/
|
||||
function useWakeLockOnPlayback() {
|
||||
const source = useAvaStore((s) => s.source)
|
||||
useEffect(() => {
|
||||
syncWakeLock(source !== 'idle')
|
||||
}, [source])
|
||||
useEffect(() => watchWakeLockVisibility(), [])
|
||||
useEffect(() => () => releaseWakeLock(), [])
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
useInitialLoad()
|
||||
useBeatWatcher()
|
||||
useSpaceToTap()
|
||||
useWakeLockOnPlayback()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:h-screen lg:overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { getEngine } from '@/lib/ava/engine'
|
|||
import type { TapResult } from '@/lib/ava/beat'
|
||||
import { validateScript } from '@/lib/ava/script'
|
||||
import type { ScriptError } from '@/lib/ava/script'
|
||||
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
|
||||
import { DEMO_TRACK_COUNT, demoTrackTitle } from '@/lib/ava/audio'
|
||||
import { useAvaStore } from '@/lib/store'
|
||||
import { toast, useToast } from '@/hooks/use-toast'
|
||||
|
|
@ -325,16 +326,56 @@ function fileErrorMessage(err: unknown): string {
|
|||
/** The read-only folder that ships the hand-authored Zai-Magic presets. */
|
||||
const ZAI_MAGIC_FOLDER = 'root/embedded/Zai-Magic'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v7.11 — the visitor's own selection vs. the welcome showcase
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The script text this visit booted with (saved default / Bass Bloom /
|
||||
* template). Any later difference = the visitor's own material. */
|
||||
let bootScriptSnapshot = ''
|
||||
|
||||
/** Capture the startup script (page boot) — the "untouched editor" baseline. */
|
||||
export function setBootScriptSnapshot(text: string): void {
|
||||
bootScriptSnapshot = text.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* v7.11 — does this visitor have their OWN thing going? True when they
|
||||
* loaded a preset (tree Load / double-click / Copy to my presets / created
|
||||
* one), brought their own script material into the editor (typed, pasted,
|
||||
* hash-imported, file-loaded — anything that differs from the untouched
|
||||
* startup text), saved their own default startup script, or are running
|
||||
* their own mix mode. The Demo showcase auto-engages ONLY while this is
|
||||
* false; otherwise the Demo button just plays music and touches nothing —
|
||||
* a custom script can be tested and debugged against the demo track
|
||||
* without any fear of it being replaced.
|
||||
*/
|
||||
export function visitorHasOwnSelection(): boolean {
|
||||
const s = useAvaStore.getState()
|
||||
if (s.manualChoice) return true
|
||||
// their own club-mode mix is running — the demo must not re-scope it
|
||||
if (s.randomize || s.sequenceMode) return true
|
||||
const cur = s.scriptText.trim()
|
||||
if (!cur) return false
|
||||
// the untouched starter template (fresh visit / New button) is not own material
|
||||
if (cur === STARTER_TEMPLATE.trim()) return false
|
||||
// anything else that differs from the untouched startup text is theirs
|
||||
return cur !== bootScriptSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Music just started but no preset is active (the visitor never picked one —
|
||||
* e.g. they are running their own untitled draft): load the default preset,
|
||||
* Zai-Magic "Bass Bloom", so something beat-reactive is on screen the moment
|
||||
* audio arrives. Engine-only — the editor's unsaved text is never touched
|
||||
* (the same courtesy random switching pays).
|
||||
* (the same courtesy random switching pays). v7.11: a visitor with their OWN
|
||||
* material (custom draft / saved default) is left completely alone — the
|
||||
* forced default used to replace their running script.
|
||||
*/
|
||||
function ensureDefaultPreset(): void {
|
||||
const s = useAvaStore.getState()
|
||||
if (s.activePresetId !== null) return
|
||||
if (visitorHasOwnSelection()) return
|
||||
const preset = s.presets.find(
|
||||
(p) => p.folderPath === ZAI_MAGIC_FOLDER && p.name === 'Bass Bloom',
|
||||
)
|
||||
|
|
@ -353,7 +394,8 @@ function ensureDefaultPreset(): void {
|
|||
* expand it so the visitor sees what is playing), then start the sequence —
|
||||
* v7.4: every Zai-Magic preset IN ORDER, one every 20 seconds, wrapping
|
||||
* around, for the one play-through (Sequence, not Random — the demo pass
|
||||
* should walk the whole family, not repeat picks).
|
||||
* should walk the whole family, not repeat picks). v7.11: only ever called
|
||||
* for a visit that made no choice of their own (see visitorHasOwnSelection).
|
||||
*/
|
||||
function startShowcaseMix(): void {
|
||||
const s = useAvaStore.getState()
|
||||
|
|
@ -370,7 +412,8 @@ function startShowcaseMix(): void {
|
|||
* Stop button, or switching to another source) unwind the showcase state:
|
||||
* the Demo flow force-checked the mix mode (Sequence), so it gets unchecked
|
||||
* again. The visitor's own selection (the last preset that was running)
|
||||
* stays put.
|
||||
* stays put. v7.11: only called when the showcase actually engaged — a
|
||||
* visitor whose own Random/Sequence mix was already running keeps it.
|
||||
*/
|
||||
function retireDemoShowcase(): void {
|
||||
const s = useAvaStore.getState()
|
||||
|
|
@ -430,27 +473,37 @@ export function useAudioSource() {
|
|||
const index = readDemoNext()
|
||||
try {
|
||||
await engine.audio.startDemo(index)
|
||||
// v7.11: the welcome showcase (Bass Bloom + Zai-Magic Sequence) only
|
||||
// engages for a visit that made NO choice of their own — no preset
|
||||
// loaded (double-click / Load / a copy), no custom script material in
|
||||
// the editor, no own mix running. Everyone else keeps their selection:
|
||||
// Demo just plays the track.
|
||||
const showcase = !visitorHasOwnSelection()
|
||||
// v7.3/v7.10: single pass — when the track runs out (or is stopped, or
|
||||
// replaced by another source) the engine fires onDemoEnded exactly
|
||||
// once, WITH the reason: sync the store to idle (the landing overlay
|
||||
// returns), uncheck the randomizer the showcase had checked, and —
|
||||
// when the pass truly ENDED (natural finish or the Stop button) —
|
||||
// returns), uncheck the showcase mix ONLY when the showcase engaged,
|
||||
// and — when the pass truly ENDED (natural finish or the Stop button) —
|
||||
// advance the collection place so the next Demo click plays the next
|
||||
// track. A source switch keeps the visitor's place.
|
||||
engine.audio.onDemoEnded = (reason) => {
|
||||
syncSource()
|
||||
retireDemoShowcase()
|
||||
if (showcase) retireDemoShowcase()
|
||||
if (reason === 'ended' || reason === 'stop') writeDemoNext(index + 1)
|
||||
}
|
||||
syncSource()
|
||||
// showcase flow (v7.2/v7.4): nothing active → Bass Bloom, then the
|
||||
// Zai-Magic sequence takes over — every preset in order, 20 s each,
|
||||
// for this one play-through
|
||||
// showcase flow (v7.2/v7.4/v7.11): nothing active and nothing of the
|
||||
// visitor's own → Bass Bloom, then the Zai-Magic sequence takes over —
|
||||
// every preset in order, 20 s each, for this one play-through. With
|
||||
// the visitor's own selection, BOTH are skipped (ensureDefaultPreset
|
||||
// holds its own guard as well).
|
||||
ensureDefaultPreset()
|
||||
startShowcaseMix()
|
||||
if (showcase) startShowcaseMix()
|
||||
toast({
|
||||
title: 'Demo track playing',
|
||||
description: `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · Zai-Magic preset sequence -- every preset in order, 20 s each, one play-through.`,
|
||||
description: showcase
|
||||
? `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · Zai-Magic preset sequence -- every preset in order, 20 s each, one play-through.`
|
||||
: `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · your selection stays active -- the demo never touches your preset or the editor.`,
|
||||
})
|
||||
} catch (err) {
|
||||
syncSource()
|
||||
|
|
|
|||
|
|
@ -167,6 +167,9 @@ function useLoadPreset() {
|
|||
if (result.ok) {
|
||||
st.setActivePresetId(preset.id)
|
||||
st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath })
|
||||
// v7.11: an explicitly loaded preset (double-click / Load button) is
|
||||
// the visitor's own choice — the Demo showcase must never override it
|
||||
st.markManualChoice()
|
||||
// No "Preset loaded" toast (v7.3): the footer's SCRIPT: label
|
||||
// already echoes the running preset — the popup was intrusive and
|
||||
// only compile errors genuinely need the attention grab.
|
||||
|
|
@ -527,6 +530,7 @@ export function PresetsTab() {
|
|||
applyScriptText(STARTER_TEMPLATE)
|
||||
st.setActivePresetId(res.preset.id)
|
||||
st.selectPreset(res.preset.id)
|
||||
st.markManualChoice() // v7.11 — a created preset is the visitor's own choice
|
||||
st.setTab('script')
|
||||
toast({
|
||||
title: 'Preset created',
|
||||
|
|
@ -671,6 +675,7 @@ export function PresetsTab() {
|
|||
const st = useAvaStore.getState()
|
||||
st.toggleFolder(USER_ROOT, true)
|
||||
st.selectPreset(res.preset.id)
|
||||
st.markManualChoice() // v7.11 — a copied preset is the visitor's own choice
|
||||
toast({
|
||||
title: 'Copied to your presets',
|
||||
description: `"${res.preset.name}" is now yours to edit -- stored in this browser only.`,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// AVA — Script tab: the custom visualization script editor.
|
||||
//
|
||||
// Toolbar: Apply, Save (to the preset picked in the Presets tab, when a save
|
||||
// target is set), Save As…, Save as Default, Save to File, Load from File,
|
||||
// Toolbar: Apply, Save (to the preset picked in the Presets tab when a save
|
||||
// target is set; v7.11 also with NO target — one click stores the current
|
||||
// script as a new preset under "user created", auto-named from its name:
|
||||
// header), Save As…, Save as Default, Save to File, Load from File,
|
||||
// Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode
|
||||
// string and paste one back), New, Help. Editor: line-number gutter +
|
||||
// monospace textarea (synced scroll, Tab inserts two spaces), error lines
|
||||
|
|
@ -48,7 +50,7 @@ import { sanitizeFileName } from '@/components/ava/shared'
|
|||
import { useToast } from '@/hooks/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { USER_ROOT, isUserAreaPath } from '@/lib/preset-paths'
|
||||
import { createUserPreset, presetIsLocal, updateUserPreset } from '@/lib/ava/user-presets'
|
||||
import { createUserPreset, listUserPresets, presetIsLocal, renameUserPreset, updateUserPreset } from '@/lib/ava/user-presets'
|
||||
|
||||
/** Middle-truncated save-target path for the chip: "root/embedded/…/Name". */
|
||||
function formatSavePath(folderPath: string, name: string): string {
|
||||
|
|
@ -58,6 +60,16 @@ function formatSavePath(folderPath: string, name: string): string {
|
|||
return `${segs.slice(0, 2).join('/')}/…/${name}`
|
||||
}
|
||||
|
||||
/** First free name in a folder: "Base", "Base (1)", "Base (2)" … */
|
||||
function uniqueName(base: string, taken: Set<string>): string {
|
||||
if (!taken.has(base)) return base
|
||||
for (let i = 1; i < 1000; i++) {
|
||||
const candidate = `${base} (${i})`
|
||||
if (!taken.has(candidate)) return candidate
|
||||
}
|
||||
return `${base} (${Date.now().toString(36).slice(-4)})`
|
||||
}
|
||||
|
||||
export function ScriptTab() {
|
||||
const { toast } = useToast()
|
||||
const scriptText = useAvaStore((s) => s.scriptText)
|
||||
|
|
@ -80,6 +92,17 @@ export function ScriptTab() {
|
|||
// v7.7 — share-string dialogs
|
||||
const [shareOutOpen, setShareOutOpen] = useState(false)
|
||||
const [shareInOpen, setShareInOpen] = useState(false)
|
||||
// v7.11 — the display name a Hash⇢Script import arrived with (the script's
|
||||
// name: header, else the share-string label); the one-click Save uses it
|
||||
// when the script itself carries no name
|
||||
const [pendingName, setPendingName] = useState<string | null>(null)
|
||||
// v7.11 — "Give This Preset a Name" dialog (renames the just-saved
|
||||
// auto-named preset: nameless scripts / duplicate conflicts)
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [renameError, setRenameError] = useState<string | null>(null)
|
||||
const [renameBusy, setRenameBusy] = useState(false)
|
||||
const [renameTarget, setRenameTarget] = useState<{ id: string; name: string; folderPath: string } | null>(null)
|
||||
|
||||
// debounced live validation (compile is cheap, scripts are small)
|
||||
useEffect(() => {
|
||||
|
|
@ -159,6 +182,111 @@ export function ScriptTab() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v7.11 — one-click save with NO target picked: create a new preset under
|
||||
* "user created" from the current editor script. Auto-named from the
|
||||
* script's name: header (e.g. a hash import of "Spectrum Bars:[▼…]" saves
|
||||
* as "Spectrum Bars"); a nameless script lands as "Empty Name", a
|
||||
* conflict as "Base (1)", "Base (2)"… — and whenever the name had to be
|
||||
* invented, the "Give This Preset a Name" dialog opens right after so the
|
||||
* just-saved preset can be renamed in one extra step. Nothing is ever
|
||||
* lost to a naming problem: the preset is saved FIRST, renamed second.
|
||||
*/
|
||||
const saveAsNewPreset = async () => {
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const v = validateScript(scriptText)
|
||||
if (!v.ok) {
|
||||
toast({
|
||||
title: 'Fix the compile errors first',
|
||||
description: 'The script must compile before it can be saved as a preset.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// candidate name: the script's own name: header, else the share-string
|
||||
// label it arrived with (Hash⇢Script import)
|
||||
let base: string | null = null
|
||||
const metaName = v.meta?.name?.trim()
|
||||
if (metaName && metaName !== 'Untitled') base = metaName
|
||||
if (!base && pendingName) base = pendingName.trim() || null
|
||||
if (base) {
|
||||
// preset names must stay path-safe (no slashes / control characters)
|
||||
base = base.replace(/[/\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120).trim()
|
||||
if (!base) base = null
|
||||
}
|
||||
const taken = new Set(
|
||||
listUserPresets().filter((p) => p.folderPath === USER_ROOT).map((p) => p.name),
|
||||
)
|
||||
const autoName = uniqueName(base ?? 'Empty Name', taken)
|
||||
const invented = base === null || autoName !== base
|
||||
const res = createUserPreset({
|
||||
name: autoName,
|
||||
content: scriptText,
|
||||
folderPath: USER_ROOT,
|
||||
author: v.meta?.author || 'you',
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Save failed', description: res.error, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
applyUserPresets()
|
||||
setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
|
||||
const st = useAvaStore.getState()
|
||||
st.selectPreset(res.preset.id)
|
||||
st.toggleFolder(USER_ROOT, true)
|
||||
toast({
|
||||
title: `Saved as "${res.preset.name}"`,
|
||||
description: invented
|
||||
? 'Stored under "user created" in this browser -- give it a better name in the dialog.'
|
||||
: 'Stored under "user created" in this browser.',
|
||||
})
|
||||
if (invented) {
|
||||
// the name had to be invented (nameless script / duplicate conflict)
|
||||
// — the preset is SAFE; offer the rename as one extra step
|
||||
setRenameTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
|
||||
setRenameValue(base ?? '')
|
||||
setRenameError(null)
|
||||
setRenameOpen(true)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** v7.11 — confirm the "Give This Preset a Name" dialog: rename the
|
||||
* just-saved preset to the visitor's name (stays open on conflicts). */
|
||||
const applyRename = async () => {
|
||||
if (!renameTarget || renameBusy) return
|
||||
const trimmed = renameValue.trim()
|
||||
if (!trimmed) {
|
||||
setRenameError('Enter a name first.')
|
||||
return
|
||||
}
|
||||
setRenameBusy(true)
|
||||
try {
|
||||
const res = renameUserPreset(renameTarget.id, trimmed)
|
||||
if (!res.ok) {
|
||||
setRenameError(res.error)
|
||||
return
|
||||
}
|
||||
applyUserPresets()
|
||||
const st = useAvaStore.getState()
|
||||
if (st.editTarget?.id === res.preset.id) {
|
||||
st.setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
|
||||
}
|
||||
st.selectPreset(res.preset.id)
|
||||
setRenameOpen(false)
|
||||
toast({
|
||||
title: 'Preset renamed',
|
||||
description: `Saved as "${res.preset.name}" in ${res.preset.folderPath} (this browser).`,
|
||||
})
|
||||
} finally {
|
||||
setRenameBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openSaveAs = () => {
|
||||
if (!editTarget) return
|
||||
setSaveAsName(editTarget.name)
|
||||
|
|
@ -226,6 +354,9 @@ export function ScriptTab() {
|
|||
const text = await file.text()
|
||||
const result = applyScriptText(text)
|
||||
if (result.ok) {
|
||||
// v7.11: a file from disk is the visitor's own material — the Demo
|
||||
// showcase must never clobber it
|
||||
useAvaStore.getState().markManualChoice()
|
||||
const meta = validateScript(text).meta
|
||||
toast({ title: 'Script loaded', description: `"${meta?.name || file.name}" applied to the engine.` })
|
||||
} else if (result.error) {
|
||||
|
|
@ -301,7 +432,7 @@ export function ScriptTab() {
|
|||
>
|
||||
<Play className="size-3.5" /> Apply
|
||||
</Button>
|
||||
{editTarget && (
|
||||
{editTarget ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void saveToTarget()}
|
||||
|
|
@ -311,6 +442,18 @@ export function ScriptTab() {
|
|||
>
|
||||
<Save className="size-3.5" /> Save
|
||||
</Button>
|
||||
) : (
|
||||
validation?.ok && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void saveAsNewPreset()}
|
||||
disabled={saving}
|
||||
className="h-9 gap-1.5 bg-emerald-400 px-3 text-emerald-950 hover:bg-emerald-300"
|
||||
title='Save the current script as a NEW preset under "user created" -- auto-named from the script name: header'
|
||||
>
|
||||
<Save className="size-3.5" /> Save
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{editTarget && (
|
||||
<Button
|
||||
|
|
@ -374,6 +517,7 @@ export function ScriptTab() {
|
|||
onClick={() => {
|
||||
setScriptText(STARTER_TEMPLATE)
|
||||
setEditTarget(null)
|
||||
setPendingName(null)
|
||||
}}
|
||||
className="h-9 gap-1.5 border-zinc-700"
|
||||
title="Start over from the template (clears the save target)"
|
||||
|
|
@ -486,12 +630,73 @@ export function ScriptTab() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* v7.11 -- rename the just-auto-named preset (nameless script /
|
||||
duplicate conflict): the preset is already saved under "user
|
||||
created"; this dialog only gives it the visitor's own name */}
|
||||
<Dialog
|
||||
open={renameOpen}
|
||||
onOpenChange={(o) => {
|
||||
setRenameOpen(o)
|
||||
if (o) setRenameError(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Give This Preset a Name</DialogTitle>
|
||||
<DialogDescription>
|
||||
The script is already saved as “{renameTarget?.name ?? 'Untitled'}” under “user created” in this
|
||||
browser. Name it anything you like -- the rename applies instantly.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => {
|
||||
setRenameValue(e.target.value)
|
||||
setRenameError(null)
|
||||
}}
|
||||
placeholder="Preset name"
|
||||
autoFocus
|
||||
aria-label="New preset name"
|
||||
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void applyRename()
|
||||
}}
|
||||
/>
|
||||
{renameError && <p className="mt-1.5 text-[11px] text-rose-400">{renameError}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="h-9 border-zinc-700" onClick={() => setRenameOpen(false)}>
|
||||
Keep as-is
|
||||
</Button>
|
||||
<Button
|
||||
className="h-9 gap-1.5 bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
|
||||
disabled={renameBusy}
|
||||
onClick={() => void applyRename()}
|
||||
>
|
||||
<Save className="size-3.5" /> {renameBusy ? 'Renaming…' : 'Save Name'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* v7.7 -- share-string dialogs */}
|
||||
<ScriptToHashDialog open={shareOutOpen} onOpenChange={setShareOutOpen} scriptText={scriptText} />
|
||||
<HashToScriptDialog
|
||||
open={shareInOpen}
|
||||
onOpenChange={setShareInOpen}
|
||||
onImport={(text) => setScriptText(text)}
|
||||
onImport={(text, name) => {
|
||||
setScriptText(text)
|
||||
setPendingName(name)
|
||||
// the editor now holds FOREIGN content — the previous save target
|
||||
// no longer matches what is in the editor, so a later Save must not
|
||||
// overwrite it (v7.11: Save creates a new preset instead)
|
||||
setEditTarget(null)
|
||||
// imported material is the visitor's own — the Demo showcase must
|
||||
// never clobber it (v7.11)
|
||||
useAvaStore.getState().markManualChoice()
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -164,8 +164,10 @@ export function HashToScriptDialog({
|
|||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Load the decoded script into the editor (script-tab wires this). */
|
||||
onImport: (script: string) => void
|
||||
/** Load the decoded script into the editor (script-tab wires this).
|
||||
* v7.11: also hands back the script's display name (its name: header,
|
||||
* else the share-string label) so Save can auto-name the new preset. */
|
||||
onImport: (script: string, name: string | null) => void
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const [input, setInput] = useState('')
|
||||
|
|
@ -219,12 +221,12 @@ export function HashToScriptDialog({
|
|||
|
||||
function load() {
|
||||
if (!decoded) return
|
||||
onImport(decoded.script)
|
||||
onImport(decoded.script, decoded.name ?? null)
|
||||
onOpenChange(false)
|
||||
toast({
|
||||
title: 'Script loaded from share string',
|
||||
description: `"${decoded.name ?? 'Untitled'}" -- ${decoded.lines} lines in the editor.${
|
||||
decoded.compiles ? ' Hit Apply to run it.' : ' Fix the errors, then Apply.'
|
||||
decoded.compiles ? ' Hit Apply to run it, or Save to keep it as a preset.' : ' Fix the errors, then Apply.'
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
128
src/lib/ava/wake-lock.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// OVE — Screen Wake Lock manager (v7.11).
|
||||
//
|
||||
// Mobile browsers dim and eventually turn the screen off after a few seconds
|
||||
// of inactivity — correct for reading, fatal for a music visualizer propped
|
||||
// on a table. While audio is actually playing (demo / mic / file / link —
|
||||
// anything but idle) the app holds a screen wake lock so the visuals stay
|
||||
// visible; the moment playback stops the lock is released and the phone's
|
||||
// normal inactivity timeout takes over again.
|
||||
//
|
||||
// API notes (developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API):
|
||||
// • navigator.wakeLock.request('screen') → Promise<WakeLockSentinel>
|
||||
// • The browser SILENTLY releases the sentinel when the page becomes
|
||||
// hidden (app switch / screen lock) — so a 'visibilitychange' listener
|
||||
// re-acquires it when the visitor comes back and music is still going.
|
||||
// • request() rejects when unsupported (older iOS Safari, Firefox Android
|
||||
// pre-126), when the document is hidden, or when a platform setting
|
||||
// refuses — every rejection is swallowed: the lock is a nice-to-have,
|
||||
// never a blocker.
|
||||
// • Wake locks are free — no battery drain beyond the screen itself, and
|
||||
// the page already renders at full tilt while playing.
|
||||
|
||||
/** Minimal structural type — lib.dom versions vary in Wake Lock coverage. */
|
||||
interface WakeLockSentinelLike {
|
||||
readonly released: boolean
|
||||
release: () => Promise<void>
|
||||
addEventListener: (type: 'release', listener: () => void) => void
|
||||
removeEventListener: (type: 'release', listener: () => void) => void
|
||||
}
|
||||
|
||||
interface WakeLockLike {
|
||||
request: (type: 'screen') => Promise<WakeLockSentinelLike>
|
||||
}
|
||||
|
||||
/** The held sentinel (null = not held / browser released it). */
|
||||
let sentinel: WakeLockSentinelLike | null = null
|
||||
/** Whether a lock is currently WANTED (music playing). */
|
||||
let wantActive = false
|
||||
/** Guards against overlapping request() calls. */
|
||||
let requesting = false
|
||||
/** The sentinel's own release listener (kept so it can be detached). */
|
||||
let releaseListener: (() => void) | null = null
|
||||
|
||||
function navigatorWakeLock(): WakeLockLike | null {
|
||||
if (typeof navigator === 'undefined') return null
|
||||
const nav = navigator as Navigator & { wakeLock?: unknown }
|
||||
const wl = nav.wakeLock
|
||||
if (!wl || typeof wl !== 'object') return null
|
||||
const typed = wl as Partial<WakeLockLike>
|
||||
return typeof typed.request === 'function' ? (typed as WakeLockLike) : null
|
||||
}
|
||||
|
||||
/** Drop our reference when the browser auto-releases (page hidden). */
|
||||
function onSentinelRelease(): void {
|
||||
sentinel = null
|
||||
releaseListener = null
|
||||
}
|
||||
|
||||
async function acquire(): Promise<void> {
|
||||
if (sentinel !== null || requesting) return
|
||||
const wl = navigatorWakeLock()
|
||||
if (!wl) return
|
||||
requesting = true
|
||||
try {
|
||||
const s = await wl.request('screen')
|
||||
// state may have flipped to "stopped" while the promise was in flight
|
||||
if (!wantActive) {
|
||||
try {
|
||||
await s.release()
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
return
|
||||
}
|
||||
sentinel = s
|
||||
releaseListener = onSentinelRelease
|
||||
s.addEventListener('release', releaseListener)
|
||||
} catch {
|
||||
/* unsupported / hidden / refused — degrade silently */
|
||||
} finally {
|
||||
requesting = false
|
||||
}
|
||||
}
|
||||
|
||||
function dropSentinel(): void {
|
||||
const s = sentinel
|
||||
sentinel = null
|
||||
if (s) {
|
||||
if (releaseListener) s.removeEventListener('release', releaseListener)
|
||||
releaseListener = null
|
||||
s.release().catch(() => {
|
||||
/* already released */
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold or release the screen wake lock according to playback. Called with
|
||||
* `true` whenever a source starts, `false` whenever the engine goes idle.
|
||||
*/
|
||||
export function syncWakeLock(active: boolean): void {
|
||||
wantActive = active
|
||||
if (!active) {
|
||||
dropSentinel()
|
||||
return
|
||||
}
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||
void acquire()
|
||||
}
|
||||
// hidden right now (rare — playback needs a gesture, but a queued playlist
|
||||
// could land here): the visibilitychange listener below acquires on return.
|
||||
}
|
||||
|
||||
/** Explicit teardown (hook unmount / pagehide). */
|
||||
export function releaseWakeLock(): void {
|
||||
syncWakeLock(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-acquire the lock when the page becomes visible again while music is
|
||||
* still playing (the browser always drops it on hide). Returns a cleanup.
|
||||
*/
|
||||
export function watchWakeLockVisibility(): () => void {
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible' && wantActive) void acquire()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
return () => document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
|
|
@ -39,6 +39,24 @@ export interface ReleaseNote {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseNote[] = [
|
||||
{
|
||||
version: '7.11',
|
||||
name: 'Wake lock, a respectful Demo, one-click save',
|
||||
changes: [
|
||||
{
|
||||
kind: 'added',
|
||||
text: 'The screen stays on while music plays: on phones and tablets the site now holds a screen wake lock for as long as any audio source is live, then lets go the moment playback stops so the device\'s normal screen timeout takes over again.',
|
||||
},
|
||||
{
|
||||
kind: 'improved',
|
||||
text: 'The Demo Track button stopped hijacking the preset selection. When a preset is already loaded (tree double-click, Load, a copied preset) or the editor holds custom script material, Demo just plays the track and leaves everything untouched -- the Zai-Magic Sequence showcase now only starts for a visit that never picked or typed anything, so a custom script can be built and debugged against the demo track without fear of it being replaced.',
|
||||
},
|
||||
{
|
||||
kind: 'improved',
|
||||
text: 'One-click saving in the Script tab: a valid script shows the Save button even with no save target picked, and one click stores it under "user created", auto-named from the script\'s name header (a hash import of "Spectrum Bars:[▼…]" saves as "Spectrum Bars"). Nameless scripts land as "Empty Name", collisions as "(1)", "(2)"… -- and in those cases a "Give This Preset a Name" dialog opens immediately so the just-saved preset can be renamed on the spot.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '7.10.1',
|
||||
name: 'Spectrum edge unfrozen',
|
||||
|
|
|
|||
|
|
@ -187,6 +187,14 @@ interface AvaState {
|
|||
editTarget: EditTarget | null
|
||||
/** Preset currently running on the engine (tree indicator). */
|
||||
activePresetId: string | null
|
||||
/** v7.11 — true once THIS visit made an explicit choice: loaded a preset
|
||||
* (tree Load / double-click / copy-to-my-presets / created one), brought
|
||||
* the visitor's own script material into the editor (typed / pasted /
|
||||
* hash-imported / file-loaded), saved a default startup script, or runs
|
||||
* their own mix mode. While false the Demo button may still run the
|
||||
* welcome showcase; once true, Demo never touches the selection.
|
||||
* Deliberately NOT persisted — every visit starts fresh. */
|
||||
manualChoice: boolean
|
||||
/** Timed preset mixing (club mode): Random (no repeats) or, v7.4, Sequence
|
||||
* (in-order cycling with wrap-around). The two are mutually exclusive —
|
||||
* ticking one unticks the other; both share one delay + folder scope and
|
||||
|
|
@ -219,6 +227,8 @@ interface AvaState {
|
|||
selectPreset: (id: string | null) => void
|
||||
setEditTarget: (t: EditTarget | null) => void
|
||||
setActivePresetId: (id: string | null) => void
|
||||
/** v7.11 — flag this visitor as having made their own choice (see field). */
|
||||
markManualChoice: () => void
|
||||
setRandomize: (on: boolean) => void
|
||||
/** v7.4: Sequence mode — in-order cycling; mutually exclusive with Random. */
|
||||
setSequenceMode: (on: boolean) => void
|
||||
|
|
@ -371,6 +381,7 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
|
|||
expandedFolders: {},
|
||||
editTarget: null,
|
||||
activePresetId: null,
|
||||
manualChoice: false,
|
||||
randomize: false,
|
||||
sequenceMode: false,
|
||||
randomIntervalSec: 10,
|
||||
|
|
@ -481,6 +492,8 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
|
|||
|
||||
setActivePresetId: (id) => set({ activePresetId: id }),
|
||||
|
||||
markManualChoice: () => set({ manualChoice: true }),
|
||||
|
||||
setRandomize: (on) => {
|
||||
// v7.4 mutual exclusion: ticking Random unticks Sequence (turning one
|
||||
// OFF never lights the other — the visitor's explicit choice)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,35 @@
|
|||
// warns in the console when the two drift apart.
|
||||
//
|
||||
// History:
|
||||
// 7.11 — wake lock, a respectful Demo button, one-click save. While any
|
||||
// audio source is live the site holds a Screen Wake Lock so phone
|
||||
// and tablet screens stay on (released the moment the engine goes
|
||||
// idle so the normal inactivity dimming returns; browsers silently
|
||||
// drop the sentinel on hide, so a visibilitychange watcher
|
||||
// re-acquires it on return while music is still going;
|
||||
// unsupported browsers degrade silently). The Demo Track button no
|
||||
// longer force-engages the Sequence checkbox: a visitor who loaded
|
||||
// a preset (tree double-click / Load / Copy to my presets / created
|
||||
// one), brought their own script material into the editor (typed,
|
||||
// pasted, Hash⇢Script, file), saved their own default startup
|
||||
// script, or runs their own Random/Sequence mix keeps their
|
||||
// selection -- Demo just plays the track (the showcase's tree
|
||||
// re-scope, 20 s interval and retire-on-end all skip too; a new
|
||||
// per-visit manualChoice flag + a boot-script snapshot decide, and
|
||||
// ensureDefaultPreset gained the same guard so mic/file/link never
|
||||
// clobber a custom draft either; the showcase now only starts for
|
||||
// a visit that never picked or typed anything). Script tab: a valid
|
||||
// script shows the Save button with NO save target needed -- one
|
||||
// click creates a preset under "user created", auto-named from the
|
||||
// script's name: header (a hash import of "Spectrum Bars:[▼…]"
|
||||
// saves as "Spectrum Bars"), "Empty Name" when nameless, " (1)"/
|
||||
// " (2)"… suffixes on conflicts, and whenever the name had to be
|
||||
// invented a "Give This Preset a Name" dialog opens to rename the
|
||||
// just-saved preset in one step (the preset is always saved FIRST,
|
||||
// renamed second, so nothing is ever lost to a naming problem).
|
||||
// Hash⇢Script imports clear the stale save target (the editor now
|
||||
// holds foreign content) and pass the decoded label through as the
|
||||
// save-name fallback when the script has no name: header
|
||||
// 7.10.1 — the frozen right edge of spectrum visuals. getspec()/sv mapped
|
||||
// the bar field across the whole FFT (bin 2 ... Nyquist, ~12 Hz to
|
||||
// 24 kHz), but music carries almost nothing above ~16 kHz and every
|
||||
|
|
@ -311,4 +340,4 @@
|
|||
|
||||
export const APP_NAME = 'Open Visualization Engine'
|
||||
export const APP_SHORT = 'OVE'
|
||||
export const APP_VERSION = '7.10.1'
|
||||
export const APP_VERSION = '7.11'
|
||||
|
|
|
|||
1254
tool-results/read_1793817131261_fb699e6a294f.txt
Normal file
1106
tool-results/read_1793817160288_8d6de58d6d8d.txt
Normal file
BIN
tool-results/v711-01-fresh-home.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
tool-results/v711-02-fresh-demo.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
tool-results/v711-03-preset-loaded.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
tool-results/v711-04-demo-with-manual.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
tool-results/v711-05-demo-custom-material.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
tool-results/v711-06-seq-unchecked.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
tool-results/v711-07-state-check.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
tool-results/v711-08-whats-new.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
tool-results/v711-09-decoded.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
tool-results/v711-10-imported.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
tool-results/v711-11-saved-named.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
tool-results/v711-12-empty-name-dialog.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
tool-results/v711-13-renamed.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
tool-results/v711-14-rename-conflict.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
tool-results/v711-15-empty-name-1.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
tool-results/v711-16-suffix-conflict.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
tool-results/v711-17-named-dup.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
tool-results/v711-18-default-boot.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
tool-results/v711-19-default-demo.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
tool-results/v711-20-final-fresh.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
tool-results/v711-21-mobile.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
tool-results/v711-22-desktop.png
Normal file
|
After Width: | Height: | Size: 125 KiB |