Repository navigation
Expand file tree
/
Copy pathdial.js
More file actions
356 lines (331 loc) · 16.3 KB
/
Copy pathdial.js
File metadata and controls
356 lines (331 loc) · 16.3 KB
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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
/* CABLE 82 dial math: the broadcast clock and everything derived from it.
Pure functions, no DOM, no fetch. The load-bearing idea of the whole
network lives here: position on a channel is a function of the wall
clock, never a play cursor, which is what lets a stopped channel resume at
exactly the moment it would have reached, two sets agree to the frame, and
the guide never disagree with the picture.
UMD, so test/harness.html and a node test can both exercise it; the
display loads it as window.Cable82Dial. */
(function (root, factory) {
const isNode = typeof module !== "undefined" && module.exports;
const schema = isNode ? require("./config-schema.js") : root.Cable82Schema;
const api = factory(schema);
if (isNode) module.exports = api;
else root.Cable82Dial = api;
})(typeof self !== "undefined" ? self : this, function (S) {
"use strict";
// ---------------------------------------------------------- the clock face
const DAYS3 = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const DAYS = ["SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"];
const MONTHS3 = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const MONTHS = ["JANUARY", "FEBRUARY", "MARCH", "APRIL", "MAY", "JUNE", "JULY", "AUGUST", "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER"];
// The broadcast clock's origin: an arbitrary fixed point every channel's
// playlist position is measured from, so two sets agree to the frame.
const EPOCH = Date.UTC(2026, 0, 1);
function formatClock(d, mode, opts) {
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = opts && opts.seconds ? ":" + String(d.getSeconds()).padStart(2, "0") : "";
if (mode === "24h") return String(d.getHours()).padStart(2, "0") + ":" + mm + ss;
const h = d.getHours() % 12 || 12;
return h + ":" + mm + ss + " " + (d.getHours() < 12 ? "AM" : "PM");
}
function formatHeaderDate(d) {
return DAYS3[d.getDay()] + " " + MONTHS3[d.getMonth()] + " " + d.getDate();
}
function formatLongDate(d) {
return DAYS[d.getDay()] + ", " + MONTHS[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear();
}
// ---------------------------------------------------------- the running order
// (playlist, nowMs, epochMs) -> { index, offset } | null.
// playlist: [{ duration }] with every duration known and positive.
function positionAt(playlist, nowMs, epochMs) {
if (!Array.isArray(playlist) || !playlist.length) return null;
let total = 0;
for (const item of playlist) {
if (!item || !Number.isFinite(item.duration) || item.duration <= 0) return null;
total += item.duration;
}
let t = ((((nowMs - epochMs) / 1000) % total) + total) % total;
for (let i = 0; i < playlist.length; i++) {
if (t < playlist[i].duration) return { index: i, offset: t };
t -= playlist[i].duration;
}
return { index: 0, offset: 0 };
}
// Deterministic shuffle, seeded. shuffle-daily seeds with the date + the
// channel number, so the running order is stable all day (tune away and
// back and nothing reshuffles) and different tomorrow.
function seededShuffle(arr, seedStr) {
let h = 2166136261;
const s = String(seedStr);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
const rand = () => {
h ^= h << 13; h ^= h >>> 17; h ^= h << 5;
return ((h >>> 0) % 1e6) / 1e6;
};
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
const t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
// A folder's running order for the day: the server's natural sort, or a
// shuffle seeded by the date so it holds all day and reshuffles tomorrow.
function orderFiles(files, order, seed) {
return order === "shuffle-daily" ? seededShuffle(files, seed) : files;
}
// A video channel's air as segments [{ file, url, from, to, duration }],
// each a slice of one file, walked by positionAt like any playlist.
// Without breaks it is one segment per file, played whole (`to` null
// while a duration is unknown: play to the end). With breaks, each
// program is cut into acts of about everyMinutes (a 63-minute film at 15
// becomes four acts of 15:45), a break of `spots` spots follows every act
// - the last one included, so a break separates programs - and the spot
// pool cycles across the whole loop so each spot airs as often as the
// next. The movie resumes exactly where the break cut it. At 0 minutes
// (breaks only between programs) everyPrograms spaces the breaks out: a
// break after every Nth program, and always one at the end of the loop,
// so the loop's seam is a break too.
// Acts need every program duration up front; a spot whose length is not
// known yet sits out until the probe learns it. No usable spots (an empty
// or unprobed folder) means the program folder plays whole.
// Every segment carries `kind` ("program" or "spot") and the file's
// `title` (from its metadata, when the server read one), so the guide can
// name what is on without guessing from the URL.
function channelTimeline(channel, files, spots, date) {
const day = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate();
const program = orderFiles(files || [], channel.order, day + "#" + channel.number);
const b = channel.breaks;
const pool = b
? orderFiles((spots || []).filter((f) => f.duration > 0), channel.order, day + "#" + channel.number + "#breaks")
: [];
const whole = (f, kind) => ({ kind, file: f.file, title: f.title || null, url: f.url, from: 0, to: f.duration, duration: f.duration });
if (!b || !pool.length || program.some((f) => !(f.duration > 0))) return program.map((f) => whole(f, "program"));
const actLen = b.everyMinutes * 60;
const every = actLen > 0 ? 1 : Math.max(1, Math.round(b.everyPrograms || 1));
const out = [];
let cursor = 0;
program.forEach((p, pi) => {
const acts = actLen > 0 ? Math.max(1, Math.round(p.duration / actLen)) : 1;
for (let a = 0; a < acts; a++) {
const from = (p.duration * a) / acts;
const to = a === acts - 1 ? p.duration : (p.duration * (a + 1)) / acts;
out.push({ kind: "program", file: p.file, title: p.title || null, url: p.url, from, to, duration: to - from });
if ((pi + 1) % every !== 0 && pi !== program.length - 1) continue;
for (let k = 0; k < b.spots; k++) out.push(whole(pool[cursor++ % pool.length], "spot"));
}
});
return out;
}
// ---------------------------------------------------------- the schedule
// Is a scheduled channel on the air at `date`, and when does that change?
// Returns { onAir, untilMs, resumeText }. untilMs is when the state next
// flips (so the tuner can set one exact timer instead of polling); for a
// channel with no windows it is null and the state never changes.
function airState(channel, date) {
if (!channel || channel.mode !== "schedule") return { onAir: true, untilMs: null, resumeText: "" };
const windows = Array.isArray(channel.schedule) ? channel.schedule : [];
if (!windows.length) return { onAir: false, untilMs: null, resumeText: "NO PROGRAMMING SCHEDULED" };
// The schema expands each window (overnight ones included) into
// non-wrapping { day, start, end } minute segments, so all the math
// below can rely on start < end.
const segs = windows.flatMap((w) => S.windowSegments(w));
if (!segs.length) return { onAir: false, untilMs: null, resumeText: "NO PROGRAMMING SCHEDULED" };
const nowMin = date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60;
const today = date.getDay();
// On air now?
for (const g of segs) {
if (g.day === today && nowMin >= g.start && nowMin < g.end) {
const until = new Date(date);
until.setHours(Math.floor(g.end / 60), g.end % 60, 0, 0);
return { onAir: true, untilMs: until.getTime(), resumeText: "" };
}
}
// Off air: find the next segment start within the coming week. A cont
// segment (the morning half of an overnight window) can never win
// wrongly: being off air before it means its evening half is in the
// future too, and the evening half sorts earlier.
let best = null;
for (let d = 0; d < 8; d++) {
const day = (today + d) % 7;
for (const g of segs) {
if (g.day !== day) continue;
if (d === 0 && g.start <= nowMin) continue;
const at = new Date(date);
at.setDate(at.getDate() + d);
at.setHours(Math.floor(g.start / 60), g.start % 60, 0, 0);
if (!best || at.getTime() < best.at) best = { at: at.getTime(), day, startMin: g.start };
}
if (best) break;
}
if (!best) return { onAir: false, untilMs: null, resumeText: "NO PROGRAMMING SCHEDULED" };
const startMin = best.startMin;
const h12 = Math.floor(startMin / 60) % 12 || 12;
const ampm = startMin < 720 ? "AM" : "PM";
const mm = String(startMin % 60).padStart(2, "0");
const dayWord = best.day === date.getDay() && best.at - date.getTime() < 12 * 3600 * 1000 ? "TODAY" : DAYS[best.day];
return {
onAir: false,
untilMs: best.at,
resumeText: "PROGRAMMING RESUMES " + dayWord + " " + h12 + ":" + mm + " " + ampm,
};
}
// ---------------------------------------------------------- the dial itself
// Which dial position a tune command lands on. channels is the enabled,
// number-sorted dial; dir is +1/-1; wrap comes from config.
function nextChannelIndex(channels, idx, dir, wrap) {
if (!Array.isArray(channels) || channels.length < 2) return idx;
const next = idx + dir;
if (next < 0) return wrap ? channels.length - 1 : 0;
if (next >= channels.length) return wrap ? 0 : channels.length - 1;
return next;
}
// The sound levels a volume key steps through, in the order the Zenith
// Space Command stepped its motorized control: loud, off, soft, medium,
// back to loud. One key, no state on the remote: the set remembers.
const VOLUME_STEPS = [
{ level: 1, name: "LOUD" },
{ level: 0, name: "SOUND OFF" },
{ level: 0.35, name: "SOFT" },
{ level: 0.7, name: "MEDIUM" },
];
function nextVolumeStep(i) {
return (Number.isInteger(i) && i >= 0 && i < VOLUME_STEPS.length ? i + 1 : 1) % VOLUME_STEPS.length;
}
// The set holds its sound as a level, 0 to 1, because two kinds of key
// move it. The volume key walks the Zenith ring from wherever the level
// is: up to the next of its steps, and from loud around to sound off. A
// stick pushed left or right moves one cell of the on-screen meter at a
// time and stops at the ends.
const VOLUME_CELLS = 8;
const clampLevel = (l) => (Number.isFinite(l) ? Math.min(1, Math.max(0, l)) : 1);
function nextVolumeLevel(level) {
const l = clampLevel(level);
const above = VOLUME_STEPS.map((s) => s.level).filter((s) => s > l + 1e-9).sort((a, b) => a - b);
return above.length ? above[0] : 0;
}
function stepVolumeLevel(level, dir) {
const cell = Math.round(clampLevel(level) * VOLUME_CELLS) + (dir < 0 ? -1 : 1);
return Math.min(VOLUME_CELLS, Math.max(0, cell)) / VOLUME_CELLS;
}
// A button pressed more than once. Presses of the same button that follow
// each other within PRESS_GAP_MS are one run, and the count of a run is
// what it means: two is the power key. A long run switches the machine
// off, the moment the count is reached (shutdownPresses, 0 for never).
// Every other count means nothing, so a single press and a miscount do no
// harm.
const PRESS_GAP_MS = 500;
function countPress(run, button, nowMs) {
const same = run && run.button === button && nowMs - run.at <= PRESS_GAP_MS;
return { button, count: same ? run.count + 1 : 1, at: nowMs };
}
function runMeaning(count, shutdownPresses) {
if (shutdownPresses > 0 && count >= shutdownPresses) return "shutdown";
if (count === 2) return "power";
return null;
}
// ---------------------------------------------------------- the guide
// A program's on-screen name, from its filename. Strips the extension, a
// leading track number ("02 "), and an episode tag ("S01.E03."), then puts
// the separators back to spaces: "02 Design for Dreaming (1956).mp4"
// becomes "DESIGN FOR DREAMING (1956)".
function programTitle(file) {
let t = String(file || "").replace(/\.[a-z0-9]+$/i, "");
t = t.replace(/^\s*\d{1,3}[\s._-]+/, "");
t = t.replace(/^s\d{1,2}[\s._-]*e\d{1,3}[\s._-]*/i, "");
t = t.replace(/[._]+/g, " ").replace(/\s+/g, " ").trim();
return t.toUpperCase() || "PROGRAM";
}
// The half-hour slots a guide page shows: the one we are in now, then the
// ones after it. A guide always starts its grid on the half hour, which is
// why a listing that began at 8:12 still reads as the 8:00 column.
function guideSlots(date, count) {
const first = new Date(date.getTime());
first.setMinutes(first.getMinutes() < 30 ? 0 : 30, 0, 0);
const out = [];
for (let i = 0; i < (count || 3); i++) out.push(new Date(first.getTime() + i * 30 * 60000));
return out;
}
// What the guide calls one segment of a channel, by the channel's titles
// setting: the cleaned file name, the title written inside the file (the
// file name when it has none), or one fixed name for the whole channel.
function segmentName(channel, seg) {
if (channel.titles === "fixed") return channel.title || channel.name || programTitle(seg.file);
if (channel.titles === "metadata" && seg.title) return String(seg.title).toUpperCase();
return programTitle(seg.file);
}
// What one channel is showing at a moment. Video channels answer from the
// same broadcast clock the player runs on, so the guide can never disagree
// with the picture; everything else answers for what it is. A commercial
// break is never the answer: the guide names the program the break sits
// inside (the act before it), the way a printed grid would.
function programAt(channel, lib, date, epochMs) {
if (!channel || channel.enabled === false) return null;
if (channel.type === "guide") return { title: "PROGRAM GUIDE", kind: "guide" };
if (channel.type === "bulletin") return { title: "COMMUNITY BULLETIN BOARD", kind: "bulletin" };
if (channel.type === "external") return { title: "LIVE", kind: "external" };
const state = airState(channel, date);
if (!state.onAir) return { title: "OFF AIR", kind: "offair" };
const files = (lib && lib.files) || [];
if (!files.length) return { title: "NO PROGRAMS", kind: "empty" };
const tl = channelTimeline(channel, files, (lib && lib.spots) || [], date);
const pos = positionAt(tl, date.getTime(), epochMs);
if (!pos) return { title: "TO BE ANNOUNCED", kind: "unknown" };
let i = pos.index;
if (tl[i].kind === "spot") {
let j = i;
while (j >= 0 && tl[j].kind === "spot") j--;
if (j < 0) { j = i; while (j < tl.length && tl[j].kind === "spot") j++; }
if (j >= 0 && j < tl.length) i = j;
}
const seg = tl[i];
return { title: segmentName(channel, seg), kind: "program", file: seg.file };
}
// The grid: a row per channel, and within a row the cells merged wherever
// the same program runs across more than one slot.
function guideGrid(channels, libs, date, count, epochMs) {
const slots = guideSlots(date, count);
const rows = [];
for (const ch of channels) {
if (!ch || ch.enabled === false) continue;
const cells = [];
slots.forEach((slot, i) => {
const p = programAt(ch, libs && libs[ch.number], slot, epochMs) || { title: "", kind: "empty" };
const last = cells[cells.length - 1];
if (last && last.title === p.title && last.kind === p.kind) last.span += 1;
else cells.push({ title: p.title, kind: p.kind, span: 1, slot: i });
});
rows.push({ number: ch.number, name: ch.name, type: ch.type, cells });
}
return { slots, rows };
}
return {
EPOCH,
formatClock,
formatHeaderDate,
formatLongDate,
positionAt,
seededShuffle,
orderFiles,
channelTimeline,
airState,
nextChannelIndex,
VOLUME_STEPS,
nextVolumeStep,
VOLUME_CELLS,
nextVolumeLevel,
stepVolumeLevel,
PRESS_GAP_MS,
countPress,
runMeaning,
programTitle,
segmentName,
guideSlots,
programAt,
guideGrid,
};
});