jsbeeb 1.22.0 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,358 @@
1
+ import * as utils from "../utils.js";
2
+ import * as disc from "../fdc.js";
3
+ import { DiscLayout } from "../disc.js";
4
+ import { loadTapeFromData } from "../tapes.js";
5
+ import { toast } from "./toast.js";
6
+ import { errorText, reportIgnoredFiles, reportLoadFailure, unzipAndReport } from "./reporting.js";
7
+
8
+ /** The images offered on the Discs dialog's built-in list. */
9
+ export const BuiltInImages = [
10
+ {
11
+ name: "Elite",
12
+ desc: "An 8-bit classic. Hit F10 to launch from the space station, then use <, >, S, X and A to fly around.",
13
+ file: "elite.ssd",
14
+ },
15
+ {
16
+ name: "Welcome",
17
+ desc: "The disc supplied with BBC Disc systems to demonstrate some of the features of the system.",
18
+ file: "Welcome.ssd",
19
+ },
20
+ {
21
+ name: "Music 5000",
22
+ desc: "The Music 5000 system disk and demo songs.",
23
+ file: "5000mstr36008.ssd",
24
+ },
25
+ ];
26
+
27
+ export function splitImage(image) {
28
+ const match = image.match(/(([^:]+):\/?\/?|[!^|])?(.*)/);
29
+ const schema = match[2] || match[1] || "";
30
+ image = match[3];
31
+ return { image: image, schema: schema };
32
+ }
33
+
34
+ function readFileAsBinaryString(file) {
35
+ return new Promise((resolve, reject) => {
36
+ const reader = new FileReader();
37
+ reader.onload = (e) => {
38
+ resolve(e.target.result);
39
+ };
40
+ reader.onerror = (e) => {
41
+ console.error(`Error reading file ${file.name}:`, e);
42
+ reject(new Error(`Failed to read file ${file.name}`));
43
+ };
44
+ reader.readAsBinaryString(file);
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Getting discs and tapes into the machine: resolving any image reference the
50
+ * URL schema can name, the local file inputs, the drop zone and the built-in
51
+ * list. Choosing what goes in a drive funnels through drives.putDiscIn.
52
+ */
53
+ export class MediaLoader {
54
+ /**
55
+ * @param {object} deps
56
+ * @param {object} deps.sources fetchers keyed by schema: sth, tapeSth and hfe
57
+ * resolve an archive name to image data; drive loads a Google Drive file
58
+ * @param {Function} deps.isSnapshotFile says whether a dropped file is a save state
59
+ * @param {Function} deps.loadSnapshot restores a dropped save state
60
+ */
61
+ constructor({ processor, model, drives, urlState, config, modals, sources, isSnapshotFile, loadSnapshot }) {
62
+ this.processor = processor;
63
+ this.model = model;
64
+ this.drives = drives;
65
+ this.urlState = urlState;
66
+ this.config = config;
67
+ this.modals = modals;
68
+ this.sources = sources;
69
+
70
+ document.getElementById("disc_load").addEventListener("change", async (evt) => {
71
+ if (evt.target.files.length === 0) return;
72
+ utils.noteEvent("local", "click"); // NB no filename here
73
+ const file = evt.target.files[0];
74
+ try {
75
+ await this.loadHTMLFile(file);
76
+ } catch (error) {
77
+ reportLoadFailure(file.name, error);
78
+ }
79
+ evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
80
+ });
81
+
82
+ document.getElementById("fs_load").addEventListener("change", async (evt) => {
83
+ if (evt.target.files.length === 0) return;
84
+ utils.noteEvent("local", "click"); // NB no filename here
85
+ const file = evt.target.files[0];
86
+ try {
87
+ await this.loadSCSIFile(file);
88
+ } catch (error) {
89
+ reportLoadFailure(file.name, error);
90
+ }
91
+ evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
92
+ });
93
+
94
+ document.getElementById("tape_load").addEventListener("change", async (evt) => {
95
+ if (evt.target.files.length === 0) return;
96
+ const file = evt.target.files[0];
97
+ utils.noteEvent("local", "clickTape"); // NB no filename here
98
+
99
+ try {
100
+ let tapeData = await readFileAsBinaryString(file);
101
+ let tapeName = file.name;
102
+ if (/\.zip/i.test(tapeName)) {
103
+ const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
104
+ tapeData = unzipped.data;
105
+ tapeName = unzipped.name;
106
+ }
107
+ this.setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
108
+ delete this.params.tape;
109
+ urlState.updateUrl();
110
+ modals.hide("tapes");
111
+ } catch (error) {
112
+ reportLoadFailure(file.name, error);
113
+ }
114
+
115
+ evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
116
+ });
117
+
118
+ const pastetext = document.getElementById("paste-text");
119
+ pastetext.addEventListener("dragover", (event) => {
120
+ event.preventDefault();
121
+ event.stopPropagation();
122
+ event.dataTransfer.dropEffect = "copy";
123
+ });
124
+ pastetext.addEventListener("drop", async (event) => {
125
+ utils.noteEvent("local", "drop");
126
+ const file = event.dataTransfer.files[0];
127
+ if (!file) return;
128
+ try {
129
+ const arrayBuffer = await file.arrayBuffer();
130
+ if (isSnapshotFile(file.name, arrayBuffer)) {
131
+ await loadSnapshot(file, arrayBuffer);
132
+ } else if (file.name.toLowerCase().endsWith(".uef")) {
133
+ // Regular UEF tape image (not a BeebEm save state)
134
+ this.setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
135
+ toast(`Loaded ${file.name} as the tape.`, { title: "Dropped" });
136
+ } else {
137
+ await this.loadHTMLFile(file);
138
+ toast(`Loaded ${file.name} into drive 0.`, { title: "Dropped" });
139
+ }
140
+ } catch (error) {
141
+ reportLoadFailure(file.name, error);
142
+ }
143
+ });
144
+
145
+ const discList = document.getElementById("disc-list");
146
+ const discTemplate = discList.querySelector(".template");
147
+ for (const image of BuiltInImages) {
148
+ const elem = discTemplate.cloneNode(true);
149
+ elem.classList.remove("template");
150
+ discList.appendChild(elem);
151
+ elem.querySelector(".name").textContent = image.name;
152
+ elem.querySelector(".description").textContent = image.desc;
153
+ elem.addEventListener("click", async () => {
154
+ utils.noteEvent("images", "click", image.file);
155
+ this.setDisc1Image(image.file);
156
+ modals.hide("discs");
157
+ try {
158
+ drives.putDiscIn(0, await this.loadDiscImage(this.params.disc1, drives.layoutForDrive(0)));
159
+ } catch (error) {
160
+ reportLoadFailure(`${image.name} (${image.file})`, error);
161
+ }
162
+ });
163
+ }
164
+ }
165
+
166
+ get params() {
167
+ return this.urlState.params;
168
+ }
169
+
170
+ /** Route tape to the correct interface (ACIA for BBC, PPIA for Atom) */
171
+ setProcessorTape(tape) {
172
+ if (this.model.isAtom) {
173
+ this.processor.atomppia.setTape(tape);
174
+ } else {
175
+ this.processor.acia.setTape(tape);
176
+ }
177
+ }
178
+
179
+ setDisc1Image(name) {
180
+ delete this.params.disc;
181
+ this.params.disc1 = name;
182
+ this.urlState.updateUrl();
183
+ this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
184
+ }
185
+
186
+ setDisc2Image(name) {
187
+ this.params.disc2 = name;
188
+ this.urlState.updateUrl();
189
+ this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
190
+ }
191
+
192
+ setTapeImage(name) {
193
+ this.params.tape = name;
194
+ this.urlState.updateUrl();
195
+ this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
196
+ }
197
+
198
+ async loadHTMLFile(file) {
199
+ const imageData = utils.stringToUint8Array(await readFileAsBinaryString(file));
200
+ const loadedDisc = disc.discFor(
201
+ this.processor.fdc,
202
+ file.name,
203
+ imageData,
204
+ undefined,
205
+ this.drives.layoutForDrive(0),
206
+ );
207
+ // Local file: retain the image bytes for embedding in save-to-file snapshots.
208
+ loadedDisc.setOriginalImage(imageData);
209
+ this.drives.putDiscIn(0, loadedDisc);
210
+ delete this.params.disc;
211
+ delete this.params.disc1;
212
+ this.urlState.updateUrl();
213
+ this.modals.hide("discs");
214
+ }
215
+
216
+ async loadSCSIFile(file) {
217
+ const binaryData = await readFileAsBinaryString(file);
218
+ const { processor } = this;
219
+ processor.filestore.scsi = utils.stringToUint8Array(binaryData);
220
+
221
+ processor.filestore.PC = 0x400;
222
+ processor.filestore.SP = 0xff;
223
+ processor.filestore.A = 1;
224
+ processor.filestore.emulationSpeed = 0;
225
+
226
+ // Reset any open receive blocks
227
+ processor.econet.receiveBlocks = [];
228
+ processor.econet.nextReceiveBlockNumber = 1;
229
+
230
+ this.modals.hide("econetfs");
231
+ }
232
+
233
+ async loadDiscImage(discImage, layout = DiscLayout.auto) {
234
+ if (!discImage) return null;
235
+ const split = splitImage(discImage);
236
+ discImage = split.image;
237
+ const schema = split.schema;
238
+ if (schema[0] === "!" || schema === "local") {
239
+ return disc.localDisc(this.processor.fdc, discImage, layout, (error) =>
240
+ toast(
241
+ `Browser storage would not take changes to ${discImage} (${errorText(error)}). Use Discs, Download to keep a copy.`,
242
+ { title: "Disc", quietKey: "quietLocalDiscSaveFailed" },
243
+ ),
244
+ );
245
+ }
246
+ // TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
247
+ // Consider:
248
+ // * hashing contents and making a local disc image named by original disc hash, save by that, and offer
249
+ // to load the modified disc on load.
250
+ // * popping up a message that notes the disc has changed, and offers a way to make a local image
251
+ // * Dialog box (ugh) saying "is this ok?"
252
+ switch (schema) {
253
+ case "|":
254
+ case "sth": {
255
+ const { name, data, ignored } = await this.sources.sth(discImage);
256
+ reportIgnoredFiles(name, ignored);
257
+ return disc.discFor(this.processor.fdc, name, data, undefined, layout);
258
+ }
259
+
260
+ case "hfe":
261
+ return disc.discFor(
262
+ this.processor.fdc,
263
+ discImage,
264
+ await this.sources.hfe(discImage),
265
+ undefined,
266
+ layout,
267
+ );
268
+
269
+ case "gd": {
270
+ const splat = discImage.match(/([^/]+)\/?(.*)/);
271
+ let name = "(unknown)";
272
+ if (splat) {
273
+ discImage = splat[1];
274
+ name = splat[2];
275
+ }
276
+ return this.sources.drive({ name, id: discImage }, layout);
277
+ }
278
+ case "b64data":
279
+ return disc.discFor(this.processor.fdc, "disk.ssd", atob(discImage), undefined, layout);
280
+
281
+ case "data": {
282
+ const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
283
+ const { name, data } = await unzipAndReport(arr);
284
+ return disc.discFor(this.processor.fdc, name, data, undefined, layout);
285
+ }
286
+ case "http":
287
+ case "https":
288
+ case "file": {
289
+ const asUrl = `${schema}://${discImage}`;
290
+ // url may end in query params etc, which can upset the DSD/SSD etc detection on the extension.
291
+ discImage = new URL(asUrl).pathname;
292
+ let discData = await utils.loadData(asUrl);
293
+ if (/\.zip/i.test(discImage)) {
294
+ const unzipped = await unzipAndReport(discData);
295
+ discData = unzipped.data;
296
+ discImage = unzipped.name;
297
+ }
298
+ return disc.discFor(this.processor.fdc, discImage, discData, undefined, layout);
299
+ }
300
+ default:
301
+ return disc.discFor(
302
+ this.processor.fdc,
303
+ discImage,
304
+ await disc.load("discs/" + discImage),
305
+ undefined,
306
+ layout,
307
+ );
308
+ }
309
+ }
310
+
311
+ async loadTapeImage(tapeImage) {
312
+ const split = splitImage(tapeImage);
313
+ tapeImage = split.image;
314
+ const schema = split.schema;
315
+
316
+ switch (schema) {
317
+ case "|":
318
+ case "sth": {
319
+ const { name, data, ignored } = await this.sources.tapeSth(tapeImage);
320
+ reportIgnoredFiles(name, ignored);
321
+ return await loadTapeFromData(name, data, this.model);
322
+ }
323
+
324
+ case "data": {
325
+ const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
326
+ const { name, data } = await unzipAndReport(arr);
327
+ return await loadTapeFromData(name, data, this.model);
328
+ }
329
+
330
+ case "http":
331
+ case "https":
332
+ case "file": {
333
+ const asUrl = `${schema}://${tapeImage}`;
334
+ // url may end in query params etc, which can upset file handling
335
+ tapeImage = new URL(asUrl).pathname;
336
+ let tapeData = await utils.loadData(asUrl);
337
+ if (/\.zip/i.test(tapeImage)) {
338
+ const unzipped = await unzipAndReport(tapeData);
339
+ tapeData = unzipped.data;
340
+ tapeImage = unzipped.name;
341
+ }
342
+ return await loadTapeFromData(tapeImage, tapeData, this.model);
343
+ }
344
+
345
+ default: {
346
+ const tapePath = "tapes/" + tapeImage;
347
+ let tapeData = await utils.loadData(tapePath);
348
+ let tapeName = tapeImage;
349
+ if (/\.zip/i.test(tapeName)) {
350
+ const unzipped = await unzipAndReport(tapeData);
351
+ tapeData = unzipped.data;
352
+ tapeName = unzipped.name;
353
+ }
354
+ return await loadTapeFromData(tapeName, tapeData, this.model);
355
+ }
356
+ }
357
+ }
358
+ }
@@ -0,0 +1,83 @@
1
+ import * as bootstrap from "bootstrap";
2
+ import { toast } from "./toast.js";
3
+
4
+ /**
5
+ * The dialogs the page raises itself, and the rule that a dialog pauses the
6
+ * emulator: the first one up stops it, and the last one down starts it again
7
+ * if it was running before.
8
+ */
9
+ export class Modals {
10
+ constructor({ isRunning, stop, go }) {
11
+ this.errorDialog = document.getElementById("error-dialog");
12
+ this.errorModal = new bootstrap.Modal(this.errorDialog);
13
+ this.loadingDialog = document.getElementById("loading-dialog");
14
+ this.loadingModal = new bootstrap.Modal(this.loadingDialog);
15
+ this.googleDriveAuth = document.getElementById("google-drive-auth");
16
+ this.aysEl = document.getElementById("are-you-sure");
17
+ this.aysModal = new bootstrap.Modal(this.aysEl);
18
+
19
+ let savedRunning = false;
20
+ document.addEventListener("show.bs.modal", () => {
21
+ if (!this.anyVisible()) savedRunning = isRunning();
22
+ if (isRunning()) stop(false);
23
+ });
24
+ document.addEventListener("hidden.bs.modal", () => {
25
+ if (!this.anyVisible() && savedRunning) go();
26
+ });
27
+ }
28
+
29
+ anyVisible() {
30
+ return document.querySelectorAll(".modal.show").length !== 0;
31
+ }
32
+
33
+ show(id) {
34
+ const el = document.getElementById(id);
35
+ if (el) bootstrap.Modal.getOrCreateInstance(el).show();
36
+ }
37
+
38
+ hide(id) {
39
+ const el = document.getElementById(id);
40
+ if (el) bootstrap.Modal.getInstance(el)?.hide();
41
+ }
42
+
43
+ showError(context, error) {
44
+ this.errorDialog.querySelector(".context").textContent = context;
45
+ this.errorDialog.querySelector(".error").textContent = error;
46
+ this.errorModal.show();
47
+ }
48
+
49
+ popupLoading(msg) {
50
+ this.loadingDialog.querySelector(".loading").textContent = msg;
51
+ this.googleDriveAuth.style.display = "none";
52
+ this.loadingModal.show();
53
+ }
54
+
55
+ loadingFinished(message) {
56
+ this.googleDriveAuth.style.display = "none";
57
+ this.loadingModal.hide();
58
+ if (message) toast(message);
59
+ }
60
+
61
+ areYouSure(message, yesText, noText, yesFunc) {
62
+ const yesButton = this.aysEl.querySelector(".ays-yes");
63
+ this.aysEl.querySelector(".context").textContent = message;
64
+ this.aysEl.querySelector(".ays-no").textContent = noText;
65
+ yesButton.textContent = yesText;
66
+ let confirmed = false;
67
+ const onYes = () => {
68
+ confirmed = true;
69
+ this.aysModal.hide();
70
+ };
71
+ yesButton.addEventListener("click", onYes, { once: true });
72
+ // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
73
+ this.aysEl.addEventListener(
74
+ "hidden.bs.modal",
75
+ () => {
76
+ yesButton.removeEventListener("click", onYes);
77
+ if (confirmed) yesFunc();
78
+ },
79
+ { once: true },
80
+ );
81
+ this.aysModal.show();
82
+ }
83
+ }
@@ -0,0 +1,28 @@
1
+ import * as utils from "../utils.js";
2
+ import { toast } from "./toast.js";
3
+
4
+ export const errorText = (error) => error?.message ?? `${error}`;
5
+
6
+ export function reportLoadFailure(description, error) {
7
+ console.error(`Error loading ${description}:`, error);
8
+ toast(`Could not load ${description}: ${errorText(error)}`, { title: "Loading" });
9
+ }
10
+
11
+ export function reportIgnoredFiles(name, ignored) {
12
+ if (!ignored.length) return;
13
+ toast(`Loaded ${name}. The archive also holds ${ignored.join(", ")}, and only one file is loaded from it.`, {
14
+ title: "Archive",
15
+ });
16
+ }
17
+
18
+ export async function unzipAndReport(data) {
19
+ const unzipped = await utils.unzipDiscImage(data);
20
+ reportIgnoredFiles(unzipped.name, unzipped.ignored);
21
+ return unzipped;
22
+ }
23
+
24
+ /** Handles a component's "notice" event by toasting it. */
25
+ export function showNotice(event) {
26
+ const { message, title, quietKey } = event.detail;
27
+ toast(message, { title, quietKey });
28
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- import { renderThumbnails, executeUntilFrame } from "./rewind-thumbnail.js";
3
+ import { renderThumbnails, executeUntilFrame } from "../rewind-thumbnail.js";
4
4
 
5
5
  /**
6
6
  * Rewind scrubber UI — a filmstrip overlay showing thumbnails of recent
@@ -0,0 +1,160 @@
1
+ import * as utils from "../utils.js";
2
+ import { Config } from "./config.js";
3
+ import { DefaultModel, findModel } from "../models.js";
4
+ import { DefaultAudioOutput, isAudioOutput } from "../audio-output.js";
5
+ import { SpeechOutput } from "../speech-output.js";
6
+ import { guessModelFromHostname } from "../url-params.js";
7
+ import { toast } from "./toast.js";
8
+
9
+ // A slider fires for every pixel of a drag, and each URL update is a history entry.
10
+ const UrlSettleMs = 300;
11
+
12
+ /**
13
+ * The user's settings: resolved from the URL, browser storage and the
14
+ * defaults, kept in step across the Settings dialog, the top bar, storage and
15
+ * the URL, and applied to the machine. Built before anything it applies to;
16
+ * wire() hands it the targets once they exist, which is before any setting
17
+ * can change hands.
18
+ */
19
+ export class Settings {
20
+ constructor({ urlState, makeConfig = (...handlers) => new Config(...handlers) }) {
21
+ this.urlState = urlState;
22
+ this.targets = null;
23
+ const parsedQuery = urlState.params;
24
+
25
+ // Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
26
+ // Must be created before Config so the onClose callback and the initial checkbox state can reference it.
27
+ this.speechOutput = new SpeechOutput();
28
+ this.setSpeechOutput(!!parsedQuery.speechOutput);
29
+
30
+ this.keyLayout = window.localStorage.keyLayout || "physical";
31
+ if (parsedQuery.keyLayout) {
32
+ this.keyLayout = (parsedQuery.keyLayout + "").toLowerCase();
33
+ }
34
+
35
+ this.config = makeConfig(
36
+ (changed) => {
37
+ if (changed.audioOutput) this.applyAudioOutput(changed.audioOutput);
38
+ if (changed.speakerAmount !== undefined) this.applySpeakerAmount(changed.speakerAmount);
39
+ if (changed.displayMode) this.applyDisplayMode(changed.displayMode);
40
+ },
41
+ (changed) => this.onDialogClosed(changed),
42
+ () => {
43
+ this.targets.modals.areYouSure(
44
+ "Your change is saved, but only takes effect when the emulator restarts. Restart now?",
45
+ "Restart now",
46
+ "Later",
47
+ () => window.location.reload(),
48
+ );
49
+ },
50
+ );
51
+
52
+ // Perform mapping of legacy models to the new format
53
+ this.config.mapLegacyModels(parsedQuery);
54
+
55
+ const requestedModelName = parsedQuery.model || guessModelFromHostname(window.location.hostname);
56
+ const requestedModel = findModel(requestedModelName);
57
+ if (!requestedModel)
58
+ toast(`There is no model called "${requestedModelName}". Using ${DefaultModel.name} instead.`, {
59
+ title: "Model",
60
+ });
61
+ this.config.setModel((requestedModel ?? DefaultModel).name);
62
+ this.config.setKeyLayout(this.keyLayout);
63
+ this.config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
64
+ this.config.setMicrophoneChannel(parsedQuery.microphoneChannel);
65
+ this.config.setCheckboxes({
66
+ coProcessor: !!parsedQuery.coProcessor,
67
+ hasEconet: !!parsedQuery.hasEconet,
68
+ hasMusic5000: !!parsedQuery.hasMusic5000,
69
+ hasTeletextAdaptor: !!parsedQuery.hasTeletextAdaptor,
70
+ mouseJoystickEnabled: !!parsedQuery.mouseJoystickEnabled,
71
+ speechOutput: this.speechOutput.enabled,
72
+ });
73
+
74
+ this.displayMode = parsedQuery.displayMode || window.localStorage.displayMode || "rgb";
75
+ this.config.setDisplayMode(this.displayMode);
76
+ this.audioOutput =
77
+ [parsedQuery.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
78
+ this.speakerAmount =
79
+ [parsedQuery.speakerAmount, parseFloat(window.localStorage.speakerAmount)].find(Number.isFinite) ?? 1;
80
+ this.config.setAudioOutput(this.audioOutput);
81
+ this.config.setSpeakerAmount(this.speakerAmount);
82
+
83
+ this.updateUrlOnceSettled = utils.debounce(() => urlState.updateUrl(), UrlSettleMs);
84
+ }
85
+
86
+ get model() {
87
+ return this.config.model;
88
+ }
89
+
90
+ /** What applying a setting reaches: everything here exists before a setting can change. */
91
+ wire(targets) {
92
+ this.targets = targets;
93
+ }
94
+
95
+ setSpeechOutput(enabled) {
96
+ this.speechOutput.enabled = enabled;
97
+ if (enabled && typeof speechSynthesis === "undefined")
98
+ toast("This browser has no speech synthesis, so speech output has nothing to speak with.", {
99
+ title: "Speech",
100
+ });
101
+ }
102
+
103
+ applyAudioOutput(output) {
104
+ this.audioOutput = output;
105
+ this.targets.audioHandler.setAudioOutput(output);
106
+ this.config.setAudioOutput(output);
107
+ this.targets.quickSettings?.showAudioOutput(output);
108
+ window.localStorage.audioOutput = output;
109
+ this.urlState.params.audioOutput = output;
110
+ this.urlState.updateUrl();
111
+ }
112
+
113
+ applySpeakerAmount(amount) {
114
+ this.speakerAmount = amount;
115
+ this.targets.audioHandler.setSpeakerAmount(amount);
116
+ this.config.setSpeakerAmount(amount);
117
+ this.targets.quickSettings?.showSpeakerAmount(amount);
118
+ window.localStorage.speakerAmount = amount;
119
+ this.urlState.params.speakerAmount = amount;
120
+ this.updateUrlOnceSettled();
121
+ }
122
+
123
+ applyDisplayMode(mode) {
124
+ this.displayMode = mode;
125
+ this.targets.display.setMode(mode);
126
+ this.config.setDisplayMode(mode);
127
+ this.targets.quickSettings?.showDisplayMode(mode);
128
+ window.localStorage.displayMode = mode;
129
+ this.urlState.params.displayMode = mode;
130
+ this.urlState.updateUrl();
131
+ }
132
+
133
+ onDialogClosed(changed) {
134
+ const { urlState } = this;
135
+ const { machine, keys, inputs } = this.targets;
136
+ const parsedQuery = urlState.params;
137
+ Object.assign(parsedQuery, changed);
138
+ if (changed.keyLayout) {
139
+ window.localStorage.keyLayout = changed.keyLayout;
140
+ machine.emulationConfig.keyLayout = changed.keyLayout;
141
+ keys.setKeyLayout(changed.keyLayout);
142
+ }
143
+ if (changed.mouseJoystickEnabled !== undefined || changed.microphoneChannel !== undefined) {
144
+ inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
145
+
146
+ if (changed.microphoneChannel !== undefined) {
147
+ inputs.setupMicrophone();
148
+ }
149
+ }
150
+ if (changed.speechOutput !== undefined) this.setSpeechOutput(!!changed.speechOutput);
151
+ if (changed.tubeCpuMultiplier !== undefined) {
152
+ machine.emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
153
+ this.config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
154
+ if (machine.processor.hasTube) {
155
+ machine.processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
156
+ }
157
+ }
158
+ urlState.updateUrl();
159
+ }
160
+ }