jsbeeb 1.16.0 → 1.17.0
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.
- package/README.md +8 -2
- package/package.json +8 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/canvas.js +9 -2
- package/src/disc-drive.js +45 -12
- package/src/disc-hfe.js +18 -1
- package/src/disc.js +132 -20
- package/src/fdc.js +83 -11
- package/src/google-drive.js +4 -4
- package/src/intel-fdc.js +8 -1
- package/src/jsbeeb.css +65 -0
- package/src/keyboard.js +14 -15
- package/src/machine-session.js +2 -1
- package/src/main.js +324 -139
- package/src/models.js +2 -0
- package/src/sth.js +5 -5
- package/src/teletext_adaptor.js +6 -4
- package/src/url-params.js +28 -0
- package/src/utils.js +3 -0
- package/src/wd-fdc.js +8 -1
- package/src/web/audio-handler.js +4 -2
- package/src/web/toast.js +79 -0
- package/tests/test-machine.js +1 -1
package/src/models.js
CHANGED
package/src/sth.js
CHANGED
|
@@ -50,12 +50,12 @@ export class StairwayToHell {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
async fetch(file) {
|
|
53
|
-
const
|
|
54
|
-
console.log("Loading ZIP from " +
|
|
55
|
-
const response = await fetch(
|
|
56
|
-
if (!response.ok) throw new Error(
|
|
53
|
+
const url = this._baseUrl + encodePath(file);
|
|
54
|
+
console.log("Loading ZIP from " + url);
|
|
55
|
+
const response = await fetch(url);
|
|
56
|
+
if (!response.ok) throw new Error(`Unable to load ${url}, http code ${response.status}`);
|
|
57
57
|
try {
|
|
58
|
-
return
|
|
58
|
+
return await utils.unzipDiscImage(new Uint8Array(await response.arrayBuffer()));
|
|
59
59
|
} catch (error) {
|
|
60
60
|
console.error("Failed to fetch file:", error);
|
|
61
61
|
throw error;
|
package/src/teletext_adaptor.js
CHANGED
|
@@ -35,8 +35,8 @@ Status register:
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Emulates the Acorn teletext adaptor. Dispatches a `
|
|
39
|
-
* `
|
|
38
|
+
* Emulates the Acorn teletext adaptor. Dispatches a `notice` CustomEvent, carrying a
|
|
39
|
+
* `message` in its detail, when a channel's stream cannot be loaded.
|
|
40
40
|
*/
|
|
41
41
|
export class TeletextAdaptor extends EventTarget {
|
|
42
42
|
constructor(cpu) {
|
|
@@ -79,8 +79,10 @@ export class TeletextAdaptor extends EventTarget {
|
|
|
79
79
|
if (request !== this.streamRequest) return;
|
|
80
80
|
console.error(`Teletext adaptor: failed to load channel ${channel}`, error);
|
|
81
81
|
this.dispatchEvent(
|
|
82
|
-
new CustomEvent("
|
|
83
|
-
detail: {
|
|
82
|
+
new CustomEvent("notice", {
|
|
83
|
+
detail: {
|
|
84
|
+
message: `Teletext channel ${channel} could not be loaded (${error?.message ?? error}). The adaptor carries on with nothing to show.`,
|
|
85
|
+
},
|
|
84
86
|
}),
|
|
85
87
|
);
|
|
86
88
|
return;
|
package/src/url-params.js
CHANGED
|
@@ -236,6 +236,34 @@ export function processAutobootParams(parsedQuery) {
|
|
|
236
236
|
return { needsAutoboot, autoType };
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
/** Where a drive's 40/80 switch is set, `auto` leaving it to whatever disc is loaded. */
|
|
240
|
+
export const DriveTracks = Object.freeze({ auto: "auto", forty: "40", eighty: "80" });
|
|
241
|
+
|
|
242
|
+
const NumDrives = 2;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Process the per-drive 40/80 track settings
|
|
246
|
+
* @param {Object} parsedQuery - The parsed query parameters
|
|
247
|
+
* @returns {{settings: string[], warnings: string[]}} One DriveTracks per drive, and what was
|
|
248
|
+
* unusable about anything asked for that is not in there
|
|
249
|
+
*/
|
|
250
|
+
export function processDriveTrackParams(parsedQuery) {
|
|
251
|
+
const warnings = [];
|
|
252
|
+
const settings = [];
|
|
253
|
+
for (let driveIndex = 0; driveIndex < NumDrives; ++driveIndex) {
|
|
254
|
+
const name = `drive${driveIndex}Tracks`;
|
|
255
|
+
const asked = parsedQuery[name];
|
|
256
|
+
const setting = isDefined(asked) ? `${asked}`.toLowerCase() : DriveTracks.auto;
|
|
257
|
+
if (Object.values(DriveTracks).includes(setting)) {
|
|
258
|
+
settings.push(setting);
|
|
259
|
+
} else {
|
|
260
|
+
warnings.push(`${name}=${asked}: a drive is set to 40, 80 or auto.`);
|
|
261
|
+
settings.push(DriveTracks.auto);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { settings, warnings };
|
|
265
|
+
}
|
|
266
|
+
|
|
239
267
|
/**
|
|
240
268
|
* Guess the appropriate model based on the hostname
|
|
241
269
|
* @param {string} hostname - The hostname to check
|
package/src/utils.js
CHANGED
package/src/wd-fdc.js
CHANGED
|
@@ -1414,8 +1414,15 @@ export class WdFdc {
|
|
|
1414
1414
|
* @param {Number} drive
|
|
1415
1415
|
* @param {Disc} disc
|
|
1416
1416
|
*/
|
|
1417
|
-
|
|
1417
|
+
/**
|
|
1418
|
+
* @param {Number} drive
|
|
1419
|
+
* @param {Disc} disc
|
|
1420
|
+
* @param {Number} [tracksPerStep] where to leave the drive's 40/80 switch, which by default
|
|
1421
|
+
* follows the disc, since no drive can tell what pitch the disc in it was written at
|
|
1422
|
+
*/
|
|
1423
|
+
loadDisc(drive, disc, tracksPerStep = disc?.is40Track ? 2 : 1) {
|
|
1418
1424
|
this._drives[drive].setDisc(disc);
|
|
1425
|
+
this._drives[drive].tracksPerStep = tracksPerStep;
|
|
1419
1426
|
}
|
|
1420
1427
|
|
|
1421
1428
|
get motorOn() {
|
package/src/web/audio-handler.js
CHANGED
|
@@ -15,6 +15,7 @@ export class AudioHandler {
|
|
|
15
15
|
this.cpuSpeed = cpuSpeed;
|
|
16
16
|
this.isAtom = isAtom;
|
|
17
17
|
this.warningNode = warningNode;
|
|
18
|
+
this.noAudioWorklet = false;
|
|
18
19
|
toggle(this.warningNode, false);
|
|
19
20
|
this.stats = {};
|
|
20
21
|
if (statsNode) {
|
|
@@ -41,14 +42,15 @@ export class AudioHandler {
|
|
|
41
42
|
} else {
|
|
42
43
|
if (this.audioContext && !this.audioContext.audioWorklet) {
|
|
43
44
|
this.audioContext = null;
|
|
45
|
+
this.noAudioWorklet = true;
|
|
44
46
|
console.log("Unable to initialise audio: no audio worklet API");
|
|
45
|
-
toggle(this.warningNode, true);
|
|
46
47
|
const localhost = new URL(window.location);
|
|
47
48
|
localhost.hostname = "localhost";
|
|
48
49
|
this.warningNode.innerHTML = `No audio worklet API was found - there will be no audio.
|
|
49
50
|
If you are running a local jsbeeb, you must either use a host of
|
|
50
51
|
<a href="${localhost}">localhost</a>,
|
|
51
52
|
or serve the content over <em>https</em>.`;
|
|
53
|
+
toggle(this.warningNode, true);
|
|
52
54
|
}
|
|
53
55
|
this.soundChip = new FakeSoundChip();
|
|
54
56
|
this.ddNoise = new FakeDdNoise();
|
|
@@ -56,7 +58,6 @@ export class AudioHandler {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
this.warningNode.addEventListener("mousedown", () => this.tryResume());
|
|
59
|
-
toggle(this.warningNode, false);
|
|
60
61
|
|
|
61
62
|
// Initialise Music 5000 audio context
|
|
62
63
|
this.audioContextM5000 = createAudioContext({ sampleRate: 46875 });
|
|
@@ -145,6 +146,7 @@ export class AudioHandler {
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
checkStatus() {
|
|
149
|
+
if (this.noAudioWorklet) return;
|
|
148
150
|
if (!this.audioContext && !this.audioContextM5000) return;
|
|
149
151
|
const suspended =
|
|
150
152
|
(this.audioContext && this.audioContext.state === "suspended") ||
|
package/src/web/toast.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as bootstrap from "bootstrap";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Passing notices: something happened that is worth knowing and needs nothing doing about it.
|
|
5
|
+
* The error dialog is for the other kind.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
let nextQuietId = 0;
|
|
9
|
+
|
|
10
|
+
function toastContainer() {
|
|
11
|
+
const existing = document.querySelector(".toast-container");
|
|
12
|
+
if (existing) return existing;
|
|
13
|
+
const container = document.createElement("div");
|
|
14
|
+
container.className = "toast-container position-fixed bottom-0 end-0 p-3";
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
return container;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Storage can be unreachable or full, and a notice that nothing needs doing about is not worth
|
|
20
|
+
// failing over: forget the answer and say the thing again.
|
|
21
|
+
function remembered(key) {
|
|
22
|
+
try {
|
|
23
|
+
return !!window.localStorage.getItem(key);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.log(`Unable to read ${key}: ${e}`);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function remember(key, wanted) {
|
|
31
|
+
try {
|
|
32
|
+
if (wanted) window.localStorage.setItem(key, "yes");
|
|
33
|
+
else window.localStorage.removeItem(key);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.log(`Unable to remember ${key}: ${e}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} message
|
|
41
|
+
* @param {object} [options]
|
|
42
|
+
* @param {string} [options.title] a heading, for a notice whose message does not say where it came from
|
|
43
|
+
* @param {string} [options.quietKey] offer to stop showing this kind of notice, remembering the answer here
|
|
44
|
+
*/
|
|
45
|
+
export function toast(message, { title = "", quietKey = "" } = {}) {
|
|
46
|
+
if (quietKey && remembered(quietKey)) return;
|
|
47
|
+
|
|
48
|
+
const element = document.createElement("div");
|
|
49
|
+
element.className = "toast text-bg-dark";
|
|
50
|
+
element.setAttribute("role", "status");
|
|
51
|
+
element.setAttribute("aria-live", "polite");
|
|
52
|
+
element.setAttribute("aria-atomic", "true");
|
|
53
|
+
const quietId = `toast-quiet-${nextQuietId++}`;
|
|
54
|
+
element.innerHTML = `
|
|
55
|
+
<div class="toast-header text-bg-dark">
|
|
56
|
+
<strong class="me-auto"></strong>
|
|
57
|
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="toast-body">
|
|
60
|
+
<div class="message"></div>
|
|
61
|
+
<div class="form-check mt-2">
|
|
62
|
+
<input class="form-check-input" type="checkbox" id="${quietId}" />
|
|
63
|
+
<label class="form-check-label small" for="${quietId}">Stop telling me this</label>
|
|
64
|
+
</div>
|
|
65
|
+
</div>`;
|
|
66
|
+
// Disc names and the like come from the outside world, so they are set as text, never as markup.
|
|
67
|
+
element.querySelector(".message").textContent = message;
|
|
68
|
+
element.querySelector(".toast-header strong").textContent = title;
|
|
69
|
+
element.querySelector(".toast-header").hidden = !title;
|
|
70
|
+
|
|
71
|
+
const quiet = element.querySelector(".form-check");
|
|
72
|
+
quiet.hidden = !quietKey;
|
|
73
|
+
quiet.querySelector("input").addEventListener("change", (event) => remember(quietKey, event.target.checked));
|
|
74
|
+
|
|
75
|
+
toastContainer().appendChild(element);
|
|
76
|
+
element.addEventListener("hidden.bs.toast", () => element.remove());
|
|
77
|
+
bootstrap.Toast.getOrCreateInstance(element).show();
|
|
78
|
+
return element;
|
|
79
|
+
}
|
package/tests/test-machine.js
CHANGED
|
@@ -213,7 +213,7 @@ export class TestMachine {
|
|
|
213
213
|
|
|
214
214
|
async loadDisc(image) {
|
|
215
215
|
const data = await fdc.load(image);
|
|
216
|
-
this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc,
|
|
216
|
+
this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, image, data));
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
/**
|