jsbeeb 1.22.0 → 1.22.2
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 +1 -0
- package/package.json +3 -2
- package/src/basic-loader.js +25 -0
- package/src/dom-utils.js +8 -0
- package/src/main.js +291 -2532
- package/src/utils.js +8 -0
- package/src/web/analogue-inputs.js +134 -0
- package/src/web/archive-list.js +44 -0
- package/src/web/audio-handler.js +2 -2
- package/src/web/autoboot.js +85 -0
- package/src/{config.js → web/config.js} +4 -4
- package/src/{disc-visualiser.js → web/disc-visualiser.js} +2 -2
- package/src/web/display.js +166 -0
- package/src/web/drives.js +116 -0
- package/src/web/emulation-loop.js +314 -0
- package/src/web/front-panel.js +133 -0
- package/src/web/google-drive-picker.js +175 -0
- package/src/web/hfe-picker.js +156 -0
- package/src/web/keyboard-setup.js +125 -0
- package/src/web/layout.js +156 -0
- package/src/web/machine.js +204 -0
- package/src/web/media-loader.js +359 -0
- package/src/web/modals.js +83 -0
- package/src/web/reporting.js +28 -0
- package/src/{rewind-ui.js → web/rewind-ui.js} +16 -13
- package/src/web/settings.js +160 -0
- package/src/web/snapshot-ui.js +217 -0
- package/src/web/sth-picker.js +137 -0
- package/src/web/url-state.js +84 -0
- package/tests/test-machine.js +6 -14
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import * as utils from "../utils.js";
|
|
2
|
+
|
|
3
|
+
// A timer, not requestAnimationFrame: a display presentation stall withholds
|
|
4
|
+
// animation frames, and with them the sound chip's samples (issue #885).
|
|
5
|
+
const TickMs = 10;
|
|
6
|
+
|
|
7
|
+
export const RewindCaptureInterval = 50; // emulated frames, ~1 second
|
|
8
|
+
|
|
9
|
+
// Under ?audioDebug, one console line per second in which the emulator sat
|
|
10
|
+
// idle between ticks or a tick ran long, or the audio queue underran or
|
|
11
|
+
// dropped, so a click can be matched to a cause. The sound chip posts samples
|
|
12
|
+
// throughout execute(), so only the idle time starves the audio queue.
|
|
13
|
+
const AudioDebugLogIntervalMs = 1000;
|
|
14
|
+
const AudioDebugSlowTickMs = 30;
|
|
15
|
+
const AudioDebugSlowPresentMs = 30;
|
|
16
|
+
|
|
17
|
+
const VirtualMhzUpdateMs = 3333;
|
|
18
|
+
|
|
19
|
+
class VirtualSpeedUpdater {
|
|
20
|
+
constructor(cpuSpeed) {
|
|
21
|
+
this.cpuSpeed = cpuSpeed;
|
|
22
|
+
this.cycles = 0;
|
|
23
|
+
this.time = 0;
|
|
24
|
+
this.v = document.querySelector(".virtualMHz");
|
|
25
|
+
this.header = document.getElementById("virtual-mhz-header");
|
|
26
|
+
this.speedy = false;
|
|
27
|
+
this.display();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
update(cycles, time, speedy) {
|
|
31
|
+
this.cycles += cycles;
|
|
32
|
+
this.time += time;
|
|
33
|
+
this.speedy = speedy;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
display() {
|
|
37
|
+
// MRG would be nice to graph instantaneous speed to get some idea where the time goes.
|
|
38
|
+
if (this.cycles) {
|
|
39
|
+
const thisMHz = this.cycles / this.time / 1000;
|
|
40
|
+
this.v.textContent = thisMHz.toFixed(1);
|
|
41
|
+
if (this.cycles >= 10 * this.cpuSpeed) {
|
|
42
|
+
this.cycles = this.time = 0;
|
|
43
|
+
}
|
|
44
|
+
this.header.style.color = this.speedy ? "red" : "white";
|
|
45
|
+
}
|
|
46
|
+
setTimeout(() => this.display(), VirtualMhzUpdateMs);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Runs the machine in real time: the tick that turns wall-clock time into
|
|
52
|
+
* cycles, starting and stopping, the audio lead, fast-forward, rewind capture
|
|
53
|
+
* and the speed readout. Owns `running`, and dispatches a "running" event
|
|
54
|
+
* whenever it changes hands.
|
|
55
|
+
*/
|
|
56
|
+
export class EmulationLoop extends EventTarget {
|
|
57
|
+
constructor({
|
|
58
|
+
processor,
|
|
59
|
+
display,
|
|
60
|
+
audioHandler,
|
|
61
|
+
dbgr,
|
|
62
|
+
gamepad,
|
|
63
|
+
keyboard,
|
|
64
|
+
syncLights,
|
|
65
|
+
rewindBuffer,
|
|
66
|
+
onRewindCaptured,
|
|
67
|
+
clocksPerSecond,
|
|
68
|
+
cpuSpeed,
|
|
69
|
+
fastTape,
|
|
70
|
+
audioStatsNode,
|
|
71
|
+
}) {
|
|
72
|
+
super();
|
|
73
|
+
this.processor = processor;
|
|
74
|
+
this.display = display;
|
|
75
|
+
this.audioHandler = audioHandler;
|
|
76
|
+
this.dbgr = dbgr;
|
|
77
|
+
this.gamepad = gamepad;
|
|
78
|
+
this.keyboard = keyboard;
|
|
79
|
+
this.syncLights = syncLights;
|
|
80
|
+
this.rewindBuffer = rewindBuffer;
|
|
81
|
+
this.onRewindCaptured = onRewindCaptured;
|
|
82
|
+
this.clocksPerSecond = clocksPerSecond;
|
|
83
|
+
this.maxCyclesPerTick = clocksPerSecond / 10;
|
|
84
|
+
this.rewindCaptureCycles = (RewindCaptureInterval * clocksPerSecond) / 50;
|
|
85
|
+
this.fastTape = fastTape;
|
|
86
|
+
this.audioStatsNode = audioStatsNode;
|
|
87
|
+
|
|
88
|
+
this.running = false;
|
|
89
|
+
this.fastAsPossible = false;
|
|
90
|
+
this.last = 0;
|
|
91
|
+
this.lastEnd = 0;
|
|
92
|
+
this.tickToken = null;
|
|
93
|
+
this.emulationLeadMs = 0;
|
|
94
|
+
this.rewindCycleCounter = 0;
|
|
95
|
+
this.wasPreviouslyRunning = false;
|
|
96
|
+
|
|
97
|
+
this.virtualSpeedUpdater = new VirtualSpeedUpdater(cpuSpeed);
|
|
98
|
+
this.audioDebugLog = { start: 0, ticks: 0, cycles: 0, maxIdle: 0, maxExecute: 0, maxPaint: 0, maxSnapshot: 0 };
|
|
99
|
+
|
|
100
|
+
document.addEventListener("visibilitychange", () => this.handleVisibilityChange(), false);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
isRunning() {
|
|
104
|
+
return this.running;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
go() {
|
|
108
|
+
this.audioHandler.unmute();
|
|
109
|
+
this.running = true;
|
|
110
|
+
this.dispatchEvent(new Event("running"));
|
|
111
|
+
this.run();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
stop(debug) {
|
|
115
|
+
this.running = false;
|
|
116
|
+
this.dispatchEvent(new Event("running"));
|
|
117
|
+
this.processor.stop();
|
|
118
|
+
if (debug) this.dbgr.debug(this.processor.pc);
|
|
119
|
+
this.audioHandler.mute();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
run() {
|
|
123
|
+
this.scheduleTick(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
toggleFastAsPossible() {
|
|
127
|
+
this.fastAsPossible = !this.fastAsPossible;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A user-blocking task runs ahead of rendering and ordinary timers, so a stuck
|
|
131
|
+
// compositor does not hold the tick off too.
|
|
132
|
+
scheduleTick(delayMs) {
|
|
133
|
+
const token = (this.tickToken = {});
|
|
134
|
+
const fire = () => {
|
|
135
|
+
if (this.tickToken === token) this.tick();
|
|
136
|
+
};
|
|
137
|
+
if (window.scheduler?.postTask) window.scheduler.postTask(fire, { delay: delayMs, priority: "user-blocking" });
|
|
138
|
+
else window.setTimeout(fire, delayMs);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
tick() {
|
|
142
|
+
if (!this.running) {
|
|
143
|
+
this.last = 0;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const now = performance.now();
|
|
147
|
+
|
|
148
|
+
const { processor, display, audioHandler } = this;
|
|
149
|
+
const motorOn = processor.acia.motorOn;
|
|
150
|
+
const speedy = this.fastAsPossible || (this.fastTape && motorOn);
|
|
151
|
+
|
|
152
|
+
// In speedy mode, we still run all the state machines accurately
|
|
153
|
+
// but we paint less often because painting is the most expensive
|
|
154
|
+
// part of jsbeeb at this time.
|
|
155
|
+
// We need need to paint per odd number of frames so that interlace
|
|
156
|
+
// modes, i.e. MODE 7, still look ok.
|
|
157
|
+
display.video.frameSkipCount = speedy ? 9 : 0;
|
|
158
|
+
|
|
159
|
+
this.scheduleTick(speedy ? 0 : TickMs);
|
|
160
|
+
|
|
161
|
+
this.gamepad.update(processor.sysvia);
|
|
162
|
+
this.syncLights();
|
|
163
|
+
if (this.last !== 0) {
|
|
164
|
+
let cycles;
|
|
165
|
+
if (!speedy) {
|
|
166
|
+
const sinceLast = Math.max(0, now - this.last);
|
|
167
|
+
cycles = (sinceLast * this.clocksPerSecond) / 1000;
|
|
168
|
+
cycles = Math.min(cycles, this.maxCyclesPerTick);
|
|
169
|
+
} else {
|
|
170
|
+
cycles = this.clocksPerSecond / 50;
|
|
171
|
+
}
|
|
172
|
+
cycles |= 0;
|
|
173
|
+
try {
|
|
174
|
+
if (!processor.execute(cycles)) {
|
|
175
|
+
this.stop(true);
|
|
176
|
+
}
|
|
177
|
+
audioHandler.flushChipEvents();
|
|
178
|
+
const end = performance.now();
|
|
179
|
+
this.virtualSpeedUpdater.update(cycles, end - now, speedy);
|
|
180
|
+
const paintMs = display.takePaintMs();
|
|
181
|
+
let snapshotMs = 0;
|
|
182
|
+
this.rewindCycleCounter += cycles;
|
|
183
|
+
if (this.rewindCycleCounter >= this.rewindCaptureCycles) {
|
|
184
|
+
this.rewindCycleCounter -= this.rewindCaptureCycles;
|
|
185
|
+
this.rewindBuffer.push(processor.snapshotState());
|
|
186
|
+
this.onRewindCaptured();
|
|
187
|
+
snapshotMs = performance.now() - end;
|
|
188
|
+
}
|
|
189
|
+
if (this.audioStatsNode)
|
|
190
|
+
this.logAudioDebugTick(
|
|
191
|
+
now,
|
|
192
|
+
cycles,
|
|
193
|
+
speedy ? 0 : now - this.lastEnd,
|
|
194
|
+
end - now,
|
|
195
|
+
paintMs,
|
|
196
|
+
snapshotMs,
|
|
197
|
+
);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
this.running = false;
|
|
200
|
+
utils.noteEvent("exception", "thrown", e.stack);
|
|
201
|
+
this.dbgr.debug(processor.pc);
|
|
202
|
+
throw e;
|
|
203
|
+
}
|
|
204
|
+
if (this.keyboard.postFrameShouldPause()) {
|
|
205
|
+
this.stop(false);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
this.last = Math.max(this.last, now);
|
|
209
|
+
this.lastEnd = performance.now();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A change of audio buffer depth is taken by the picture, not the sound:
|
|
213
|
+
// gaining lead emulates ahead at once; losing it moves `last` forward so the
|
|
214
|
+
// ticks emulate nothing until the queue has drained by that much.
|
|
215
|
+
setEmulationLead(leadMs) {
|
|
216
|
+
if (!this.running) return;
|
|
217
|
+
const aheadMs = leadMs - this.emulationLeadMs;
|
|
218
|
+
this.emulationLeadMs = leadMs;
|
|
219
|
+
if (aheadMs > 0) {
|
|
220
|
+
if (!this.processor.execute((aheadMs * this.clocksPerSecond) / 1000)) this.stop(true);
|
|
221
|
+
this.audioHandler.flushChipEvents();
|
|
222
|
+
} else {
|
|
223
|
+
this.last -= aheadMs;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
handleVisibilityChange() {
|
|
228
|
+
const { processor } = this;
|
|
229
|
+
if (document.visibilityState === "hidden") {
|
|
230
|
+
this.wasPreviouslyRunning = this.running;
|
|
231
|
+
const keepRunningWhenHidden =
|
|
232
|
+
processor.acia.motorOn || processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
|
|
233
|
+
if (this.running && !keepRunningWhenHidden) {
|
|
234
|
+
this.stop(false);
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
if (this.wasPreviouslyRunning) {
|
|
238
|
+
this.go();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
logAudioDebugTick(now, cycles, idleMs, executeMs, paintMs, snapshotMs) {
|
|
244
|
+
const log = this.audioDebugLog;
|
|
245
|
+
if (log.start === 0) log.start = now;
|
|
246
|
+
log.ticks++;
|
|
247
|
+
log.cycles += cycles;
|
|
248
|
+
log.maxIdle = Math.max(log.maxIdle, idleMs);
|
|
249
|
+
log.maxExecute = Math.max(log.maxExecute, executeMs);
|
|
250
|
+
log.maxPaint = Math.max(log.maxPaint, paintMs);
|
|
251
|
+
log.maxSnapshot = Math.max(log.maxSnapshot, snapshotMs);
|
|
252
|
+
if (now - log.start < AudioDebugLogIntervalMs) return;
|
|
253
|
+
const audio = this.audioHandler.takeEventCounts();
|
|
254
|
+
const present = this.display.takePresentMs();
|
|
255
|
+
const leadMin = Number.isFinite(audio.leadMinMs) ? `${audio.leadMinMs.toFixed(1)}ms` : "(no stats)";
|
|
256
|
+
if (
|
|
257
|
+
log.maxIdle > AudioDebugSlowTickMs ||
|
|
258
|
+
log.maxExecute > AudioDebugSlowTickMs ||
|
|
259
|
+
present > AudioDebugSlowPresentMs ||
|
|
260
|
+
audio.stall ||
|
|
261
|
+
audio.skip
|
|
262
|
+
) {
|
|
263
|
+
console.log(
|
|
264
|
+
`${(now / 1000).toFixed(0)}s: ${log.ticks} ticks emulating ${((1000 * log.cycles) / this.clocksPerSecond).toFixed(0)}ms, ` +
|
|
265
|
+
`idle max ${log.maxIdle.toFixed(0)}ms, ` +
|
|
266
|
+
`execute max ${log.maxExecute.toFixed(0)}ms (paint ${log.maxPaint.toFixed(1)}ms), ` +
|
|
267
|
+
`present max ${present.toFixed(0)}ms, snapshot ${log.maxSnapshot.toFixed(1)}ms; ` +
|
|
268
|
+
`audio lead min ${leadMin}, stalls ${audio.stall}, skipped ${audio.skip.toFixed(0)}ms`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
log.start = now;
|
|
272
|
+
log.ticks = log.cycles = log.maxIdle = log.maxExecute = log.maxPaint = log.maxSnapshot = 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
benchmarkCpu(numCycles) {
|
|
276
|
+
numCycles = numCycles || 10 * 1000 * 1000;
|
|
277
|
+
const oldFS = this.display.frameSkip;
|
|
278
|
+
this.display.frameSkip = 1000000;
|
|
279
|
+
const startTime = performance.now();
|
|
280
|
+
this.processor.execute(numCycles);
|
|
281
|
+
const endTime = performance.now();
|
|
282
|
+
this.display.frameSkip = oldFS;
|
|
283
|
+
const msTaken = endTime - startTime;
|
|
284
|
+
const virtualMhz = numCycles / msTaken / 1000;
|
|
285
|
+
console.log("Took " + msTaken + "ms to execute " + numCycles + " cycles");
|
|
286
|
+
console.log("Virtual " + virtualMhz.toFixed(2) + "MHz");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
benchmarkVideo(numCycles) {
|
|
290
|
+
numCycles = numCycles || 10 * 1000 * 1000;
|
|
291
|
+
const oldFS = this.display.frameSkip;
|
|
292
|
+
this.display.frameSkip = 1000000;
|
|
293
|
+
const startTime = performance.now();
|
|
294
|
+
this.display.video.polltime(numCycles);
|
|
295
|
+
const endTime = performance.now();
|
|
296
|
+
this.display.frameSkip = oldFS;
|
|
297
|
+
const msTaken = endTime - startTime;
|
|
298
|
+
const virtualMhz = numCycles / msTaken / 1000;
|
|
299
|
+
console.log("Took " + msTaken + "ms to execute " + numCycles + " video cycles");
|
|
300
|
+
console.log("Virtual " + virtualMhz.toFixed(2) + "MHz");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
profileCpu(arg) {
|
|
304
|
+
console.profile("CPU");
|
|
305
|
+
this.benchmarkCpu(arg);
|
|
306
|
+
console.profileEnd();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
profileVideo(arg) {
|
|
310
|
+
console.profile("Video");
|
|
311
|
+
this.benchmarkVideo(arg);
|
|
312
|
+
console.profileEnd();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { toast } from "./toast.js";
|
|
2
|
+
|
|
3
|
+
class Light {
|
|
4
|
+
constructor(name) {
|
|
5
|
+
this.dom = document.getElementById(name);
|
|
6
|
+
this.on = false;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
update(val) {
|
|
10
|
+
if (val === this.on) return;
|
|
11
|
+
this.on = val;
|
|
12
|
+
this.dom.classList.toggle("on", this.on);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The furniture around the screen: the keyboard and drive lights, the tape
|
|
18
|
+
* controls, and the pop-up window the printer prints into.
|
|
19
|
+
*/
|
|
20
|
+
export class FrontPanel {
|
|
21
|
+
constructor({ processor, model, printer }) {
|
|
22
|
+
this.processor = processor;
|
|
23
|
+
this.model = model;
|
|
24
|
+
this.printer = printer;
|
|
25
|
+
this.printerWindow = null;
|
|
26
|
+
this.printerTextArea = null;
|
|
27
|
+
|
|
28
|
+
for (const link of document.querySelectorAll("#tape-menu a")) {
|
|
29
|
+
link.addEventListener("click", (e) => {
|
|
30
|
+
const type = e.target.dataset.id;
|
|
31
|
+
if (type === undefined) return;
|
|
32
|
+
|
|
33
|
+
if (type === "rewind") {
|
|
34
|
+
console.log("Rewinding tape to the start");
|
|
35
|
+
if (model.isAtom) {
|
|
36
|
+
processor.atomppia.stopTape();
|
|
37
|
+
processor.atomppia.rewindTape();
|
|
38
|
+
this.updateTapeButton();
|
|
39
|
+
} else {
|
|
40
|
+
processor.acia.rewindTape();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.tapePlayStopBtn = document.getElementById("tape-play-stop");
|
|
47
|
+
this.tapeControlHeader = document.getElementById("tape-control-header");
|
|
48
|
+
this.tapeControlCell = document.getElementById("tape-control-cell");
|
|
49
|
+
|
|
50
|
+
this.tapePlayStopBtn.addEventListener("click", () => {
|
|
51
|
+
if (processor.atomppia.motorOn) {
|
|
52
|
+
processor.atomppia.stopTape();
|
|
53
|
+
} else {
|
|
54
|
+
processor.atomppia.playTape();
|
|
55
|
+
}
|
|
56
|
+
this.updateTapeButton();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
this.cassette = new Light("motorlight");
|
|
60
|
+
this.caps = new Light("capslight");
|
|
61
|
+
this.shift = new Light("shiftlight");
|
|
62
|
+
this.drive0 = new Light("drive0");
|
|
63
|
+
this.drive1 = new Light("drive1");
|
|
64
|
+
this.network = new Light("networklight");
|
|
65
|
+
|
|
66
|
+
this.updateLedVisibility();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
updateTapeButton() {
|
|
70
|
+
if (!this.model.isAtom) return;
|
|
71
|
+
const playing = this.processor.atomppia.motorOn;
|
|
72
|
+
const label = playing ? "Stop cassette" : "Play cassette";
|
|
73
|
+
this.tapePlayStopBtn.textContent = playing ? "■" : "▶";
|
|
74
|
+
this.tapePlayStopBtn.title = label;
|
|
75
|
+
this.tapePlayStopBtn.setAttribute("aria-label", label);
|
|
76
|
+
this.tapePlayStopBtn.classList.toggle("playing", playing);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
showTapeControl(visible) {
|
|
80
|
+
const display = visible ? "" : "none";
|
|
81
|
+
this.tapeControlHeader.style.display = display;
|
|
82
|
+
this.tapeControlCell.style.display = display;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
updateLedVisibility() {
|
|
86
|
+
const bbcDisplay = this.model.isAtom ? "none" : "";
|
|
87
|
+
for (const el of document.querySelectorAll(".bbc-only")) {
|
|
88
|
+
el.style.display = bbcDisplay;
|
|
89
|
+
}
|
|
90
|
+
this.showTapeControl(this.model.isAtom);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
syncLights() {
|
|
94
|
+
const { processor } = this;
|
|
95
|
+
if (this.model.isAtom) {
|
|
96
|
+
this.cassette.update(processor.atomppia.motorOn);
|
|
97
|
+
} else {
|
|
98
|
+
this.caps.update(processor.sysvia.capsLockLight);
|
|
99
|
+
this.shift.update(processor.sysvia.shiftLockLight);
|
|
100
|
+
this.drive0.update(processor.fdc.motorOn[0]);
|
|
101
|
+
this.drive1.update(processor.fdc.motorOn[1]);
|
|
102
|
+
this.cassette.update(processor.acia.motorOn);
|
|
103
|
+
if (processor.econet) {
|
|
104
|
+
this.network.update(processor.econet.activityLight());
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What the printer prints lands in its window, when one is open. */
|
|
110
|
+
printChar(char) {
|
|
111
|
+
if (this.printerTextArea) this.printerTextArea.value += char;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
checkPrinterWindow() {
|
|
115
|
+
if (this.printerWindow && !this.printerWindow.closed) return;
|
|
116
|
+
|
|
117
|
+
this.printerWindow = window.open("", "_blank", "height=300,width=400");
|
|
118
|
+
if (!this.printerWindow) {
|
|
119
|
+
toast(
|
|
120
|
+
"The printer output window was blocked. Allow pop-up windows for this site, then press Ctrl-B again.",
|
|
121
|
+
{
|
|
122
|
+
title: "Printer",
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
this.printerWindow.document.write(
|
|
128
|
+
'<textarea id="text" rows="15" cols="40" placeholder="Printer outputs here..."></textarea>',
|
|
129
|
+
);
|
|
130
|
+
this.printerTextArea = this.printerWindow.document.getElementById("text");
|
|
131
|
+
this.printerTextArea.value = this.printer.text;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as utils from "../utils.js";
|
|
2
|
+
import * as bootstrap from "bootstrap";
|
|
3
|
+
import * as disc from "../fdc.js";
|
|
4
|
+
import { GoogleDriveLoader } from "../google-drive.js";
|
|
5
|
+
import { toast } from "./toast.js";
|
|
6
|
+
import { errorText } from "./reporting.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The Google Drive picker: signing in, listing the user's discs, loading one
|
|
10
|
+
* and creating a new one, blank or from what is in drive 0.
|
|
11
|
+
*/
|
|
12
|
+
export class GoogleDrivePicker {
|
|
13
|
+
constructor({ media, drives, modals, processor, loader = new GoogleDriveLoader() }) {
|
|
14
|
+
this.media = media;
|
|
15
|
+
this.drives = drives;
|
|
16
|
+
this.modals = modals;
|
|
17
|
+
this.processor = processor;
|
|
18
|
+
this.googleDrive = loader;
|
|
19
|
+
|
|
20
|
+
this.authEl = document.getElementById("google-drive-auth");
|
|
21
|
+
this.el = document.getElementById("google-drive");
|
|
22
|
+
this.modal = new bootstrap.Modal(this.el);
|
|
23
|
+
this.authResolve = null;
|
|
24
|
+
this.authReject = null;
|
|
25
|
+
|
|
26
|
+
document.querySelector("#google-drive-auth form").addEventListener("submit", async (e) => {
|
|
27
|
+
this.authEl.style.display = "none";
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
const authed = await this.auth(false);
|
|
30
|
+
if (authed) this.authResolve();
|
|
31
|
+
else this.authReject(new Error("Unable to authorize Google Drive"));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Loading the Google client holds the main thread for ~100ms, so it waits for
|
|
35
|
+
// someone to ask for Drive.
|
|
36
|
+
document.getElementById("open-drive-link").addEventListener("click", async (e) => {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
try {
|
|
39
|
+
await this.googleDrive.initialise();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
toast(`Google Drive is unavailable: ${errorText(error)}`, { title: "Google Drive" });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const authed = await this.auth(false);
|
|
45
|
+
if (authed) {
|
|
46
|
+
this.modal.show();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.el.addEventListener("show.bs.modal", () => this.showList());
|
|
51
|
+
document.querySelector("#google-drive form").addEventListener("submit", (e) => this.create(e));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async auth(imm) {
|
|
55
|
+
try {
|
|
56
|
+
return await this.googleDrive.authorize(imm);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.log("Error handling google auth: " + errorText(err));
|
|
59
|
+
this.el.querySelector(".loading").textContent =
|
|
60
|
+
`There was an error accessing your Google Drive account: ${errorText(err)}`;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async load(cat, layout) {
|
|
66
|
+
this.modals.popupLoading("Loading '" + cat.name + "' from Google Drive");
|
|
67
|
+
try {
|
|
68
|
+
const available = await this.googleDrive.initialise();
|
|
69
|
+
console.log("Google Drive available =", available);
|
|
70
|
+
if (!available) throw new Error("Google Drive is not available");
|
|
71
|
+
|
|
72
|
+
const authed = await this.auth(true);
|
|
73
|
+
console.log("Google Drive authed=", authed);
|
|
74
|
+
|
|
75
|
+
if (!authed) {
|
|
76
|
+
await new Promise((resolve, reject) => {
|
|
77
|
+
this.authResolve = resolve;
|
|
78
|
+
this.authReject = reject;
|
|
79
|
+
this.authEl.style.display = "";
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ssd = await this.googleDrive.load(this.processor.fdc, cat.id, layout);
|
|
84
|
+
console.log("Google Drive loading finished");
|
|
85
|
+
this.modals.loadingFinished();
|
|
86
|
+
if (!ssd.savesChanges) {
|
|
87
|
+
toast(`${cat.name} is read only on Google Drive, so changes to it are not written back.`, {
|
|
88
|
+
title: "Google Drive",
|
|
89
|
+
quietKey: "quietDriveReadOnly",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return ssd;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error("Google Drive loading error:", error);
|
|
95
|
+
this.modals.loadingFinished(`Unable to load ${cat.name} from Google Drive: ${errorText(error)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async showList() {
|
|
100
|
+
const gdLoading = this.el.querySelector(".loading");
|
|
101
|
+
gdLoading.textContent = "Loading...";
|
|
102
|
+
gdLoading.style.display = "";
|
|
103
|
+
for (const el of this.el.querySelectorAll("li:not(.template)")) el.remove();
|
|
104
|
+
let cat;
|
|
105
|
+
try {
|
|
106
|
+
cat = await this.googleDrive.listFiles();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error("Error listing Google Drive files:", error);
|
|
109
|
+
gdLoading.textContent = `Unable to list your Google Drive files: ${errorText(error)}`;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const dbList = this.el.querySelector(".list");
|
|
113
|
+
gdLoading.style.display = "none";
|
|
114
|
+
const template = dbList.querySelector(".template");
|
|
115
|
+
for (const item of cat) {
|
|
116
|
+
const row = template.cloneNode(true);
|
|
117
|
+
row.classList.remove("template");
|
|
118
|
+
dbList.appendChild(row);
|
|
119
|
+
row.querySelector(".name").textContent = item.name;
|
|
120
|
+
row.addEventListener("click", async () => {
|
|
121
|
+
utils.noteEvent("google-drive", "click", item.name);
|
|
122
|
+
this.media.setDisc1Image(`gd:${item.id}/${item.name}`);
|
|
123
|
+
this.modal.hide();
|
|
124
|
+
const ssd = await this.load(item, this.drives.layoutForDrive(0));
|
|
125
|
+
if (ssd) this.drives.putDiscIn(0, ssd);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async create(e) {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
let name = document.querySelector("#google-drive .disc-name").value;
|
|
133
|
+
if (!name) return;
|
|
134
|
+
|
|
135
|
+
this.modal.hide();
|
|
136
|
+
this.modals.popupLoading("Creating '" + name + "' on Google Drive");
|
|
137
|
+
|
|
138
|
+
let data;
|
|
139
|
+
if (document.querySelector("#google-drive .create-from-existing").checked) {
|
|
140
|
+
const discType = disc.guessDiscTypeFromName(name);
|
|
141
|
+
try {
|
|
142
|
+
data = discType.saver(this.processor.fdc.drives[0].disc);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
this.modals.loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(e)}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
name = utils.replaceOrAddExtension(name, discType.extension);
|
|
148
|
+
console.log(`Saving existing disc: ${name}`);
|
|
149
|
+
} else {
|
|
150
|
+
// TODO support HFE, I guess?
|
|
151
|
+
const discType = disc.guessDiscTypeFromName(name);
|
|
152
|
+
if (!discType.byteSize) {
|
|
153
|
+
this.modals.loadingFinished(
|
|
154
|
+
`Unable to create ${name} on Google Drive: blank ${discType.extension} discs have no known size`,
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
data = new Uint8Array(discType.byteSize);
|
|
159
|
+
if (discType.supportsCatalogue) {
|
|
160
|
+
discType.setDiscName(data, name);
|
|
161
|
+
}
|
|
162
|
+
console.log(`Creating blank: ${name}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const result = await this.googleDrive.create(this.processor.fdc, name, data);
|
|
167
|
+
this.media.setDisc1Image("gd:" + result.fileId + "/" + name);
|
|
168
|
+
this.drives.putDiscIn(0, result.disc);
|
|
169
|
+
this.modals.loadingFinished();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error(`Error creating Google Drive disc: ${error}`, error);
|
|
172
|
+
this.modals.loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(error)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|