Skip to content
11 changes: 8 additions & 3 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -339,13 +339,15 @@
// kept rendering finger spheres). shouldSendHands forces it on a rep flip.
const lens = [left?.joints?.length ?? 0, right?.joints?.length ?? 0];
if (!shouldSendHands({ moved, hasJoints, prevLens: lastSentJointLens, lens })) return;
// 60 Q2: a hands<->controllers switch is state (reliable); a pose of the same rep is stream
const sameRep = !!lastSentJointLens && lastSentJointLens[0] === lens[0] && lastSentJointLens[1] === lens[1];
lastSentJointLens = lens;
lastHandsSendAt = now;
for (let i = 0; i < 2; i++) {
const pose = poses[i];
if (pose) lastHandPositions[i].fromArray(pose.pos);
}
$peers.send({ type: 'vrhands', peerId: $peers.peer.id, left, right, active: true });
$peers.send({ type: 'vrhands', peerId: $peers.peer.id, left, right, active: true }, { fast: sameRep });
}

useTask((delta) => {
Expand Down Expand Up @@ -414,7 +416,9 @@
nowMs - lastCameraSendAt >= camGapMs) {
lastCameraSendAt = nowMs;
camContentEuler.setFromQuaternion(camContentQuat);
$peers.send({ type: 'camera', peerId: $peers.peer.id, position: camContentPos.toArray(), rotation: [camContentEuler.x, camContentEuler.y, camContentEuler.z], ...camExtras });
// 60 Q2: a pose of the stream rides the fast channel; a change of the extras (seat,
// feet, knocked…) is state and goes reliable, or a lost datagram could strand it
$peers.send({ type: 'camera', peerId: $peers.peer.id, position: camContentPos.toArray(), rotation: [camContentEuler.x, camContentEuler.y, camContentEuler.z], ...camExtras }, { fast: camExtrasKey === lastCameraExtras });
lastCameraExtras = camExtrasKey;
lastCameraPosition.copy(camContentPos);
lastCameraQuaternion.copy(camContentQuat);
Expand Down Expand Up @@ -1732,13 +1736,14 @@
dropToSurface($TControls.object, $objectsGroup);
}
$TControls.visible = true;
// 60 Q2: the gizmo drag is a stream (fast); its keyframe lands the last pose
$peers.send({
type: 'move',
uuid: $TControls.object.uuid,
pos: $TControls.object.position.toArray(),
rot: $TControls.object.rotation.toArray(),
scale: $TControls.object.scale.toArray()
});
}, { fast: true });
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/components/menu/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import ShortcutsSettings from './settings/ShortcutsSettings.svelte';
import SceneSettings from './settings/SceneSettings.svelte'; // checkpoints, loading, water render inside
import ExplorerSettings from './settings/ExplorerSettings.svelte';
import StorageSettings from './settings/StorageSettings.svelte'; // 60 Q6: the local asset cache
import NodeTypesSection from './NodeTypesSection.svelte'; // 36 B7
// 36-export: the export defaults (its own file; also the Publish / Export modal's Settings tab)
import ExportSettingsSection from './ExportSettingsSection.svelte';
Expand Down Expand Up @@ -84,6 +85,8 @@
$: if ((settingsQuery || '').trim() || ($settingsOpen && $settingsSection === 'accessibility')) accessibilityExpanded = true;
let sceneExpanded = false;
let explorerExpanded = false;
let storageExpanded = false; // 60 Q6: Settings ▸ Storage (StorageSettings.svelte)
$: if ((settingsQuery || '').trim() || ($settingsOpen && $settingsSection === 'storage')) storageExpanded = true;
let connectionExpanded = false;
let aboutExpanded = false;
let vrExpanded = false; // D7: edit-cap toasts deep-link here ('vr')
Expand Down Expand Up @@ -562,6 +565,10 @@
{#snippet header()}Explorer{/snippet}
<ExplorerSettings />
</AccordionItem>
<AccordionItem bind:open={storageExpanded}>
{#snippet header()}Storage{/snippet}
<StorageSettings />
</AccordionItem>
<AccordionItem bind:open={vrExpanded}>
{#snippet header()}VR{/snippet}
<VRSettingsSection />
Expand Down
167 changes: 167 additions & 0 deletions src/components/menu/settings/StorageSettings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<script module>
import { registerSettingsKeywords } from '$lib/settingsSearch';
const ROW_WORDS = {
'cached downloads': [
'cache',
'opfs',
'offline',
'packs',
'skies',
'hdri',
'textures',
'downloads',
'clear'
],
'cache size limit': ['cap', 'limit', 'quota', 'lru', 'size'],
'keep on this device': ['persist', 'persistent', 'eviction', 'storage'],
'storage breakdown': ['disk', 'space', 'quota', 'storage']
};
for (const [row, words] of Object.entries(ROW_WORDS)) registerSettingsKeywords(row, words);
</script>

<script>
// 60 Q6 — Settings ▸ Storage: the LOCAL ASSET CACHE (assetCache.js, OPFS) — what it holds,
// its size limit, Clear, and whether the browser was asked to keep it. LOCAL, never replicated.
import { onMount } from 'svelte';
import Section from '../../ui/Section.svelte';
import SettingRow from '../../ui/SettingRow.svelte';
import Segmented from '../../ui/Segmented.svelte';
import Button from '../../ui/Button.svelte';
import { settingsOpen } from '../../../stores/appStore.js';
import {
assetCacheIndex,
assetCacheBackend,
assetCacheCapMB,
assetCacheReady,
clearAssetCache,
persistState,
refreshPersistState,
requestPersist
} from '$lib/assetCache';
import { usageOf, CAP_CHOICES_MB } from '$lib/assetCacheCore.js';
import { fmtBytes, openStorageModal } from '$lib/storageUsage';

const CAPS = CAP_CHOICES_MB.map((mb) => ({
value: String(mb),
label: mb >= 1024 ? mb / 1024 + ' GB' : mb + ' MB'
}));
const KIND_LABELS = /** @type {Record<string, string>} */ ({
pack: 'packs',
sky: 'skies',
texture: 'converted textures',
import: 'downloads'
});

const usage = $derived(usageOf($assetCacheIndex));
const parts = $derived(
Object.entries(usage.byKind)
.filter(([, b]) => b > 0)
.map(([k, b]) => KIND_LABELS[k] + ' ' + fmtBytes(b))
.join(' · ')
);
let busy = $state(false);
let cleared = $state('');

onMount(() => {
void assetCacheReady();
void refreshPersistState();
});

async function clear() {
busy = true;
try {
const freed = await clearAssetCache();
cleared = 'Freed ' + fmtBytes(freed) + '.';
} finally {
busy = false;
}
}
</script>

<div class="settings-page-body" data-keywords="storage cache offline opfs">
<Section variant="card" label="Asset cache" badge="This device">
<SettingRow id="row-asset-cache" label="Cached downloads" wide>
{#snippet desc()}
<span id="asset-cache-usage">
{#if $assetCacheBackend === 'none'}
This browser gives the page no storage to keep files in, so everything is downloaded
each time.
{:else}
{fmtBytes(usage.bytes)} in {usage.blobs}
{usage.blobs === 1 ? 'file' : 'files'}{parts ? ' — ' + parts : ''}. Packs, skies,
converted textures and downloads are kept so they load again without the network. The
app itself is never kept here, so updates always arrive.
{/if}
{#if cleared}<span class="asset-cache-cleared"> {cleared}</span>{/if}
</span>
{/snippet}
<Button
id="asset-cache-clear"
size="sm"
variant="outline"
disabled={busy || usage.keys === 0}
onclick={clear}>Clear</Button
>
</SettingRow>
<SettingRow
id="row-asset-cache-cap"
label="Cache size limit"
description="When the cache grows past this, the files used longest ago are removed first."
wide
>
<Segmented
id="asset-cache-cap"
label="Cache size limit"
options={CAPS}
value={String($assetCacheCapMB)}
onchange={(v) => assetCacheCapMB.set(Number(v))}
/>
</SettingRow>
<SettingRow id="row-asset-cache-persist" label="Keep on this device" wide>
{#snippet desc()}
<span id="asset-cache-persist-state">
{#if $persistState === 'granted'}
The browser keeps this storage even when it is short of space.
{:else if $persistState === 'unsupported'}
This browser decides by itself when to clear site storage.
{:else}
The browser may clear the cache when it is short of space. Asking lets it keep it; some
browsers decide by how often you visit.
{/if}
</span>
{/snippet}
{#if $persistState === 'not-granted'}
<Button
id="asset-cache-persist"
size="sm"
variant="outline"
onclick={() => void requestPersist()}>Ask the browser</Button
>
{/if}
</SettingRow>
<SettingRow
id="row-storage-breakdown"
label="Storage breakdown"
description="Everything this app keeps on the device, with ticks to reclaim what you no longer want."
>
<Button
id="settings-storage-breakdown"
size="sm"
variant="outline"
onclick={() => {
settingsOpen.set(false);
openStorageModal();
}}>Show breakdown</Button
>
</SettingRow>
</Section>
</div>

<style>
.settings-page-body {
display: contents;
}
.asset-cache-cleared {
color: var(--text-muted);
}
</style>
10 changes: 6 additions & 4 deletions src/lib/animatedImports.js
Original file line number Diff line number Diff line change
Expand Up @@ -481,10 +481,12 @@ async function fetchAnimRef(ref) {
const url = packRefUrl(ref);
let job = refFiles.get(url);
if (!job) {
job = fetch(url).then((res) => {
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url);
return res.arrayBuffer();
});
// 60 Q6: a pack file like any other — through the pack cache (the local asset cache)
job = import('./packCache').then((m) =>
m.fetchPackBuffer(url).catch((/** @type {any} */ e) => {
throw new Error(String(e?.message ?? e) + ' ' + url);
})
);
refFiles.set(url, job);
job.catch(() => refFiles.delete(url));
}
Expand Down
Loading
Loading