a30cfb39-1b53-4097-990f-27d01fb42f1f

This commit is contained in:
Z User 2026-11-04 18:48:09 +00:00
parent 1c1105da45
commit 401963b9f3
33 changed files with 2861 additions and 22 deletions

View file

@ -17,8 +17,9 @@ import { VisualStage } from '@/components/ava/visual-stage'
import { ControlPanel } from '@/components/ava/control-panel' import { ControlPanel } from '@/components/ava/control-panel'
import { FooterBar } from '@/components/ava/footer-bar' import { FooterBar } from '@/components/ava/footer-bar'
import { QueueBar } from '@/components/ava/queue-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 { STARTER_TEMPLATE } from '@/lib/ava/presets'
import { releaseWakeLock, syncWakeLock, watchWakeLockVisibility } from '@/lib/ava/wake-lock'
import { import {
lsGetString, lsGetString,
readStoredBandConfig, readStoredBandConfig,
@ -105,6 +106,7 @@ function useInitialLoad() {
// (Zai-Magic "Bass Bloom" — the megabuf particle bloom that remembers // (Zai-Magic "Bass Bloom" — the megabuf particle bloom that remembers
// every beat's hue) → any Zai-Magic preset → template // every beat's hue) → any Zai-Magic preset → template
let text: string | null = lsGetString('ava.defaultScript') let text: string | null = lsGetString('ava.defaultScript')
const usedSavedDefault = text !== null && text.trim() !== ''
let startupPreset: (typeof presetsList)[number] | undefined let startupPreset: (typeof presetsList)[number] | undefined
if (!text) { if (!text) {
startupPreset = startupPreset =
@ -117,6 +119,12 @@ function useInitialLoad() {
} }
if (!text) text = STARTER_TEMPLATE if (!text) text = STARTER_TEMPLATE
applyScriptText(text) 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) if (startupPreset) store.setActivePresetId(startupPreset.id)
// club mode: bring back the random-preset timer if it was on // 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() { export default function Home() {
useInitialLoad() useInitialLoad()
useBeatWatcher() useBeatWatcher()
useSpaceToTap() useSpaceToTap()
useWakeLockOnPlayback()
return ( return (
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:h-screen lg:overflow-hidden"> <div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:h-screen lg:overflow-hidden">

View file

@ -11,6 +11,7 @@ import { getEngine } from '@/lib/ava/engine'
import type { TapResult } from '@/lib/ava/beat' import type { TapResult } from '@/lib/ava/beat'
import { validateScript } from '@/lib/ava/script' import { validateScript } from '@/lib/ava/script'
import type { ScriptError } 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 { DEMO_TRACK_COUNT, demoTrackTitle } from '@/lib/ava/audio'
import { useAvaStore } from '@/lib/store' import { useAvaStore } from '@/lib/store'
import { toast, useToast } from '@/hooks/use-toast' 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. */ /** The read-only folder that ships the hand-authored Zai-Magic presets. */
const ZAI_MAGIC_FOLDER = 'root/embedded/Zai-Magic' 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 — * 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, * 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 * 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 * 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 { function ensureDefaultPreset(): void {
const s = useAvaStore.getState() const s = useAvaStore.getState()
if (s.activePresetId !== null) return if (s.activePresetId !== null) return
if (visitorHasOwnSelection()) return
const preset = s.presets.find( const preset = s.presets.find(
(p) => p.folderPath === ZAI_MAGIC_FOLDER && p.name === 'Bass Bloom', (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 — * 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 * 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 * 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 { function startShowcaseMix(): void {
const s = useAvaStore.getState() const s = useAvaStore.getState()
@ -370,7 +412,8 @@ function startShowcaseMix(): void {
* Stop button, or switching to another source) unwind the showcase state: * Stop button, or switching to another source) unwind the showcase state:
* the Demo flow force-checked the mix mode (Sequence), so it gets unchecked * 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) * 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 { function retireDemoShowcase(): void {
const s = useAvaStore.getState() const s = useAvaStore.getState()
@ -430,27 +473,37 @@ export function useAudioSource() {
const index = readDemoNext() const index = readDemoNext()
try { try {
await engine.audio.startDemo(index) 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 // v7.3/v7.10: single pass — when the track runs out (or is stopped, or
// replaced by another source) the engine fires onDemoEnded exactly // replaced by another source) the engine fires onDemoEnded exactly
// once, WITH the reason: sync the store to idle (the landing overlay // once, WITH the reason: sync the store to idle (the landing overlay
// returns), uncheck the randomizer the showcase had checked, and — // returns), uncheck the showcase mix ONLY when the showcase engaged,
// when the pass truly ENDED (natural finish or the Stop button) — // and — when the pass truly ENDED (natural finish or the Stop button) —
// advance the collection place so the next Demo click plays the next // advance the collection place so the next Demo click plays the next
// track. A source switch keeps the visitor's place. // track. A source switch keeps the visitor's place.
engine.audio.onDemoEnded = (reason) => { engine.audio.onDemoEnded = (reason) => {
syncSource() syncSource()
retireDemoShowcase() if (showcase) retireDemoShowcase()
if (reason === 'ended' || reason === 'stop') writeDemoNext(index + 1) if (reason === 'ended' || reason === 'stop') writeDemoNext(index + 1)
} }
syncSource() syncSource()
// showcase flow (v7.2/v7.4): nothing active → Bass Bloom, then the // showcase flow (v7.2/v7.4/v7.11): nothing active and nothing of the
// Zai-Magic sequence takes over — every preset in order, 20 s each, // visitor's own → Bass Bloom, then the Zai-Magic sequence takes over —
// for this one play-through // 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() ensureDefaultPreset()
startShowcaseMix() if (showcase) startShowcaseMix()
toast({ toast({
title: 'Demo track playing', 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) { } catch (err) {
syncSource() syncSource()

View file

@ -167,6 +167,9 @@ function useLoadPreset() {
if (result.ok) { if (result.ok) {
st.setActivePresetId(preset.id) st.setActivePresetId(preset.id)
st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath }) 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 // No "Preset loaded" toast (v7.3): the footer's SCRIPT: label
// already echoes the running preset — the popup was intrusive and // already echoes the running preset — the popup was intrusive and
// only compile errors genuinely need the attention grab. // only compile errors genuinely need the attention grab.
@ -527,6 +530,7 @@ export function PresetsTab() {
applyScriptText(STARTER_TEMPLATE) applyScriptText(STARTER_TEMPLATE)
st.setActivePresetId(res.preset.id) st.setActivePresetId(res.preset.id)
st.selectPreset(res.preset.id) st.selectPreset(res.preset.id)
st.markManualChoice() // v7.11 — a created preset is the visitor's own choice
st.setTab('script') st.setTab('script')
toast({ toast({
title: 'Preset created', title: 'Preset created',
@ -671,6 +675,7 @@ export function PresetsTab() {
const st = useAvaStore.getState() const st = useAvaStore.getState()
st.toggleFolder(USER_ROOT, true) st.toggleFolder(USER_ROOT, true)
st.selectPreset(res.preset.id) st.selectPreset(res.preset.id)
st.markManualChoice() // v7.11 — a copied preset is the visitor's own choice
toast({ toast({
title: 'Copied to your presets', title: 'Copied to your presets',
description: `"${res.preset.name}" is now yours to edit -- stored in this browser only.`, description: `"${res.preset.name}" is now yours to edit -- stored in this browser only.`,

View file

@ -1,7 +1,9 @@
// AVA — Script tab: the custom visualization script editor. // AVA — Script tab: the custom visualization script editor.
// //
// Toolbar: Apply, Save (to the preset picked in the Presets tab, when a save // 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, // 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 // Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode
// string and paste one back), New, Help. Editor: line-number gutter + // string and paste one back), New, Help. Editor: line-number gutter +
// monospace textarea (synced scroll, Tab inserts two spaces), error lines // 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 { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { USER_ROOT, isUserAreaPath } from '@/lib/preset-paths' 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". */ /** Middle-truncated save-target path for the chip: "root/embedded/…/Name". */
function formatSavePath(folderPath: string, name: string): string { 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}` 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() { export function ScriptTab() {
const { toast } = useToast() const { toast } = useToast()
const scriptText = useAvaStore((s) => s.scriptText) const scriptText = useAvaStore((s) => s.scriptText)
@ -80,6 +92,17 @@ export function ScriptTab() {
// v7.7 — share-string dialogs // v7.7 — share-string dialogs
const [shareOutOpen, setShareOutOpen] = useState(false) const [shareOutOpen, setShareOutOpen] = useState(false)
const [shareInOpen, setShareInOpen] = 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) // debounced live validation (compile is cheap, scripts are small)
useEffect(() => { 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 = () => { const openSaveAs = () => {
if (!editTarget) return if (!editTarget) return
setSaveAsName(editTarget.name) setSaveAsName(editTarget.name)
@ -226,6 +354,9 @@ export function ScriptTab() {
const text = await file.text() const text = await file.text()
const result = applyScriptText(text) const result = applyScriptText(text)
if (result.ok) { 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 const meta = validateScript(text).meta
toast({ title: 'Script loaded', description: `"${meta?.name || file.name}" applied to the engine.` }) toast({ title: 'Script loaded', description: `"${meta?.name || file.name}" applied to the engine.` })
} else if (result.error) { } else if (result.error) {
@ -301,7 +432,7 @@ export function ScriptTab() {
> >
<Play className="size-3.5" /> Apply <Play className="size-3.5" /> Apply
</Button> </Button>
{editTarget && ( {editTarget ? (
<Button <Button
size="sm" size="sm"
onClick={() => void saveToTarget()} onClick={() => void saveToTarget()}
@ -311,6 +442,18 @@ export function ScriptTab() {
> >
<Save className="size-3.5" /> Save <Save className="size-3.5" /> Save
</Button> </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 && ( {editTarget && (
<Button <Button
@ -374,6 +517,7 @@ export function ScriptTab() {
onClick={() => { onClick={() => {
setScriptText(STARTER_TEMPLATE) setScriptText(STARTER_TEMPLATE)
setEditTarget(null) setEditTarget(null)
setPendingName(null)
}} }}
className="h-9 gap-1.5 border-zinc-700" className="h-9 gap-1.5 border-zinc-700"
title="Start over from the template (clears the save target)" title="Start over from the template (clears the save target)"
@ -486,12 +630,73 @@ export function ScriptTab() {
</DialogContent> </DialogContent>
</Dialog> </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 */} {/* v7.7 -- share-string dialogs */}
<ScriptToHashDialog open={shareOutOpen} onOpenChange={setShareOutOpen} scriptText={scriptText} /> <ScriptToHashDialog open={shareOutOpen} onOpenChange={setShareOutOpen} scriptText={scriptText} />
<HashToScriptDialog <HashToScriptDialog
open={shareInOpen} open={shareInOpen}
onOpenChange={setShareInOpen} 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 <input

View file

@ -164,8 +164,10 @@ export function HashToScriptDialog({
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
/** Load the decoded script into the editor (script-tab wires this). */ /** Load the decoded script into the editor (script-tab wires this).
onImport: (script: string) => void * 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 { toast } = useToast()
const [input, setInput] = useState('') const [input, setInput] = useState('')
@ -219,12 +221,12 @@ export function HashToScriptDialog({
function load() { function load() {
if (!decoded) return if (!decoded) return
onImport(decoded.script) onImport(decoded.script, decoded.name ?? null)
onOpenChange(false) onOpenChange(false)
toast({ toast({
title: 'Script loaded from share string', title: 'Script loaded from share string',
description: `"${decoded.name ?? 'Untitled'}" -- ${decoded.lines} lines in the editor.${ 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
View 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)
}

View file

@ -39,6 +39,24 @@ export interface ReleaseNote {
} }
export const RELEASE_NOTES: 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', version: '7.10.1',
name: 'Spectrum edge unfrozen', name: 'Spectrum edge unfrozen',

View file

@ -187,6 +187,14 @@ interface AvaState {
editTarget: EditTarget | null editTarget: EditTarget | null
/** Preset currently running on the engine (tree indicator). */ /** Preset currently running on the engine (tree indicator). */
activePresetId: string | null 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 /** Timed preset mixing (club mode): Random (no repeats) or, v7.4, Sequence
* (in-order cycling with wrap-around). The two are mutually exclusive — * (in-order cycling with wrap-around). The two are mutually exclusive —
* ticking one unticks the other; both share one delay + folder scope and * ticking one unticks the other; both share one delay + folder scope and
@ -219,6 +227,8 @@ interface AvaState {
selectPreset: (id: string | null) => void selectPreset: (id: string | null) => void
setEditTarget: (t: EditTarget | null) => void setEditTarget: (t: EditTarget | null) => void
setActivePresetId: (id: string | 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 setRandomize: (on: boolean) => void
/** v7.4: Sequence mode — in-order cycling; mutually exclusive with Random. */ /** v7.4: Sequence mode — in-order cycling; mutually exclusive with Random. */
setSequenceMode: (on: boolean) => void setSequenceMode: (on: boolean) => void
@ -371,6 +381,7 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
expandedFolders: {}, expandedFolders: {},
editTarget: null, editTarget: null,
activePresetId: null, activePresetId: null,
manualChoice: false,
randomize: false, randomize: false,
sequenceMode: false, sequenceMode: false,
randomIntervalSec: 10, randomIntervalSec: 10,
@ -481,6 +492,8 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
setActivePresetId: (id) => set({ activePresetId: id }), setActivePresetId: (id) => set({ activePresetId: id }),
markManualChoice: () => set({ manualChoice: true }),
setRandomize: (on) => { setRandomize: (on) => {
// v7.4 mutual exclusion: ticking Random unticks Sequence (turning one // v7.4 mutual exclusion: ticking Random unticks Sequence (turning one
// OFF never lights the other — the visitor's explicit choice) // OFF never lights the other — the visitor's explicit choice)

View file

@ -12,6 +12,35 @@
// warns in the console when the two drift apart. // warns in the console when the two drift apart.
// //
// History: // 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 // 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 // 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 // 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_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE' export const APP_SHORT = 'OVE'
export const APP_VERSION = '7.10.1' export const APP_VERSION = '7.11'

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: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB