All files / src/web settings.js

100% Statements 68/68
100% Branches 38/38
100% Functions 10/10
100% Lines 58/58

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                4x   4x 4x 8x 8x     4x 183x   8x 129x       65x 29x   2x 2x 2x   1x 1x 1x   1x 1x 1x                       61x 61x 61x 61x   61x 61x 61x 1x     1x   61x   61x 61x 61x 61x 61x 61x 61x 61x 61x 61x   61x 61x 122x       1x         132x         48x 57x 7x 48x   57x 50x 49x 57x 38x 36x   55x 57x 48x      
import { DefaultModel, findModel } from "../models.js";
import { DefaultAudioOutput, isAudioOutput } from "../audio-output.js";
import { guessModelFromHostname } from "../url-params.js";
import { fittedRoms } from "./config.js";
import { MaxPersistenceMs, persistenceSettings } from "./canvas.js";
import { toast } from "./toast.js";
 
// Kept in browser storage for next time, as well as in the URL.
const StoredSettings = ["keyLayout", "displayMode", "audioOutput", "speakerAmount"];
// Set by sliders, so a burst of changes makes one history entry.
const SlidSettings = ["speakerAmount"];
for (const { setting } of persistenceSettings()) {
    StoredSettings.push(setting);
    SlidSettings.push(setting);
}
 
const storedNumber = (params, name, fallback) =>
    [params[name], parseFloat(window.localStorage[name])].find(Number.isFinite) ?? fallback;
 
const PersistenceDefaults = new Map(persistenceSettings().map(({ setting, default: fallback }) => [setting, fallback]));
const clampPersistence = (value) => Math.min(MaxPersistenceMs, Math.max(0, value));
 
/** The URL spellings of a model plus a fitting, from before fittings had settings of their own. */
export function mapLegacyModels(parsedQuery) {
    if (!parsedQuery.model) return;
    switch (parsedQuery.model.toLowerCase()) {
        case "masterturbo":
            parsedQuery.model = "Master";
            parsedQuery.coProcessor = true;
            break;
        case "bmusic5000":
            parsedQuery.model = "B-DFS1.2";
            parsedQuery.hasMusic5000 = true;
            break;
        case "bteletext":
            parsedQuery.model = "B-DFS1.2";
            parsedQuery.hasTeletextAdaptor = true;
            break;
    }
}
 
/**
 * The user's settings, resolved from the URL, browser storage and the
 * defaults. set() adopts a change, persists it and dispatches an event named
 * for each changed setting carrying its new value, then one "change" event
 * carrying them all; whatever a setting reaches subscribes with on().
 */
export class Settings extends EventTarget {
    constructor({ urlState }) {
        super();
        this.urlState = urlState;
        const params = urlState.params;
        mapLegacyModels(params);
 
        const requestedModelName = params.model || guessModelFromHostname(window.location.hostname);
        this.model = findModel(requestedModelName);
        if (!this.model) {
            toast(`There is no model called "${requestedModelName}". Using ${DefaultModel.name} instead.`, {
                title: "Model",
            });
            this.model = DefaultModel;
        }
        this.keyLayout =
            (params.keyLayout && `${params.keyLayout}`.toLowerCase()) || window.localStorage.keyLayout || "physical";
        this.tubeCpuMultiplier = params.tubeCpuMultiplier || 1;
        this.microphoneChannel = params.microphoneChannel;
        this.coProcessor = !!params.coProcessor;
        this.hasEconet = !!params.hasEconet;
        this.hasMusic5000 = !!params.hasMusic5000;
        this.hasTeletextAdaptor = !!params.hasTeletextAdaptor;
        this.mouseJoystickEnabled = !!params.mouseJoystickEnabled;
        this.speechOutput = !!params.speechOutput;
        this.displayMode = params.displayMode || window.localStorage.displayMode || "rgb";
        this.audioOutput =
            [params.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
        this.speakerAmount = storedNumber(params, "speakerAmount", 1);
        for (const { setting, default: fallback } of persistenceSettings())
            this[setting] = clampPersistence(storedNumber(params, setting, fallback));
    }
 
    get extraRoms() {
        return fittedRoms(this);
    }
 
    /** Calls `listener` with the new value whenever the setting `name` is set. */
    on(name, listener) {
        this.addEventListener(name, () => listener(this[name]));
    }
 
    /** Adopts `changes` (an undefined value clears a setting), persists them and tells the subscribers. */
    set(changes) {
        for (const name of Object.keys(changes))
            if (PersistenceDefaults.has(name) && changes[name] !== undefined)
                changes[name] = clampPersistence(changes[name]);
        for (const [name, value] of Object.entries(changes)) {
            // A cleared persistence is back at its display's default, though nothing remembers it.
            if (name === "model") this[name] = findModel(value) ?? this.model;
            else if (value === undefined && PersistenceDefaults.has(name)) this[name] = PersistenceDefaults.get(name);
            else this[name] = value;
            if (!StoredSettings.includes(name)) continue;
            if (value === undefined) window.localStorage.removeItem(name);
            else window.localStorage[name] = value;
        }
        this.urlState.set(changes, { settle: Object.keys(changes).some((name) => SlidSettings.includes(name)) });
        for (const name of Object.keys(changes)) this.dispatchEvent(new Event(name));
        this.dispatchEvent(new CustomEvent("change", { detail: changes }));
    }
}