diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index b3b95c4..9c39caf 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -11,6 +11,7 @@ import { IdentityProvider, useIdentityQuery } from '@/lib/identity';
import { LayoutProvider } from '@/lib/layout';
import { PiggyContextProvider } from '@/lib/piggy-context';
import { PlatformAudioProvider } from '@/lib/audio';
+import { AudioControl } from '@/components/AudioControl';
import { Shell } from '@/components/Shell';
import { SignIn } from '@/pages/SignIn';
import { CreateProfile } from '@/pages/CreateProfile';
@@ -106,6 +107,28 @@ export function App() {
);
}
+/**
+ * The signed-out screens render outside Shell, so they get no app header — and
+ * therefore no way to stop the music. Audio a visitor cannot switch off is the
+ * worst version of this feature, so every unauthenticated screen gets the
+ * control pinned in a corner.
+ *
+ * Learn is not wrapped: it brings its own chrome and already hosts one.
+ */
+function SignedOut({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+ {children}
+
+ >
+ );
+}
+
/**
* Decides what to show based on *why* a request failed.
*
@@ -165,24 +188,32 @@ function AuthGate({ config }: { config: PublicConfig }) {
);
}
- return showRegister ? (
- setShowRegister(false)}
- onRegistered={() => {
- setShowRegister(false);
- void refetch();
- }}
- />
- ) : (
- setShowRegister(true)} />
+ return (
+
+ {showRegister ? (
+ setShowRegister(false)}
+ onRegistered={() => {
+ setShowRegister(false);
+ void refetch();
+ }}
+ />
+ ) : (
+ setShowRegister(true)} />
+ )}
+
);
}
// Authenticated but not a member. This is a step in the flow, not an
// error — sending them back to a login screen they have already completed
// would be a loop with no exit.
if (error.needsProfile) {
- return void refetch()} />;
+ return (
+
+ void refetch()} />
+
+ );
}
}
diff --git a/apps/web/src/lib/audio.tsx b/apps/web/src/lib/audio.tsx
index 9f06f9b..aacfb26 100644
--- a/apps/web/src/lib/audio.tsx
+++ b/apps/web/src/lib/audio.tsx
@@ -52,6 +52,33 @@ const TRACK_KEY = 'pig.audio.track';
*/
const VOLUME = 0.14;
+/**
+ * Ramp length. Music that snaps to full volume on the first click reads as a
+ * glitch; a second of ramp reads as the room having been there all along.
+ * Short enough that muting feels immediate.
+ */
+const FADE_IN_MS = 1100;
+const FADE_OUT_MS = 420;
+
+/**
+ * Ease the element's volume, resolving when it arrives. Returns a canceller so
+ * a fade that is superseded (mute pressed mid-fade-in) stops rather than
+ * fighting the new one.
+ */
+function ramp(audio: HTMLAudioElement, to: number, ms: number): () => void {
+ const from = audio.volume;
+ const started = performance.now();
+ let frame = 0;
+ const step = () => {
+ const t = Math.min(1, (performance.now() - started) / ms);
+ // easeOutQuad: most of the travel early, so a fade-out feels prompt.
+ audio.volume = Math.max(0, Math.min(1, from + (to - from) * (1 - (1 - t) * (1 - t))));
+ if (t < 1) frame = requestAnimationFrame(step);
+ };
+ frame = requestAnimationFrame(step);
+ return () => cancelAnimationFrame(frame);
+}
+
function storedTrack(): PlatformTrackId {
try {
const raw = localStorage.getItem(TRACK_KEY);
@@ -96,20 +123,32 @@ export function PlatformAudioProvider({ children }: { children: ReactNode }) {
if (!audio) return;
if (!enabled) {
- audio.pause();
+ const cancel = ramp(audio, 0, FADE_OUT_MS);
+ const stop = window.setTimeout(() => {
+ audio.pause();
+ audio.currentTime = 0;
+ }, FADE_OUT_MS);
setPlaying(false);
- return;
+ return () => {
+ cancel();
+ window.clearTimeout(stop);
+ };
}
- audio.volume = VOLUME;
+ // Start silent so the ramp has somewhere to come from.
+ audio.volume = 0;
let cancelled = false;
let armed: (() => void) | null = null;
+ let cancelRamp: (() => void) | null = null;
const start = () =>
audio
.play()
.then(() => {
- if (!cancelled) setPlaying(true);
+ if (cancelled) return;
+ setPlaying(true);
+ cancelRamp?.();
+ cancelRamp = ramp(audio, VOLUME, FADE_IN_MS);
})
.catch(() => false);
@@ -134,6 +173,7 @@ export function PlatformAudioProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
armed?.();
+ cancelRamp?.();
};
}, [enabled, source.src]);