fix(studio): close seven defects a review found in the previous two commits

Sixth review pass. Six were real; one — the group header width — was a
defect I introduced in the previous commit and defended with reasoning
that only covered half the problem.

The header overhang was wrong. `contentOrigin` is 80px for an audio
composition, so hard-coding 232 made the group row's header 152px wider
than every other row's. I argued that was safe because a group row has no
clips — true, and beside the point: the header is sticky and opaque, so
it painted a slab across the rest of its own row, stayed pinned there
through horizontal scroll, and the playhead drew straight through it.
Groups now turn `labelMode` on instead, which is what that flag is for.
Every row gets the same 232px header, verified in the browser.

Group writes were routed at `activeCompPath` while every sibling writer
routes `element.sourceFile || activeCompPath`. Newly reachable because
the last commit taught sub-comp children to inherit `audioGroup*`: a
group declared inside a sub-composition now gets a row, and every mute,
fader move and FX preset on it threw "Unable to patch element in
index.html".

`Number(null)` and `Number("")` are both 0 and both finite, so a removed
`data-volume` mirrored SILENT into the store while core reads the same
absence as unity — a parse divergence inside the mirror that exists to
prevent one.

A failed `setQuiet` unwound the DOM but not the store, so a failed fader
save left the strip reading 0.4 while the preview played 1.0, with
nothing to re-parse and correct it.

`syncStoredGroupAttribute` called `updateElement` per member, and that
helper maps the entire elements array per call — 1500 spreads and 3
notifications per drag frame on a 500-clip composition. One pass now.

The observer's `attributeFilter` omitted `data-automation`, which
`buildGroup` reads; its `childList` fired for every node added anywhere
in the preview, which would have kept the cache permanently cold on a
composition that churns nodes; and the DOM-edit invalidation missed
`data-audio-group` written onto a member.

