Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests. Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs. Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
153 lines
3.6 KiB
TypeScript
153 lines
3.6 KiB
TypeScript
/**
|
|
* Looping background music — drop your MP3 at public/audio/ambient.mp3
|
|
* (MP3 preferred; OGG/WAV also work if you update AMBIENT_MUSIC_SRC).
|
|
*/
|
|
export const BGM_STORAGE_KEY = 'aetherforge-bgm';
|
|
export const BGM_VOLUME_KEY = 'aetherforge-bgm-volume';
|
|
/** Served from Vite public/ — place ambient.mp3 here before enabling in Settings. */
|
|
export const AMBIENT_MUSIC_SRC = '/audio/ambient.mp3';
|
|
|
|
export function loadBgmEnabled(): boolean {
|
|
try {
|
|
const v = localStorage.getItem(BGM_STORAGE_KEY);
|
|
return v === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function loadBgmVolume(): number {
|
|
try {
|
|
const v = localStorage.getItem(BGM_VOLUME_KEY);
|
|
if (v === null) return 0.22;
|
|
const n = parseFloat(v);
|
|
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.22;
|
|
} catch {
|
|
return 0.22;
|
|
}
|
|
}
|
|
|
|
function persistBgmEnabled(enabled: boolean) {
|
|
try {
|
|
localStorage.setItem(BGM_STORAGE_KEY, enabled ? '1' : '0');
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function persistBgmVolume(volume: number) {
|
|
try {
|
|
localStorage.setItem(BGM_VOLUME_KEY, String(volume));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export class AmbientMusicPlayer {
|
|
private audio: HTMLAudioElement | null = null;
|
|
private enabled = loadBgmEnabled();
|
|
private volume = loadBgmVolume();
|
|
private unlocked = false;
|
|
private playing = false;
|
|
private listeners = new Set<(playing: boolean) => void>();
|
|
|
|
isEnabled() {
|
|
return this.enabled;
|
|
}
|
|
|
|
isPlaying() {
|
|
return this.playing;
|
|
}
|
|
|
|
getVolume() {
|
|
return this.volume;
|
|
}
|
|
|
|
subscribe(fn: (playing: boolean) => void) {
|
|
this.listeners.add(fn);
|
|
return () => { this.listeners.delete(fn); };
|
|
}
|
|
|
|
private setPlaying(v: boolean) {
|
|
if (this.playing === v) return;
|
|
this.playing = v;
|
|
for (const fn of this.listeners) fn(v);
|
|
}
|
|
|
|
setEnabled(enabled: boolean) {
|
|
this.enabled = enabled;
|
|
persistBgmEnabled(enabled);
|
|
if (enabled) {
|
|
this.ensureAudio();
|
|
void this.tryPlay();
|
|
} else {
|
|
this.pause();
|
|
}
|
|
}
|
|
|
|
setVolume(volume: number) {
|
|
this.volume = Math.min(1, Math.max(0, volume));
|
|
persistBgmVolume(this.volume);
|
|
if (this.audio) this.audio.volume = this.volume;
|
|
}
|
|
|
|
/** Browsers block autoplay until a user gesture unlocks audio. */
|
|
unlock() {
|
|
if (this.unlocked) return;
|
|
this.unlocked = true;
|
|
this.ensureAudio();
|
|
if (this.enabled) void this.tryPlay();
|
|
}
|
|
|
|
togglePlay() {
|
|
if (this.playing) {
|
|
this.pause();
|
|
return false;
|
|
}
|
|
if (!this.enabled) {
|
|
this.setEnabled(true);
|
|
}
|
|
void this.tryPlay();
|
|
return true;
|
|
}
|
|
|
|
private ensureAudio() {
|
|
if (this.audio || typeof document === 'undefined') return;
|
|
const el = new Audio(AMBIENT_MUSIC_SRC);
|
|
el.loop = true;
|
|
el.preload = 'auto';
|
|
el.volume = this.volume;
|
|
el.addEventListener('play', () => this.setPlaying(true));
|
|
el.addEventListener('pause', () => this.setPlaying(false));
|
|
el.addEventListener('ended', () => this.setPlaying(false));
|
|
el.addEventListener('error', () => {
|
|
this.setPlaying(false);
|
|
});
|
|
this.audio = el;
|
|
}
|
|
|
|
private pause() {
|
|
if (!this.audio) return;
|
|
this.audio.pause();
|
|
this.setPlaying(false);
|
|
}
|
|
|
|
async tryPlay(): Promise<boolean> {
|
|
if (!this.enabled) return false;
|
|
this.ensureAudio();
|
|
if (!this.audio) return false;
|
|
this.audio.volume = this.volume;
|
|
try {
|
|
await this.audio.play();
|
|
this.setPlaying(true);
|
|
return true;
|
|
} catch {
|
|
this.setPlaying(false);
|
|
/* Autoplay policy — wait for Settings toggle or first click */
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const ambientMusicPlayer = new AmbientMusicPlayer();
|