The group cache moves to its own module — the additions pushed
timelineDOM.ts past the 600-line ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:42 -07:00
co-authored by Claude Opus 5
parent 25d7af8a5c
commit 6056ec7463
11 changed files with 254 additions and 125 deletions
@@ -12,7 +12,7 @@ import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimeline
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
import { invalidateGroupInfoCache } from "../player/lib/timelineDOM";
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
import {
applyPatchByTarget,
@@ -85,6 +85,32 @@ describe("group attribute writes reach the store", () => {
);
});
// `Number(null)` and `Number("")` are both 0 AND finite, so the obvious
// isFinite check mirrored "silent" for a removed attribute while core's
// `readAudioGroupVolume` reads the same absence as unity — a parse divergence
// inside the mirror whose entire job is to prevent one.
it("reads a removed data-volume as unity, the way core does", async () => {
const react = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const harness = makeSetter();
renderToStaticMarkup(react.createElement(harness.Probe));
const setter = harness.get();
usePlayerStore.getState().setElements([member("voice-1", 0)]);
setter?.setLive("voiceover", "data-volume", "0.3");
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBeCloseTo(0.3, 6);
setter?.setLive("voiceover", "data-volume", null);
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
setter?.setLive("voiceover", "data-volume", "");
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
setter?.setLive("voiceover", "data-volume", "nonsense");
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
});
it("mirrors data-volume, and leaves other groups alone", async () => {
const react = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
@@ -2,7 +2,8 @@ import { useCallback } from "react";
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
import { usePlayerStore } from "../player";
import type { TimelineElementPatch } from "../player/store/timelineElement";
import { invalidateGroupInfoCache } from "../player/lib/timelineDOM";
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
import { getTimelineElementSourceFile } from "../player/lib/timelineElementHelpers";
import {
buildPatchTarget,
persistElementAttribute,
@@ -28,15 +29,28 @@ function patchLiveGroupAttribute(
invalidateGroupInfoCache(iframe?.contentDocument);
}
/**
* `data-volume` exactly as core reads it (`readAudioGroupVolume`): an absent or
* empty attribute is UNITY, not zero.
*
* `Number(null)` and `Number("")` are both 0 and both finite, so the obvious
* `Number.isFinite(Number(value))` mirrored "silent" into the store for a
* removed attribute while the DOM, the preview bus and the render all read 1 —
* exactly the parse divergence this mirror exists to eliminate.
*/
function mirroredGroupVolume(value: string | null): number {
if (!value) return 1;
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : 1;
}
/** Which store field each writable group attribute mirrors into. */
const GROUP_ATTR_TO_MIRROR: Record<
string,
(value: string | null, groupId: string) => TimelineElementPatch
> = {
"data-hidden": (value) => ({ audioGroupHidden: value !== null }),
"data-volume": (value) => ({
audioGroupVolume: Number.isFinite(Number(value)) ? Number(value) : 1,
}),
"data-volume": (value) => ({ audioGroupVolume: mirroredGroupVolume(value) }),
"data-label": (value, groupId) => ({ audioGroupLabel: value ?? groupId }),
[HF_AUDIO_FX_ATTR]: (value) => ({ audioGroupFxChain: value ?? undefined }),
};
@@ -59,10 +73,14 @@ function syncStoredGroupAttribute(groupId: string, attr: string, value: string |
const toPatch = GROUP_ATTR_TO_MIRROR[attr];
if (!toPatch) return;
const patch = toPatch(value, groupId);
const store = usePlayerStore.getState();
for (const element of store.elements) {
if (element.audioGroup === groupId) store.updateElement(element.key ?? element.id, patch);
}
// ONE pass and one notification, rather than `updateElement` per member.
// That helper maps the whole `elements` array per call, so a 3-member group on
// a 500-clip composition was 1500 object spreads and 3 store notifications per
// drag frame — at ~60/s, with every `elements`-keyed memo downstream
// recomputing each time.
usePlayerStore.setState((state) => ({
elements: state.elements.map((el) => (el.audioGroup === groupId ? { ...el, ...patch } : el)),
}));
}
interface SetAudioGroupAttributeInput {
@@ -98,7 +116,17 @@ async function setAudioGroupAttribute({
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: SetAudioGroupAttributeInput): Promise<string[]> {
const targetPath = activeCompPath || "index.html";
// The file that actually CONTAINS the group element, not just the active
// composition. A hand-authored sub-composition can declare both the members
// and their `<hf-audio-group>`, and until sub-comp children inherited
// `audioGroup*` no group row existed for that case so nothing could reach
// here. Now the row appears, and routing its writes at `activeCompPath`
// means `readTagSnippetByTarget` finds nothing and every mute, fader move and
// FX preset throws "Unable to patch element in index.html". Every sibling
// timeline writer already routes `element.sourceFile || activeCompPath`.
const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
const targetPath =
(groupEl ? getTimelineElementSourceFile(groupEl) : undefined) || activeCompPath || "index.html";
const patchTarget = buildPatchTarget({ domId: groupId });
if (!patchTarget) return [];
@@ -174,6 +202,14 @@ export function useSetAudioGroupAttribute({
});
syncStoredGroupAttribute(groupId, attr, value);
} catch (error) {
// `persistElementAttribute` has already unwound the live DOM to the
// previous value, but `setLive` mirrored the in-progress value into the
// store on every drag frame — so without this the fader reads 0.4 while
// the preview and the file are both back at 1.0, and nothing re-parses
// to correct it (a live patch causing no parse is this mirror's whole
// premise). Re-mirror from the DOM, which is now authoritative again.
const live = previewIframeRef.current?.contentDocument?.getElementById(groupId);
syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null);
console.error("[Timeline] Failed to set group attribute", error);
const message = error instanceof Error ? error.message : "Failed to update group";
showToast(message);
@@ -9,8 +9,8 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { invalidateGroupInfoCache } from "../player/lib/timelineDOM";
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
// ── Types ──
@@ -68,12 +68,12 @@ function setOrRemovePreviewAttribute(
// Every DOM-edit attribute write funnels through here, which is the only
// place that can catch a group edit made from the rack rather than from the
// group header — `openGroupFxRack` hands the `<hf-audio-group>` to the DOM
// editor, and that path never went near the timeline's own writers. The group
// scan is cached against the preview Document, and group edits are live
// patches so that document is never replaced; a stale entry is re-read on
// every manifest tick, so the header's preset button then builds on the old
// chain and discards the rack's edit.
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
// editor, and that path never went near the timeline's own writers.
//
// The group element itself OR a member's membership attribute: writing
// `data-audio-group` onto an `<audio>` moves it between groups, which changes
// the answer just as much as editing the bus does.
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG || fullAttr === HF_AUDIO_GROUP_ATTR) {
invalidateGroupInfoCache(el.ownerDocument);
}
}
@@ -33,7 +33,7 @@ import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemet
import {
getEffectiveTimelineDuration,
getTimelinePreviewElement,
hasKeyframedTimelineClips,
timelineNeedsLabelColumn,
} from "./timelineViewModel";
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
@@ -115,7 +115,10 @@ export const Timeline = memo(function Timeline({
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
const labelMode = useMemo(
() => timelineNeedsLabelColumn(gsapAnimations, expandedElements),
[gsapAnimations, expandedElements],
);
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
const contentGutter = labelMode ? GUTTER : 0;
@@ -25,6 +25,7 @@ interface TimelineGroupRowProps {
top: number;
height: number;
virtualized: boolean;
contentOrigin: number;
theme: TimelineTheme;
rovingTargetId?: string | null;
collapsedGroupIds: ReadonlySet<string>;
@@ -42,6 +43,7 @@ export function TimelineGroupRow({
top,
height,
virtualized,
contentOrigin,
theme,
rovingTargetId = null,
collapsedGroupIds,
@@ -125,14 +127,12 @@ export function TimelineGroupRow({
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
onOpenFxRack={openGroupFxRack}
// Always the full label column, never squeezed down to `contentOrigin`.
// A track row can afford a narrow gutter because its CLIPS carry the
// name on the bar; a group row has no clips at all, so the gutter is
// the only place its name exists — and at the default fit the gutter is
// ~80px, which rendered the label at zero width and clipped the solo,
// FX and lane buttons off the side. Overhanging into the lane area is
// safe precisely because this row is empty (see `propertyRows={[]}`).
columnWidth={LABEL_COL_W}
// Same width as every other row's header. The group row needs a real
// label column, but it gets one by turning `labelMode` on for the whole
// timeline (see Timeline.tsx) rather than by overhanging alone — an
// overhanging header paints opaquely across the rest of its row and
// stays pinned there through horizontal scroll.
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme}
/>
{isLaneOpen && (
@@ -166,6 +166,7 @@ export function TimelineLanes({
top={rowGeometry.getRowTop(row)}
height={rowGeometry.getRowHeight(row)}
virtualized={rowsVirtualized}
contentOrigin={contentOrigin}
theme={theme}
rovingTargetId={keyboard.rovingTargetId}
collapsedGroupIds={collapsedGroupIds}
@@ -4,7 +4,7 @@ import type { ResizingClipState } from "./timelineClipDragTypes";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { animationContributesLane } from "./TimelinePropertyLanes";
export function hasKeyframedTimelineClips(
function hasKeyframedTimelineClips(
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
): boolean {
return Array.from(animationsByElement.values()).some((animations) =>
@@ -12,6 +12,31 @@ export function hasKeyframedTimelineClips(
);
}
/**
* Does the timeline need the wide label column?
*
* Keyframed clips need it for their property-lane names. Audio GROUPS need it
* for the same reason a keyframed clip does a row whose name has nowhere else
* to go. A track row survives a narrow gutter because its CLIPS carry the name
* on the bar; a group row has no clips at all, so in the 80px gutter its label
* rendered at zero width and its solo, FX and lane buttons were clipped off the
* side.
*
* Widening the column for the whole timeline, rather than letting just the
* group row overhang: the header is sticky and opaque, so an oversized one
* painted a slab across the rest of its own row, stayed pinned there through
* horizontal scroll, and had the playhead drawn straight through it.
*/
export function timelineNeedsLabelColumn(
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
elements: readonly TimelineElement[],
): boolean {
return (
hasKeyframedTimelineClips(animationsByElement) ||
elements.some((element) => Boolean(element.audioGroup))
);
}
export function getEffectiveTimelineDuration(
duration: number,
elements: readonly TimelineElement[],
@@ -4,10 +4,10 @@ import {
createTimelineElementFromManifestClip,
parseTimelineFromDOM,
createImplicitTimelineLayersFromDOM,
invalidateGroupInfoCache,
mergeTimelineElementsPreservingDowngrades,
} from "./timelineDOM";
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
import { invalidateGroupInfoCache } from "./timelineGroupInfo";
import type { TimelineElement } from "../store/playerStore";
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
+1 -95
View File
@@ -12,8 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "./playbackTypes";
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
import { readClipTiming } from "@hyperframes/core/composition-contract";
import { HF_AUDIO_GROUP_ATTR, resolveAudioGroups } from "@hyperframes/core/audio-groups";
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
import { groupInfoFor } from "./timelineGroupInfo";
import {
resolveMediaElement,
applyMediaMetadataFromElement,
@@ -69,99 +68,6 @@ function resolveClipTag(clip: ClipManifestClip): string {
return clip.tagName || clip.kind || "div";
}
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
// walks the whole tree, and a parse touches every clip in it.
interface GroupInfo {
label: string;
volume: number;
hidden: boolean;
fxChain?: string;
}
/**
* The cached scan, plus the DOM revision it was taken at.
*
* Keeping the revision beside the entry is what makes staleness *detectable*
* rather than a documented obligation. The cache is keyed on the document, and
* group edits are applied as live patches precisely so the iframe never
* reloads, so the key alone never changes: an explicit "remember to invalidate"
* contract silently rots the first time a new writer forgets which is exactly
* what happened with the FX rack, whose group writes go through the DOM editor
* rather than the timeline's own writers.
*/
const groupInfoCache = new WeakMap<
Document,
{ revision: number; entries: Map<string, GroupInfo> }
>();
/** Bumped by every observed mutation to group state in a document. */
const groupRevisions = new WeakMap<Document, number>();
const groupObservers = new WeakSet<Document>();
/**
* Watch a document for any change to group state, so the cache expires itself.
*
* One observer per document, attached the first time a group is read from it.
* It watches the attributes a group's identity is made of, anywhere in the
* tree, plus added/removed nodes which covers a group element appearing, a
* member joining or leaving, and any group attribute being edited, by any
* writer, without that writer having to know this cache exists.
*/
function observeGroupState(doc: Document): void {
if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
groupObservers.add(doc);
const observer = new MutationObserver(() => {
groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
});
observer.observe(doc.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: [
HF_AUDIO_GROUP_ATTR,
HF_AUDIO_FX_ATTR,
"data-label",
"data-volume",
"data-hidden",
"id",
],
});
}
/**
* Drop the cached group scan for a document.
*
* Belt to the observer's braces: a caller that has just written and wants the
* very next read to be honest cannot wait for the observer's microtask. Callers
* that forget are no longer punished the revision check catches them.
*/
export function invalidateGroupInfoCache(doc: Document | null | undefined): void {
if (doc) groupInfoCache.delete(doc);
}
function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
if (!doc) return { label: groupId, volume: 1, hidden: false };
observeGroupState(doc);
const revision = groupRevisions.get(doc) ?? 0;
const cached = groupInfoCache.get(doc);
let info = cached && cached.revision === revision ? cached.entries : undefined;
if (!info) {
info = new Map(
resolveAudioGroups(doc).map((group) => [
group.id,
{
label: group.label,
volume: group.volume,
hidden: group.hidden,
...(group.fxChain ? { fxChain: group.fxChain } : {}),
},
]),
);
groupInfoCache.set(doc, { revision, entries: info });
}
return info.get(groupId) ?? { label: groupId, volume: 1, hidden: false };
}
// fallow-ignore-next-line complexity
export function createTimelineElementFromManifestClip(params: {
clip: ClipManifestClip;
@@ -0,0 +1,132 @@
/**
* The audio-group scan cached per preview document, and the mechanism that
* keeps it honest.
*
* Split out of `timelineDOM.ts` (600-line studio ceiling). Self-contained: the
* cache, its revision counter, the observer that bumps it, and the one reader.
*/
import {
HF_AUDIO_GROUP_ATTR,
HF_AUDIO_GROUP_TAG,
resolveAudioGroups,
} from "@hyperframes/core/audio-groups";
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
// walks the whole tree, and a parse touches every clip in it.
interface GroupInfo {
label: string;
volume: number;
hidden: boolean;
fxChain?: string;
}
/**
* The cached scan, plus the DOM revision it was taken at.
*
* Keeping the revision beside the entry is what makes staleness *detectable*
* rather than a documented obligation. The cache is keyed on the document, and
* group edits are applied as live patches precisely so the iframe never
* reloads, so the key alone never changes: an explicit "remember to invalidate"
* contract silently rots the first time a new writer forgets which is exactly
* what happened with the FX rack, whose group writes go through the DOM editor
* rather than the timeline's own writers.
*/
const groupInfoCache = new WeakMap<
Document,
{ revision: number; entries: Map<string, GroupInfo> }
>();
/** Bumped by every observed mutation to group state in a document. */
const groupRevisions = new WeakMap<Document, number>();
const groupObservers = new WeakSet<Document>();
/**
* Watch a document for any change to group state, so the cache expires itself.
*
* One observer per document, attached the first time a group is read from it.
* It watches the attributes a group's identity is made of, anywhere in the
* tree, plus added/removed nodes which covers a group element appearing, a
* member joining or leaving, and any group attribute being edited, by any
* writer, without that writer having to know this cache exists.
*/
function observeGroupState(doc: Document): void {
if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
groupObservers.add(doc);
const observer = new MutationObserver((records) => {
// `childList` fires for EVERY node added or removed anywhere in the live
// preview, which on a composition that churns nodes during playback
// (SplitText, a typewriter, anything runtime-inserted) would expire this
// cache permanently and put it back to one whole-tree scan per parse. Only
// a group ELEMENT appearing or leaving actually changes the answer, so
// childList records are filtered rather than trusted; attribute records
// always count, because the filter below already narrowed them.
const relevant = records.some(
(record) =>
record.type !== "childList" ||
[...record.addedNodes, ...record.removedNodes].some(
(node) =>
node instanceof Element &&
(node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
),
);
if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
});
observer.observe(doc.body, {
subtree: true,
childList: true,
attributes: true,
// Every attribute `resolveAudioGroups`/`buildGroup` reads, including
// `data-automation` — which `GroupInfo` does not cache TODAY, so omitting it
// was inert, but group automation lanes already exist and the first person
// to cache one would have got a silently never-firing observer. That is the
// precise rot this observer replaced.
attributeFilter: [
HF_AUDIO_GROUP_ATTR,
HF_AUDIO_FX_ATTR,
HF_AUDIO_AUTOMATION_ATTR,
"data-label",
"data-volume",
"data-hidden",
"id",
],
});
}
/**
* Drop the cached group scan for a document.
*
* Belt to the observer's braces: a caller that has just written and wants the
* very next read to be honest cannot wait for the observer's microtask. Callers
* that forget are no longer punished the revision check catches them.
*/
export function invalidateGroupInfoCache(doc: Document | null | undefined): void {
if (doc) groupInfoCache.delete(doc);
}
export function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
if (!doc) return { label: groupId, volume: 1, hidden: false };
observeGroupState(doc);
const revision = groupRevisions.get(doc) ?? 0;
const cached = groupInfoCache.get(doc);
let info = cached && cached.revision === revision ? cached.entries : undefined;
if (!info) {
info = new Map(
resolveAudioGroups(doc).map((group) => [
group.id,
{
label: group.label,
volume: group.volume,
hidden: group.hidden,
...(group.fxChain ? { fxChain: group.fxChain } : {}),
},
]),
);
groupInfoCache.set(doc, { revision, entries: info });
}
return info.get(groupId) ?? { label: groupId, volume: 1, hidden: false };
}