├── public
├── otr.png
├── favicon.png
├── robots.txt
├── silence.mp3
├── css
│ ├── Bellerose.ttf
│ ├── CourierPrime-Regular.ttf
│ └── main.css
├── swirl_sprites.png
├── js
│ ├── clock.js
│ ├── utils.js
│ ├── sleepTimer.js
│ ├── viewSleepTimer.js
│ ├── stateMachine.js
│ ├── model.js
│ ├── viewSchedule.js
│ ├── events.js
│ ├── service.js
│ ├── snowMachine.js
│ ├── messageManager.js
│ ├── config.js
│ ├── audioPlayer.js
│ ├── playingNowManager.js
│ ├── visualiserData.js
│ ├── viewStationBuilder.js
│ ├── visualiser.js
│ ├── view.js
│ └── main.js
├── audio_test.html
└── index.html
├── src
├── utils.mts.js.map
├── clock.mts
├── tsconfig.json
├── sitemap.mts
├── archiveOrg.mts
├── app.mts
├── log.mts
├── config.mts
├── channelCodes.mts
├── utils.mts
├── webClient.mts
├── service.mts
├── webServer.mts
├── types.mts
├── shows.mts
├── scheduler.mts
└── cache.mts
├── .gitignore
├── package.json
├── LICENSE
├── notes.txt
├── README.md
└── config.json
/public/otr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/HEAD/public/otr.png
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 |
4 | Sitemap: https://oldtime.radio/sitemap.xml
--------------------------------------------------------------------------------
/public/silence.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/HEAD/public/silence.mp3
--------------------------------------------------------------------------------
/public/css/Bellerose.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/HEAD/public/css/Bellerose.ttf
--------------------------------------------------------------------------------
/public/swirl_sprites.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/HEAD/public/swirl_sprites.png
--------------------------------------------------------------------------------
/src/utils.mts.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"utils.mts.js","sourceRoot":"","sources":["utils.mts.ts"],"names":[],"mappings":""}
--------------------------------------------------------------------------------
/public/css/CourierPrime-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/HEAD/public/css/CourierPrime-Regular.ttf
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .idea
3 | *.iml
4 | node_modules
5 | cache
6 | public/hi.mp3
7 | public/lo.mp3
8 | public/test.mp3
9 | src/*.mjs
10 | src/*.mjs.map
11 |
--------------------------------------------------------------------------------
/public/js/clock.js:
--------------------------------------------------------------------------------
1 | function buildClock() {
2 | const MILLISECONDS_PER_SECOND = 1000;
3 | return {
4 | nowSeconds() {
5 | return Math.round(this.nowMillis() / MILLISECONDS_PER_SECOND);
6 | },
7 | nowMillis() {
8 | return Date.now();
9 | }
10 | }
11 | }
--------------------------------------------------------------------------------
/public/js/utils.js:
--------------------------------------------------------------------------------
1 | function shuffle(arr) {
2 | for (let i = arr.length - 1; i > 0; i--) {
3 | const j = Math.floor(Math.random() * (i + 1));
4 | [arr[i], arr[j]] = [arr[j], arr[i]];
5 | }
6 | return arr;
7 | }
8 | function rndRange(min, max) {
9 | return Math.random() * (max - min) + min;
10 | }
11 | function rndItem(arr) {
12 | return arr[Math.floor(Math.random() * arr.length)];
13 | }
--------------------------------------------------------------------------------
/src/clock.mts:
--------------------------------------------------------------------------------
1 | const MILLISECONDS_PER_SECOND = 1000;
2 |
3 | export type Seconds = number & { readonly __brand: unique symbol };
4 | export type Millis = number & { readonly __brand: unique symbol };
5 |
6 | class Clock {
7 | now(): Seconds {
8 | return this.nowMillis() / MILLISECONDS_PER_SECOND as Seconds;
9 | }
10 | nowMillis(): Millis {
11 | return Date.now() as Millis;
12 | }
13 | }
14 |
15 | export const clock = new Clock();
16 | export const ONE_HOUR = 60 * 60 as Seconds;
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "esModuleInterop": true,
4 | "skipLibCheck": true,
5 | "target": "es2022",
6 | "allowJs": true,
7 | "resolveJsonModule": true,
8 | "moduleDetection": "force",
9 | "isolatedModules": true,
10 | "verbatimModuleSyntax": true,
11 |
12 | "strict": true,
13 | "strictNullChecks": false,
14 | "noUncheckedIndexedAccess": true,
15 | "noImplicitOverride": true,
16 |
17 | "module": "NodeNext",
18 | "sourceMap": true,
19 |
20 | "lib": ["es2022"]
21 | }
22 | }
--------------------------------------------------------------------------------
/src/sitemap.mts:
--------------------------------------------------------------------------------
1 | import {config} from "./config.mjs";
2 | import type {ShowsListItem, Xml} from "./types.mjs";
3 |
4 | export function getSitemapXml(shows: ShowsListItem[]){
5 | "use strict";
6 | const urlPrefix = `${config.web.paths.publicUrlPrefix}${config.web.paths.listenTo}`,
7 | urlElements = shows
8 | .map(show => show.descriptiveId)
9 | .map(id => `${urlPrefix}/${id}`);
10 |
11 | return [
12 | '',
13 | ...urlElements,
14 | ''
15 | ].join('') as Xml;
16 | }
--------------------------------------------------------------------------------
/src/archiveOrg.mts:
--------------------------------------------------------------------------------
1 | import type {ArchiveOrgMetadata} from "./types.mjs";
2 | import {WebClient} from "./webClient.mjs";
3 | import {log} from "./log.mjs";
4 |
5 | export class ArchiveOrg {
6 | private webClient: WebClient;
7 |
8 | constructor() {
9 | this.webClient = new WebClient();
10 | }
11 |
12 | async get(playlistId: string): Promise {
13 | const url = `https://archive.org/metadata/${playlistId}`;
14 | try {
15 | log.debug(`Fetching ${url}...`);
16 | return await this.webClient.get(url) as ArchiveOrgMetadata;
17 | } catch (err) {
18 | throw new Error(`Failed to fetch ${url}: ${err}`);
19 | }
20 | }
21 |
22 | }
23 |
24 | export const archiveOrg = new ArchiveOrg();
--------------------------------------------------------------------------------
/src/app.mts:
--------------------------------------------------------------------------------
1 | import {WebServer} from "./webServer.mjs";
2 | import {config, configHelper} from "./config.mjs";
3 | import {shows} from "./shows.mjs";
4 | import {log} from "./log.mjs";
5 | import {scheduler} from "./scheduler.mjs";
6 |
7 | const webServer = new WebServer();
8 |
9 | function updateCaches(){
10 | log.debug("Updating caches...");
11 | return Promise.all(configHelper.getShows().map(show => shows.getEpisodesForShow(show.id)))
12 | .then(() => shows.refetchStaleItems())
13 | .then(() => scheduler.clearCache())
14 | .then(() => log.debug("shows cache updated successfully"))
15 | .catch(error => log.error(`Error updating shows cache: ${error.message}`));
16 | }
17 |
18 | setInterval(updateCaches, config.caches.showsCacheRefetchIntervalHours * 60 * 60 * 1000);
19 |
20 | updateCaches().then(() => {
21 | webServer.start();
22 | });
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "old-time-radio",
3 | "version": "1.0.0",
4 | "description": "A website for listening to Old Time Radio hosted by archive.org",
5 | "main": "src/app.js",
6 | "scripts": {
7 | "start": "node src/app.js"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/codebox/old-time-radio.git"
12 | },
13 | "author": "Rob Dawson (http://codebox.net)",
14 | "license": "MIT",
15 | "bugs": {
16 | "url": "https://github.com/codebox/old-time-radio/issues"
17 | },
18 | "homepage": "https://github.com/codebox/old-time-radio/issues",
19 | "dependencies": {
20 | "axios": "^1.6.0",
21 | "express": "^4.17.3",
22 | "jasmine": "^3.7.0",
23 | "level": "^9.0.0",
24 | "lru-cache": "^11.1.0",
25 | "proxyquire": "^2.1.3",
26 | "winston": "^3.17.0"
27 | },
28 | "devDependencies": {
29 | "@types/axios": "^0.14.4",
30 | "@types/express": "^5.0.1",
31 | "@types/node": "^22.13.14",
32 | "@types/winston": "^2.4.4",
33 | "typescript": "^5.7.3"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/public/audio_test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Old Time Radio - Audio Test
6 |
7 |
8 |
9 |
10 |
11 |
12 |
33 |
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Rob Dawson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/public/js/sleepTimer.js:
--------------------------------------------------------------------------------
1 | function buildSleepTimer(eventSource) {
2 | "use strict";
3 |
4 | const ONE_SECOND_IN_MILLIS = 1000, SECONDS_PER_MINUTE = 60, clock = buildClock();
5 |
6 | let endTimeSeconds, interval, minutesRequested;
7 |
8 | function onTick() {
9 | const secondsRemaining = endTimeSeconds - clock.nowSeconds();
10 | if (secondsRemaining > 0) {
11 | eventSource.trigger(EVENT_SLEEP_TIMER_TICK, secondsRemaining);
12 | } else {
13 | timer.stop();
14 | eventSource.trigger(EVENT_SLEEP_TIMER_DONE);
15 | }
16 | }
17 |
18 | const timer = {
19 | on: eventSource.on,
20 | start(minutes) {
21 | minutesRequested = minutes;
22 | endTimeSeconds = clock.nowSeconds() + minutes * SECONDS_PER_MINUTE;
23 | onTick();
24 | if (!interval) {
25 | interval = setInterval(onTick, ONE_SECOND_IN_MILLIS);
26 | }
27 | },
28 | stop() {
29 | clearInterval(interval);
30 | interval = null;
31 | minutesRequested = null;
32 | },
33 | getMinutesRequested() {
34 | return minutesRequested;
35 | }
36 | };
37 |
38 | return timer;
39 | }
--------------------------------------------------------------------------------
/src/log.mts:
--------------------------------------------------------------------------------
1 | import * as winston from "winston";
2 | import {config} from "./config.mjs";
3 |
4 | export class Log {
5 | private logger: winston.Logger;
6 |
7 | constructor() {
8 | this.logger = winston.createLogger({
9 | level: config.log.level,
10 | format: winston.format.combine(
11 | winston.format.errors({stack: true}),
12 | winston.format.timestamp(),
13 | winston.format.printf(info => {
14 | return `${info.timestamp} ${info.level.toUpperCase().padStart(5, ' ')}: ${info.message}`;
15 | })
16 | ),
17 | transports: [
18 | new winston.transports.Console()
19 | ]
20 | });
21 |
22 | }
23 |
24 | debug(message: string) {
25 | this.logger.log('debug', message);
26 | }
27 | info(message: string) {
28 | this.logger.log('info', message);
29 | }
30 | warn(message: string) {
31 | this.logger.log('warn', message);
32 | }
33 | error(message: string, error?: Error) {
34 | if (error) {
35 | this.logger.log('error', `${message}\n${error.stack}`);
36 | } else {
37 | this.logger.log('error', message);
38 | }
39 | }
40 | }
41 |
42 | export const log = new Log();
--------------------------------------------------------------------------------
/src/config.mts:
--------------------------------------------------------------------------------
1 | import {readFile} from "fs/promises";
2 | import type {Config, PlaylistId, ShowId} from "./types.mjs";
3 |
4 | export const config: Config = JSON.parse(await readFile("config.json", "utf8"));
5 |
6 | export const configHelper = {
7 | getShowForPlaylistId(playlistId: PlaylistId) {
8 | return config.shows.find(show => show.playlists.includes(playlistId));
9 | },
10 | getAllPlaylistIds() {
11 | return config.shows.flatMap(show => show.playlists)
12 | },
13 | getChannelNamesForShowId(showId: ShowId) {
14 | return config.channels.filter(channel => channel.shows.includes(showId)).map(channel => channel.name);
15 | },
16 | getShows(){
17 | return config.shows.map(show => {
18 | return {
19 | channels: configHelper.getChannelNamesForShowId(show.id),
20 | id: show.id,
21 | isCommercial: !! show.isCommercial,
22 | name: show.name,
23 | shortName: show.shortName || show.name,
24 | playlists: show.playlists
25 | };
26 | });
27 | },
28 | getChannels() {
29 | return config.channels.map(channel => channel.name);
30 | },
31 | getShowFromId(showId: ShowId) {
32 | const show = config.shows.find(show => show.id === showId);
33 | if (!show) {
34 | throw new Error(`Show with id '${showId}' not found in config`);
35 | }
36 | return show;
37 | }
38 | }
--------------------------------------------------------------------------------
/src/channelCodes.mts:
--------------------------------------------------------------------------------
1 | import type {ChannelCode, ShowId} from "./types.mjs";
2 |
3 | const TOO_BIG_ID = 200,
4 | SHOWS_PER_CHAR = 6,
5 | CHAR_MAP = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_';
6 |
7 | function numToString(n: number) {
8 | console.assert(n<64, n.toString());
9 | return CHAR_MAP.charAt(n);
10 | }
11 |
12 | function stringToNum(s: string) {
13 | console.assert(s.length === 1, s);
14 | const n = CHAR_MAP.indexOf(s);
15 | if (n < 0) {
16 | throw new Error(`Invalid character in channel code: '${s}'`);
17 | }
18 | return n;
19 | }
20 |
21 | export function buildChannelCodeFromShowIds(ids: ShowId[]): ChannelCode {
22 | const numericIds = ids.map(Number).filter(n=>!isNaN(n)),
23 | uniqueNumericIds = new Set(numericIds);
24 |
25 | const maxId = numericIds.length ? Math.max(...numericIds) : 0;
26 | if (maxId > TOO_BIG_ID) {
27 | throw new Error('Id is too large: ' + maxId);
28 | }
29 | const groupTotals = new Array(Math.ceil((maxId+1) / SHOWS_PER_CHAR)).fill(0);
30 |
31 | for (let i=0; i <= maxId; i++) {
32 | const groupIndex = Math.floor(i / SHOWS_PER_CHAR);
33 | if (uniqueNumericIds.has(i)) {
34 | groupTotals[groupIndex] += Math.pow(2, i - groupIndex * SHOWS_PER_CHAR);
35 | }
36 | }
37 | return groupTotals.map(numToString).join('') as ChannelCode;
38 | }
39 |
40 | export function buildShowIdsFromChannelCode(channelCode: ChannelCode) {
41 | const ids: number[] = [];
42 | channelCode.split('').forEach((c, charIndex) => {
43 | const num = stringToNum(c);
44 | ids.push(...[num & 1, num & 2, num & 4, num & 8, num & 16, num & 32].map((n,i) => n ? i + charIndex * SHOWS_PER_CHAR : null).filter(n => n !== null));
45 | });
46 | return ids as ShowId[];
47 | }
48 |
--------------------------------------------------------------------------------
/notes.txt:
--------------------------------------------------------------------------------
1 | Backend Rewrite
2 | ---------------
3 | scenarios to check
4 | - fails to download show when not present is disk cache (ie when service starts for first time)
5 | - ok to crash
6 | - fails to download show on refresh
7 | - keep stale copy and try again next time
8 | space patrol name parser doesnt add show name at the front
9 |
10 | Tests
11 | -----
12 | To run jasmine tests, from the root of the projet do:
13 | jasmine --config=test/support/jasmine.json
14 |
15 | - When everything on current playlist has been played, the API is queried again and playback continues
16 | - When playing a show the Download as MP3 link is visible
17 | - When not playing a show the Download as MP3 link is not visible
18 | - Stop/start playing resumes at correct point
19 | - Interrupting going to sleep process does not interrupt playback
20 |
21 | Volume Control
22 | - Volume is at max on first visit
23 | - Volume + button is disabled on first visit
24 | - Volume +/- are disabled/enabled correctly
25 | - Volume is saved between page refreshes
26 | - Adjusting volume before playing audio works correctly
27 | - Volume is saved in browser storage and set next time site is opened
28 |
29 | Sleep
30 | - Sleeping message is displayed
31 | - Periodic messages stop
32 | - Volume fades out
33 | - Triggering wake before sleep volume decrease completes, resets volume to pre-sleep level
34 |
35 | Wake
36 | - Volume restored to pre-sleep level
37 | - Select channel message shown
38 | - Periodic messages resume
39 | - Message set to 'Select a Channel'
40 |
41 | Schedule
42 | - Schedule for playing channel is auto-selected when menu is opened
43 | - Schedule is updated automatically if left open
44 |
45 | Station Builder
46 | - When listening to a custom station, schedule should show correct channels
47 |
48 | ---------------------
49 | New backend design
50 |
51 | Inputs ->
52 | data.json
53 | archive.org API / cache
54 |
55 | app.js
56 | /api/shows
57 | /api/channels
58 | /api/channel/:channel
59 | /api/channel/generate/:indexes
60 |
61 | service.js
62 | getShows()
63 | getChannels()
64 | getChannelPlaylist()
65 | getChannelCode()
66 |
67 |
--------------------------------------------------------------------------------
/public/js/viewSleepTimer.js:
--------------------------------------------------------------------------------
1 | function buildSleepTimerView(eventSource) {
2 | const elSleepTimerTime = document.getElementById('sleepTimerTime'),
3 | elSleepTimerRunningDisplay = document.getElementById('sleepTimerRunningDisplay'),
4 | elSleepTimerButtons = document.getElementById('sleepTimerButtons'),
5 |
6 | HIDDEN_CSS_CLASS = 'hidden',
7 | INTERVALS = config.sleepTimer.intervals;
8 |
9 | function formatTimePart(value) {
10 | return (value < 10 ? '0' : '') + value;
11 | }
12 |
13 | function setSelected(selectedButton) {
14 | elSleepTimerButtons.querySelectorAll('button').forEach(button => {
15 | const isSelected = button === selectedButton;
16 | button.ariaChecked = '' + isSelected;
17 | button.classList.toggle('selected', isSelected);
18 | });
19 | }
20 |
21 | return {
22 | init() {
23 | elSleepTimerButtons.innerHTML = '';
24 | INTERVALS.forEach(intervalMinutes => {
25 | const text = `${intervalMinutes} Minutes`;
26 | const button = document.createElement('button');
27 | button.setAttribute('role', 'radio');
28 | button.setAttribute('aria-controls', elSleepTimerTime.id);
29 | button.classList.add('menuButton');
30 | button.setAttribute('data-umami-event', `sleep-${intervalMinutes}`);
31 | button.innerHTML = text;
32 |
33 | button.onclick = () => {
34 | setSelected(button);
35 | eventSource.trigger(EVENT_SLEEP_TIMER_CLICK, intervalMinutes);
36 | };
37 |
38 | elSleepTimerButtons.appendChild(button);
39 | });
40 | },
41 | render(totalSeconds) {
42 | const hours = Math.floor(totalSeconds / 3600),
43 | minutes = Math.floor((totalSeconds % 3600) / 60),
44 | seconds = totalSeconds % 60;
45 | elSleepTimerTime.innerHTML = `${formatTimePart(hours)}:${formatTimePart(minutes)}:${formatTimePart(seconds)}`;
46 | },
47 | setRunState(isRunning) {
48 | elSleepTimerRunningDisplay.classList.toggle(HIDDEN_CSS_CLASS, !isRunning);
49 | if (!isRunning) {
50 | setSelected();
51 | }
52 | }
53 | };
54 | }
--------------------------------------------------------------------------------
/public/js/stateMachine.js:
--------------------------------------------------------------------------------
1 | const STATE_START = 'Start',
2 | STATE_INITIALISING = 'Initialising',
3 | STATE_IDLE = 'Idle',
4 | STATE_TUNING_IN = 'Tuning In',
5 | STATE_LOADING_TRACK = 'Loading Track',
6 | STATE_PLAYING = 'Playing',
7 | STATE_GOING_TO_SLEEP = 'Going to sleep',
8 | STATE_SLEEPING = 'Sleeping',
9 | STATE_ERROR = 'Error';
10 |
11 | function buildStateMachine() {
12 | "use strict";
13 |
14 | let state = STATE_START;
15 |
16 | function ifStateIsOneOf(...validStates) {
17 | return {
18 | thenChangeTo(newState) {
19 | if (validStates.includes(state)) {
20 | console.log('State changed to', newState);
21 | state = newState;
22 | } else {
23 | console.warn(`Unexpected state transition requested: ${state} -> ${newState}`);
24 | }
25 | }
26 | }
27 | }
28 |
29 | return {
30 | get state() {
31 | return state;
32 | },
33 | initialising() {
34 | ifStateIsOneOf(STATE_START)
35 | .thenChangeTo(STATE_INITIALISING);
36 | },
37 | idle() {
38 | ifStateIsOneOf(STATE_INITIALISING, STATE_TUNING_IN, STATE_PLAYING, STATE_LOADING_TRACK, STATE_SLEEPING)
39 | .thenChangeTo(STATE_IDLE);
40 | },
41 | error() {
42 | ifStateIsOneOf(STATE_INITIALISING, STATE_TUNING_IN, STATE_LOADING_TRACK)
43 | .thenChangeTo(STATE_ERROR);
44 | },
45 | tuningIn() {
46 | ifStateIsOneOf(STATE_IDLE, STATE_TUNING_IN, STATE_PLAYING, STATE_LOADING_TRACK, STATE_ERROR)
47 | .thenChangeTo(STATE_TUNING_IN);
48 | },
49 | goingToSleep() {
50 | ifStateIsOneOf(STATE_PLAYING)
51 | .thenChangeTo(STATE_GOING_TO_SLEEP);
52 | },
53 | sleeping() {
54 | ifStateIsOneOf(STATE_IDLE, STATE_TUNING_IN, STATE_LOADING_TRACK, STATE_ERROR, STATE_GOING_TO_SLEEP)
55 | .thenChangeTo(STATE_SLEEPING);
56 | },
57 | loadingTrack() {
58 | ifStateIsOneOf(STATE_TUNING_IN, STATE_PLAYING, STATE_ERROR)
59 | .thenChangeTo(STATE_LOADING_TRACK);
60 | },
61 | playing() {
62 | ifStateIsOneOf(STATE_LOADING_TRACK, STATE_GOING_TO_SLEEP)
63 | .thenChangeTo(STATE_PLAYING);
64 | }
65 | };
66 |
67 | }
--------------------------------------------------------------------------------
/src/utils.mts:
--------------------------------------------------------------------------------
1 | import type {Generator, ScheduleResults, ScheduleResultsAfterInsertHandler, ShowCounts, ShowId} from "./types.mjs";
2 | import {log} from "./log.mjs";
3 |
4 | type ShowCountsWithInitialCount = Map;
5 |
6 | function getNextShow(showCounts: ShowCountsWithInitialCount): ShowId {
7 | let bestShowSoFar = null as ShowId | null,
8 | bestScoreSoFar = -1;
9 |
10 | showCounts.forEach(({initial, remaining}, showId) => {
11 | const proportionRemaining = remaining / initial;
12 | if (proportionRemaining > bestScoreSoFar) {
13 | bestShowSoFar = showId;
14 | bestScoreSoFar = proportionRemaining;
15 | }
16 | });
17 |
18 | return bestShowSoFar;
19 | }
20 |
21 | export function schedule(initialShowCounts: ShowCounts, afterEach: ScheduleResultsAfterInsertHandler): ScheduleResults {
22 | const results = [] as ScheduleResults,
23 | showCounts = new Map() as ShowCountsWithInitialCount;
24 |
25 | initialShowCounts.forEach((initial, showId) => {
26 | if (initial > 0) {
27 | showCounts.set(showId, {initial, remaining: initial});
28 | } else {
29 | log.warn(`Show ${showId} has no episodes, skipping`);
30 | }
31 | });
32 |
33 | while(showCounts.size > 0) {
34 | const nextShowId = getNextShow(showCounts);
35 |
36 | results.push(nextShowId);
37 |
38 | showCounts.get(nextShowId).remaining--;
39 | if (showCounts.get(nextShowId).remaining <= 0) {
40 | showCounts.delete(nextShowId);
41 | }
42 |
43 | afterEach(results);
44 | }
45 |
46 | return results;
47 | }
48 |
49 | export function generator(items: T[]): Generator {
50 | if (items.length === 0) {
51 | throw new Error("array is empty");
52 | }
53 |
54 | let nextIndex = 0;
55 | return {
56 | next() {
57 | return items[nextIndex++ % items.length];
58 | },
59 | get length() {
60 | return items.length;
61 | }
62 | };
63 | }
64 |
65 | export function deepEquals(a: any, b: any): boolean{
66 | if (a === b) return true;
67 | if (a == null || b == null) return false;
68 | if (typeof a !== 'object' || typeof b !== 'object') return false;
69 |
70 | const keys = Object.keys(a);
71 | if (keys.length !== Object.keys(b).length) return false;
72 |
73 | return keys.every(key => deepEquals(a[key], b[key]));
74 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Old Time Radio
2 |
3 | **You can see this code running now at [https://oldtime.radio](https://oldtime.radio)**
4 |
5 | There are [thousands of classic radio shows from the 1930s, 40s and 50s available on The Internet Archive](https://archive.org/details/oldtimeradio), so I've made an [internet radio station](https://oldtime.radio/) for them!
6 |
7 |
8 |
9 | The server-side components of the site are written in Node.js. When the site starts up it reads [this configuration file](https://github.com/codebox/old-time-radio/blob/master/data.json), which contains a list of the various radio shows that the site will broadcast. The site then uses the [Internet Archive's metadata API](https://archive.org/services/docs/api/metadata.html) to get a list of the individual episodes that are available for each show, together with the mp3 file urls for each one.
10 |
11 | The configuration file also contains a list of channels (eg Comedy, Western, Action) and specifies which shows should be played on each one. A playlist is generated for each channel, alternating the shows with vintage radio commercials to make the listening experience more authentic. The commercials are sometimes more entertaining than the shows themselves, being very much [of](https://archive.org/details/Old_Radio_Adverts_01/OldRadio_Adv--Bromo_Quinine.mp3) [their](https://archive.org/details/Old_Radio_Adverts_01/OldRadio_Adv--Camel1.mp3) [time](https://archive.org/details/Old_Radio_Adverts_01/OldRadio_Adv--Fitch.mp3).
12 |
13 | The audio that your hear on the site is streamed directly from The Internet Archive, my site does not host any of the content. The [Same-Origin Policy](https://en.wikipedia.org/wiki/Same-origin_policy) often limits what websites can do with remotely hosted media. Typically such media can be displayed by other websites, but not accessed by any scripts running on those sites. However the Internet Archive explicitly allows script access to their audio files by including [the following header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) in their HTTP responses:
14 |
15 | Access-Control-Allow-Origin: *
16 |
17 | This allowed me to write some JavaScript to analyse the audio signal in real-time and produce a satisfying visualisation, making the site more interesting to look at:
18 |
19 | [Visualisation Video](https://codebox.net/assets/video/old-time-radio/audio-visualisation.mp4)
20 |
21 |
--------------------------------------------------------------------------------
/public/js/model.js:
--------------------------------------------------------------------------------
1 | function buildModel() {
2 | "use strict";
3 | const MIN_VOLUME = 1,
4 | MAX_VOLUME = 10,
5 | MODE_NORMAL = 'normal',
6 | MODE_SINGLE_SHOW = 'singleShow',
7 | MODE_USER_CHANNELS = 'userChannels',
8 | STORED_PROPS = {
9 | 'volume': MAX_VOLUME,
10 | 'visualiserId': 'Oscillograph',
11 | 'showInfoMessages': true,
12 | 'showNowPlayingMessages': true,
13 | };
14 |
15 | let volume = 10,
16 | mode,
17 | stationBuilderModel = {
18 | shows:[],
19 | savedChannelCodes: [],
20 | commercialShowIds:[],
21 | includeCommercials: false
22 | };
23 |
24 | const model = {
25 | load() {
26 | Object.keys(STORED_PROPS).forEach(propName => {
27 | const valueAsString = localStorage.getItem(propName);
28 | let typedValue;
29 |
30 | if (valueAsString === null) {
31 | typedValue = STORED_PROPS[propName];
32 | } else if (valueAsString === 'true') {
33 | typedValue = true;
34 | } else if (valueAsString === 'false') {
35 | typedValue = false;
36 | } else if (/^\d+$/.test(valueAsString)) {
37 | typedValue = Number(valueAsString);
38 | } else {
39 | typedValue = valueAsString;
40 | }
41 | model[propName] = typedValue;
42 | });
43 | },
44 | save() {
45 | Object.keys(STORED_PROPS).forEach(propName => {
46 | localStorage.setItem(propName, model[propName]);
47 | });
48 | },
49 | get maxVolume() {
50 | return MAX_VOLUME;
51 | },
52 | get minVolume() {
53 | return MIN_VOLUME;
54 | },
55 | get volume() {
56 | return volume;
57 | },
58 | set volume(value) {
59 | volume = Math.max(Math.min(value, MAX_VOLUME), MIN_VOLUME);
60 | },
61 | setModeNormal() {
62 | mode = MODE_NORMAL;
63 | },
64 | setModeSingleShow() {
65 | mode = MODE_SINGLE_SHOW;
66 | },
67 | setModelUserChannels() {
68 | mode = MODE_USER_CHANNELS;
69 | },
70 | isUserChannelMode() {
71 | return mode === MODE_USER_CHANNELS;
72 | },
73 | isSingleShowMode() {
74 | return mode === MODE_SINGLE_SHOW;
75 | },
76 | stationBuilder: stationBuilderModel
77 | };
78 |
79 | model.load();
80 |
81 | return model;
82 | }
--------------------------------------------------------------------------------
/src/webClient.mts:
--------------------------------------------------------------------------------
1 | import {log} from "./log.mjs";
2 | import {config} from "./config.mjs";
3 | import {clock, type Millis} from "./clock.mjs";
4 | import axios from 'axios';
5 |
6 | type WebRequest = {
7 | url: string;
8 | resolve: (data: any) => void;
9 | reject: (response: any) => void;
10 | }
11 |
12 | const requestQueue = (() => {
13 | const pendingRequests: WebRequest[] = [];
14 | let lastRequestMillis = 0 as Millis, running = false, interval;
15 |
16 | function ensureRequestProcessorIsRunning(){
17 | if (!running) {
18 | log.debug('Starting request processor');
19 | running = true;
20 |
21 | function processNext() {
22 | const nextRequestPermittedTs = lastRequestMillis + config.minRequestIntervalMillis as Millis,
23 | timeUntilNextRequestPermitted = Math.max(0, nextRequestPermittedTs - clock.nowMillis()) as Millis;
24 |
25 | setTimeout(() => {
26 | const {url, resolve, reject} = pendingRequests.shift();
27 | log.debug(`Requesting ${url}...`);
28 | axios.get(url)
29 | .then(response => {
30 | log.debug(`Request for ${url} succeeded: ${response.status} - ${response.statusText}`);
31 | resolve(response.data)
32 | })
33 | .catch(response => {
34 | log.error(`Request for ${url} failed: ${response.status} - ${response.statusText}`);
35 | reject(response);
36 | })
37 | .finally(() => {
38 | lastRequestMillis = clock.nowMillis();
39 | if (pendingRequests.length === 0) {
40 | log.debug('Request queue is empty, shutting down processor');
41 | running = false;
42 | } else {
43 | processNext();
44 | }
45 | });
46 | }, timeUntilNextRequestPermitted);
47 | }
48 |
49 | processNext();
50 | }
51 | }
52 |
53 | return {
54 | push(url: string) {
55 | return new Promise((resolve, reject) => {
56 | pendingRequests.push({url, resolve, reject});
57 | ensureRequestProcessorIsRunning();
58 | });
59 | }
60 | };
61 | })();
62 |
63 | export class WebClient {
64 | async get(url: string): Promise<{ [key: string]: any }> {
65 | return requestQueue.push(url);
66 | }
67 | }
--------------------------------------------------------------------------------
/public/js/viewSchedule.js:
--------------------------------------------------------------------------------
1 | function buildScheduleView(eventSource) {
2 | "use strict";
3 |
4 | const elChannelLinks = document.getElementById('channelScheduleLinks'),
5 | elScheduleList = document.getElementById('scheduleList'),
6 | channelToElement = {},
7 | CSS_CLASS_SELECTED = 'selected',
8 | clock = buildClock();
9 |
10 | return {
11 | addChannel(channel) {
12 | const button = document.createElement('button');
13 | button.innerHTML = channel.name;
14 | button.classList.add('menuButton');
15 | button.setAttribute('data-umami-event', `schedule-${channel.name.toLowerCase().replaceAll(' ', '-')}`);
16 | button.setAttribute('role', 'radio');
17 | button.setAttribute('aria-controls', elScheduleList.id);
18 | button.onclick = () => {
19 | eventSource.trigger(EVENT_SCHEDULE_BUTTON_CLICK, channel.id);
20 | };
21 | elChannelLinks.appendChild(button);
22 | channelToElement[channel.id] = button;
23 | },
24 | setSelectedChannel(selectedChannelId) {
25 | Object.keys(channelToElement).forEach(channelId => {
26 | const el = channelToElement[channelId];
27 | el.classList.toggle(CSS_CLASS_SELECTED, selectedChannelId === channelId);
28 | el.ariaChecked = selectedChannelId === channelId;
29 | });
30 | },
31 | displaySchedule(schedule) {
32 | const playingNow = schedule.list.shift(),
33 | timeNow = clock.nowSeconds();
34 | let nextShowStartOffsetFromNow = playingNow.length - schedule.initialOffset;
35 |
36 | const scheduleList = [{time: 'NOW >', name: playingNow.name}];
37 | scheduleList.push(...schedule.list.filter(item => !item.commercial).map(item => {
38 | const ts = nextShowStartOffsetFromNow + timeNow,
39 | date = new Date(ts * 1000),
40 | hh = date.getHours().toString().padStart(2,'0'),
41 | mm = date.getMinutes().toString().padStart(2,'0');
42 | const result = {
43 | time: `${hh}:${mm}`,
44 | name: item.name,
45 | commercial: item.commercial
46 | };
47 | nextShowStartOffsetFromNow += item.length;
48 | return result;
49 | }));
50 |
51 | elScheduleList.innerHTML = '';
52 | scheduleList.forEach(scheduleItem => {
53 | const el = document.createElement('li');
54 | el.innerHTML = `${scheduleItem.time}
${scheduleItem.name}
`;
55 | elScheduleList.appendChild(el);
56 | });
57 | },
58 | hideSchedule() {
59 | elScheduleList.innerHTML = '';
60 | }
61 | };
62 | }
--------------------------------------------------------------------------------
/src/service.mts:
--------------------------------------------------------------------------------
1 | import {config} from "./config.mjs";
2 | import type {
3 | ChannelId,
4 | ConfigShow,
5 | DescriptiveId, PlayingNowAndNext,
6 | ShowId,
7 | ShowsListItem, Xml, CurrentChannelScheduleWithDetails
8 | } from "./types.mjs";
9 | import {buildChannelCodeFromShowIds} from "./channelCodes.mjs";
10 | import type {Seconds} from "./clock.mjs";
11 | import {getSitemapXml} from "./sitemap.mjs";
12 | import {shows} from "./shows.mjs";
13 | import {scheduler} from "./scheduler.mjs";
14 |
15 | function getChannelIdsForShowId(showId: ShowId) {
16 | return config.channels.filter(channel => channel.shows.includes(showId)).map(channel => channel.name);
17 | }
18 |
19 | function getDescriptiveIdForShowName(showName: string): DescriptiveId {
20 | return showName.toLowerCase().replace(/ /g, '-').replace(/-+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') as DescriptiveId;
21 | }
22 |
23 | export class Service {
24 | private getShowsListItemFromConfigShow(configShow: ConfigShow): ShowsListItem {
25 | return {
26 | channels: getChannelIdsForShowId(configShow.id),
27 | id: configShow.id,
28 | isCommercial: configShow.isCommercial,
29 | name: configShow.name,
30 | shortName: configShow.shortName || configShow.name,
31 | descriptiveId: getDescriptiveIdForShowName(configShow.name),
32 | channelCode: buildChannelCodeFromShowIds([configShow.id]),
33 | };
34 | }
35 |
36 | getShows(): Promise {
37 | return Promise.resolve(config.shows.map(show => this.getShowsListItemFromConfigShow(show)));
38 | }
39 |
40 | getChannels(): Promise {
41 | return Promise.resolve(config.channels.map(channel => channel.name));
42 | }
43 |
44 | async getScheduleForChannel(channelId: ChannelId, length: Seconds): Promise {
45 | const scheduleWithoutDetails = await scheduler.getScheduleForChannel(channelId, length),
46 | scheduleEpisodeDetails = await Promise.all(scheduleWithoutDetails.list.map(episode => shows.getEpisodeDetails(episode)));
47 |
48 | return {
49 | list: scheduleEpisodeDetails,
50 | initialOffset: scheduleWithoutDetails.initialOffset,
51 | } as CurrentChannelScheduleWithDetails;
52 | }
53 |
54 | getCodeForShowIds(showIds: ShowId[]): ChannelId {
55 | return buildChannelCodeFromShowIds(showIds);
56 | }
57 |
58 | getPlayingNowAndNext(channels: ChannelId[]): Promise {
59 | return Promise.all(channels.map(channelId => scheduler.getPlayingNowAndNext(channelId))).then(channelSchedules => {
60 | const result = {} as PlayingNowAndNext;
61 | channels.map((channelId, index) => {
62 | result[channelId] = channelSchedules[index];
63 | });
64 | return result;
65 | });
66 | }
67 |
68 | getSitemapXml(): Promise {
69 | return this.getShows().then(shows => getSitemapXml(shows));
70 | }
71 | }
--------------------------------------------------------------------------------
/public/js/events.js:
--------------------------------------------------------------------------------
1 | function getEventTarget() {
2 | "use strict";
3 | try {
4 | return new EventTarget();
5 | } catch(err) {
6 | const listeners = [];
7 | return {
8 | dispatchEvent(event) {
9 | listeners.filter(listener => listener.name === event.type).forEach(listener => {
10 | listener.handler(event);
11 | });
12 | },
13 | addEventListener(name, handler) {
14 | listeners.push({name, handler});
15 | }
16 | };
17 | }
18 | }
19 |
20 | function buildEventSource(name, stateMachine) {
21 | "use strict";
22 | const eventTarget = getEventTarget();
23 |
24 | return {
25 | trigger(eventName, eventData) {
26 | //console.debug(`=== EVENT ${name + ' ' || ''}: ${eventName} ${JSON.stringify(eventData) || ''}`);
27 | const event = new Event(eventName);
28 | event.data = eventData;
29 | eventTarget.dispatchEvent(event);
30 | },
31 | on(eventName) {
32 | return {
33 | then(handler) {
34 | eventTarget.addEventListener(eventName, handler);
35 | },
36 | ifState(...states) {
37 | return {
38 | then(handler) {
39 | eventTarget.addEventListener(eventName, event => {
40 | if (states.includes(stateMachine.state)) {
41 | handler(event);
42 | }
43 | });
44 | }
45 | };
46 | }
47 | };
48 |
49 | }
50 | };
51 | }
52 |
53 | const EVENT_CHANNEL_BUTTON_CLICK = 'channelButtonClick',
54 | EVENT_AUDIO_ERROR = 'audioError',
55 | EVENT_AUDIO_TRACK_LOADED = 'audioTrackLoaded',
56 | EVENT_AUDIO_TRACK_ENDED = 'audioTrackEnded',
57 | EVENT_AUDIO_PLAY_STARTED = 'audioPlaying',
58 | EVENT_MENU_OPEN_CLICK = 'menuOpenClick',
59 | EVENT_MENU_CLOSE_CLICK = 'menuCloseClick',
60 | EVENT_VOLUME_UP_CLICK = 'volumeUpClick',
61 | EVENT_VOLUME_DOWN_CLICK = 'volumeDownClick',
62 | EVENT_PREF_INFO_MESSAGES_CLICK = 'prefInfoMessagesClick',
63 | EVENT_PREF_NOW_PLAYING_CLICK = 'prefNowPlayingMessagesClick',
64 | EVENT_NEW_MESSAGE = 'newMessage',
65 | EVENT_MESSAGE_PRINTING_COMPLETE = 'messagePrintingComplete',
66 | EVENT_SLEEP_TIMER_CLICK = 'sleepTimerClick',
67 | EVENT_SLEEP_TIMER_TICK = 'sleepTimerTick',
68 | EVENT_SLEEP_TIMER_DONE = 'sleepTimerDone',
69 | EVENT_WAKE_UP = 'wakeUp',
70 | EVENT_SCHEDULE_BUTTON_CLICK = 'scheduleButtonClick',
71 | EVENT_STATION_BUILDER_SHOW_CLICK = 'stationBuilderShowClick',
72 | EVENT_STATION_BUILDER_PLAY_COMMERCIALS_CLICK = 'stationBuilderPlayCommercialsClick',
73 | EVENT_STATION_BUILDER_CREATE_CHANNEL_CLICK = 'stationBuilderCreateChannelClick',
74 | EVENT_STATION_BUILDER_GO_TO_CHANNEL_CLICK = 'stationBuilderGoToChannelClick',
75 | EVENT_STATION_BUILDER_ADD_CHANNEL_CLICK = 'stationBuilderAddChannelClick',
76 | EVENT_STATION_BUILDER_DELETE_STATION_CLICK = 'stationBuilderDeleteStationClick',
77 | EVENT_VISUALISER_BUTTON_CLICK = 'visualiserButtonClick';
78 |
--------------------------------------------------------------------------------
/public/js/service.js:
--------------------------------------------------------------------------------
1 | function buildService() {
2 | const clock = buildClock();
3 | "use strict";
4 | const playlistCache = (() => {
5 | const cache = {};
6 |
7 | function buildKeyName(channelId, length) {
8 | return `${channelId}_${length}`;
9 | }
10 |
11 | return {
12 | get(channelId, length) {
13 | const key = buildKeyName(channelId, length),
14 | entry = cache[key];
15 | if (entry) {
16 | const ageInSeconds = clock.nowSeconds() - entry.ts,
17 | initialOffsetInSecondsNow = entry.playlist.initialOffset + ageInSeconds,
18 | lengthOfCurrentPlaylistItem = entry.playlist.list[0].length;
19 | if (lengthOfCurrentPlaylistItem > initialOffsetInSecondsNow) {
20 | return {
21 | initialOffset: initialOffsetInSecondsNow,
22 | list: [...entry.playlist.list] // defensive copy, the playlist object will get mutated by other code
23 | }
24 | } else {
25 | delete cache[key];
26 | }
27 | }
28 | },
29 | set(channelId, length, playlist) {
30 | const key = buildKeyName(channelId, length);
31 | cache[key] = {
32 | ts: clock.nowSeconds(),
33 | playlist: { // defensive copy
34 | initialOffset: playlist.initialOffset,
35 | list: [...playlist.list]
36 | }
37 | };
38 | }
39 | };
40 | })();
41 |
42 | return {
43 | getChannels() {
44 | return fetch('/api/channels')
45 | .then(response => response.json());
46 | },
47 | getShowList() {
48 | return fetch('/api/shows')
49 | .then(response => response.json());
50 | },
51 | getChannelCodeForShows(indexes) {
52 | return fetch(`/api/channel/generate/${indexes.join(',')}`)
53 | .then(response => response.json());
54 | },
55 | getPlaylistForChannel(channelId, length) {
56 | const cachedPlaylist = playlistCache.get(channelId, length);
57 | if (cachedPlaylist) {
58 | console.log(`Cache HIT for ${channelId}/${length}`);
59 | return Promise.resolve(cachedPlaylist);
60 | }
61 | console.log(`Cache MISS for ${channelId}/${length}`);
62 | return fetch(`/api/channel/${channelId}${length ? '?length=' + length : ''}`)
63 | .then(response => {
64 | return response.json().then(playlist => {
65 | playlistCache.set(channelId, length, playlist);
66 | return playlist;
67 | });
68 | });
69 | },
70 | getPlayingNow(channelsList) {
71 | const hasChannels = channelsList && channelsList.length > 0,
72 | channelsParameter = hasChannels ? channelsList.map(encodeURIComponent).join(',') : '';
73 | return fetch(`/api/playing-now${channelsParameter ? '?channels=' + channelsParameter : ''}`)
74 | .then(response => response.json());
75 | }
76 | };
77 | }
--------------------------------------------------------------------------------
/public/js/snowMachine.js:
--------------------------------------------------------------------------------
1 | function buildSnowMachine(elCanvas) {
2 | const maxSnowflakeCount = config.snow.maxFlakeCount,
3 | minSize = config.snow.minFlakeSize,
4 | maxSize = config.snow.maxFlakeSize,
5 | minXSpeed = -config.snow.maxXSpeed,
6 | maxXSpeed = config.snow.maxXSpeed,
7 | minYSpeed = config.snow.minYSpeed,
8 | maxYSpeed = config.snow.maxYSpeed,
9 | windSpeedDelta = config.snow.windSpeedDelta,
10 | windSpeedChangeIntervalMillis = config.snow.windSpeedChangeIntervalSeconds * 1000,
11 | snowflakeAddIntervalMillis = config.snow.snowflakeAddIntervalSeconds * 1000,
12 | distanceColourFade = config.snow.distanceColourFade;
13 |
14 | let snowFlakeCount = 0, running = false, currentWindSpeed = 0, targetWindSpeed = 0,
15 | lastWindSpeedChangeTs = Date.now(),
16 | lastAddedSnowflakeTs = Date.now();
17 |
18 | function buildSnowflake() {
19 | const distance = rndRange(0, 1);
20 | return {
21 | x: (rndRange(0, 2) - 0.5) * elCanvas.width,
22 | y: 0,
23 | size: ((1 - distance) * (maxSize - minSize)) + minSize,
24 | speedX: rndRange(minXSpeed, maxXSpeed),
25 | speedY: ((1-distance) * (maxYSpeed - minYSpeed)) + minYSpeed,
26 | color: `rgba(255, 255, 255, ${1 - distance/distanceColourFade})`,
27 | distance
28 | }
29 | }
30 |
31 | function drawSnowflake(snowflake) {
32 | const ctx = elCanvas.getContext('2d');
33 | ctx.beginPath();
34 | ctx.arc(snowflake.x, snowflake.y, snowflake.size, 0, 2 * Math.PI);
35 | ctx.fillStyle = snowflake.color;
36 | ctx.fill();
37 | }
38 | function updateSnowflake(snowflake) {
39 | snowflake.x += snowflake.speedX + (currentWindSpeed * (1 - snowflake.distance / 2));
40 | snowflake.y += snowflake.speedY;
41 | if (snowflake.y > elCanvas.height) {
42 | Object.assign(snowflake, buildSnowflake());
43 | }
44 | }
45 | function updateCanvas() {
46 | const ctx = elCanvas.getContext('2d');
47 | ctx.clearRect(0, 0, elCanvas.width, elCanvas.height);
48 | if (lastWindSpeedChangeTs + windSpeedChangeIntervalMillis < Date.now()) {
49 | targetWindSpeed = rndRange(-config.snow.windSpeedMax, config.snow.windSpeedMax);
50 | lastWindSpeedChangeTs = Date.now();
51 | }
52 | if (Math.abs(targetWindSpeed - currentWindSpeed) < windSpeedDelta) {
53 | currentWindSpeed = targetWindSpeed;
54 | } else {
55 | currentWindSpeed += Math.sign(targetWindSpeed - currentWindSpeed) * windSpeedDelta;
56 | }
57 | if (snowflakes.length < snowFlakeCount) {
58 | if (lastAddedSnowflakeTs + snowflakeAddIntervalMillis < Date.now()) {
59 | snowflakes.push(buildSnowflake());
60 | lastAddedSnowflakeTs = Date.now();
61 | }
62 | }
63 | snowflakes.forEach(updateSnowflake);
64 | snowflakes.forEach(drawSnowflake);
65 | if (running) {
66 | requestAnimationFrame(updateCanvas);
67 | }
68 | }
69 | const snowflakes = [];
70 | return {
71 | start(intensity) {
72 | snowFlakeCount = Math.round(maxSnowflakeCount * intensity);
73 | running = true;
74 | updateCanvas();
75 | },
76 | stop() {
77 | running = false;
78 | snowflakes.length = 0;
79 | }
80 | }
81 | }
--------------------------------------------------------------------------------
/public/js/messageManager.js:
--------------------------------------------------------------------------------
1 | function buildMessageManager(model, eventSource) {
2 | "use strict";
3 | const TEMP_MESSAGE_DURATION = config.messages.tempMessageDurationMillis;
4 |
5 | let persistentMessage, temporaryMessage;
6 |
7 | function triggerNewMessage(text, isTemp) {
8 | if (isTemp) {
9 | if (temporaryMessage) {
10 | return;
11 | }
12 | temporaryMessage = text;
13 | setTimeout(() => {
14 | if (temporaryMessage === text) {
15 | triggerNewMessage(persistentMessage);
16 | }
17 | }, TEMP_MESSAGE_DURATION);
18 |
19 | } else {
20 | temporaryMessage = null;
21 | persistentMessage = text;
22 | }
23 | eventSource.trigger(EVENT_NEW_MESSAGE, {text, isTemp});
24 | }
25 |
26 | const cannedMessages = (() => {
27 | function showNext() {
28 | const nonCommercials = (model.playlist || []).filter(item => !item.commercial);
29 | if (nonCommercials.length){
30 | return `Up next: ${nonCommercials[0].name}`;
31 | }
32 | }
33 | function getModeSpecificCannedMessages() {
34 | if (model.isUserChannelMode()) {
35 | return config.messages.canned.userChannel;
36 | } else if (model.isSingleShowMode()) {
37 | return config.messages.canned.singleShow;
38 | } else {
39 | return config.messages.canned.normal;
40 | }
41 | }
42 |
43 | let messages, nextIndex = 0;
44 | return {
45 | init() {
46 | const modeSpecificCannedMessages = getModeSpecificCannedMessages(),
47 | allCannedMessages = [...modeSpecificCannedMessages, ...config.messages.canned.all];
48 |
49 | messages = allCannedMessages.map(textMessage => [showNext, textMessage]).flatMap(m => m);
50 | },
51 | next() {
52 | const nextMsg = messages[nextIndex = (nextIndex + 1) % messages.length];
53 | return (typeof nextMsg === 'function') ? nextMsg() : nextMsg;
54 | }
55 | };
56 | })();
57 |
58 | return {
59 | on: eventSource.on,
60 | init() {
61 | cannedMessages.init();
62 | },
63 | showLoadingChannels() {
64 | triggerNewMessage('Loading Channels...');
65 | },
66 | showSelectChannel() {
67 | if (model.channels.length === 1) {
68 | triggerNewMessage(`Press the '${model.channels[0].name}' button to tune in`);
69 | } else {
70 | triggerNewMessage('Select a channel');
71 | }
72 | },
73 | showTuningInToChannel(channelName) {
74 | if (model.isSingleShowMode() || model.isUserChannelMode()) {
75 | triggerNewMessage(`Tuning in to ${channelName}...`);
76 | } else {
77 | triggerNewMessage(`Tuning in to the ${channelName} channel...`);
78 | }
79 | },
80 | showNowPlaying(trackName) {
81 | triggerNewMessage(trackName);
82 | },
83 | showTempMessage() {
84 | const msgText = cannedMessages.next();
85 | if (msgText) {
86 | triggerNewMessage(msgText, true);
87 | }
88 | },
89 | showSleeping() {
90 | triggerNewMessage('Sleeping');
91 | },
92 | showError() {
93 | triggerNewMessage(`There is a reception problem, please adjust your aerial`);
94 | }
95 | };
96 | }
--------------------------------------------------------------------------------
/public/js/config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | audio : {
3 | smoothing: 0.8,
4 | fftWindowSize: 1024
5 | },
6 | visualiser: {
7 | fadeOutIntervalMillis: 2000,
8 | oscillograph: {
9 | bucketCount: 20,
10 | waveSpeed: 0.5,
11 | minWaveLightness: 10
12 | },
13 | phonograph: {
14 | bucketCount: 100,
15 | bucketSpread: 1.2,
16 | minRadius: 30,
17 | silenceThresholdMillis: 5 * 1000,
18 | gapTotal: Math.PI,
19 | offsetRate: 1 / 100000,
20 | snapshotIntervalMillis: 1000,
21 | gradientStartColour: 10,
22 | gradientStopColour: 255,
23 | snapshotStartColour: 100,
24 | snapshotStopColour: 0,
25 | snapshotSpeed: 1,
26 | snapshotFadeOutFactor: 2
27 | },
28 | spirograph: {
29 | bucketCount: 100,
30 | bucketSpread: 1.5,
31 | silenceThresholdMillis: 5 * 1000,
32 | rotationBaseValue: 0.0005,
33 | alphaCycleRate: 600,
34 | aspectRatio: 0.5,
35 | rotationFactor: 1 / 3,
36 | maxRadiusSize: 0.5,
37 | minRadiusSize: 0.25,
38 | historySize: 10,
39 | backgroundLoop: {
40 | minRadiusFactor: 0.5,
41 | maxRadiusFactor: 2,
42 | minAlpha: 0.05,
43 | maxAlpha: 0.15,
44 | offset: 0
45 | },
46 | foregroundLoop: {
47 | minAlpha: 0.1,
48 | maxAlpha: 0.4,
49 | offset: 0.5
50 | }
51 | }
52 | },
53 | sleepTimer: {
54 | fadeOutDelta: 0.02,
55 | fadeOutIntervalMillis: 100,
56 | intervals: [90,60,45,30,15]
57 | },
58 | schedule: {
59 | refreshIntervalMillis: 5000,
60 | lengthInSeconds: 12 * 60 * 60
61 | },
62 | playingNow: {
63 | apiCallIntervalMillis: 30 * 1000,
64 | infoDisplayIntervalMillis: 5 * 1000,
65 | },
66 | messages: {
67 | canned: {
68 | "all": [
69 | 'All audio hosted by The Internet Archive. Find more at http://archive.org',
70 | 'Streaming shows from the Golden Age of Radio, 24 hours a day',
71 | 'Volume too loud? You can turn it down, click the menu ↗',
72 | 'Please support The Internet Archive by donating at http://archive.org/donate',
73 | 'Build your own channel with your favourite shows, click the menu ↗',
74 | 'To change the visualiser or turn it off, click the menu ↗',
75 | 'Are these messages annoying? You can turn them off via the menu! ↗'
76 | ],
77 | "normal": [
78 | 'To check the channel schedules, click the menu ↗'
79 | ],
80 | "userChannel": [
81 | 'To check the channel schedules, click the menu ↗'
82 | ],
83 | "singleShow": [
84 | 'There are many other classic shows playing at https://oldtime.radio',
85 | 'To check the channel schedule, click the menu ↗'
86 | ]
87 | },
88 | charPrintIntervalMillis: 40,
89 | tempMessageDurationMillis: 5000,
90 | tempMessageIntervalMillis: 60 * 1000
91 | },
92 | snow: {
93 | maxFlakeCount: 500,
94 | minFlakeSize: 0.5,
95 | maxFlakeSize: 3,
96 | maxXSpeed: 0.5,
97 | minYSpeed: 0.3,
98 | maxYSpeed: 2,
99 | windSpeedMax: 0.5,
100 | windSpeedDelta: 0.001,
101 | windSpeedChangeIntervalSeconds: 10,
102 | snowflakeAddIntervalSeconds: 0.1,
103 | distanceColourFade: 3
104 | }
105 | };
--------------------------------------------------------------------------------
/public/js/audioPlayer.js:
--------------------------------------------------------------------------------
1 | function buildAudioPlayer(maxVolume, eventSource) {
2 | "use strict";
3 | const audio = new Audio(),
4 | SMOOTHING = config.audio.smoothing,
5 | FFT_WINDOW_SIZE = config.audio.fftWindowSize,
6 | BUFFER_LENGTH = FFT_WINDOW_SIZE / 2;
7 |
8 | let analyser, audioInitialised, audioGain, loadingTrack, initialAudioGainValue;
9 |
10 | function initAudio() {
11 | if (!audioInitialised) {
12 | audio.crossOrigin = "anonymous";
13 | const AudioContext = window.AudioContext || window.webkitAudioContext,
14 | audioCtx = new AudioContext(),
15 | audioSrc = audioCtx.createMediaElementSource(audio);
16 | audioGain = audioCtx.createGain();
17 | analyser = audioCtx.createAnalyser();
18 | analyser.fftSize = FFT_WINDOW_SIZE;
19 | analyser.smoothingTimeConstant = SMOOTHING;
20 |
21 | // need this if volume is set before audio is initialised
22 | if (initialAudioGainValue) {
23 | audioGain.gain.value = initialAudioGainValue;
24 | }
25 |
26 | audioCtx.onstatechange = () => {
27 | // needed to allow audio to continue to play on ios when screen is locked
28 | if (audioCtx.state === 'interrupted') {
29 | audioCtx.resume();
30 | }
31 | };
32 |
33 | audioSrc.connect(audioGain);
34 | audioGain.connect(analyser);
35 | analyser.connect(audioCtx.destination);
36 | audioInitialised = true;
37 | }
38 | }
39 |
40 | /* 'Volume' describes the user-value (0-10) which is saved in browser storage and indicated by the UI
41 | volume control. 'Gain' describes the internal value used by the WebAudio API (0-1). */
42 | function convertVolumeToGain(volume) {
43 | return Math.pow(volume / maxVolume, 2);
44 | }
45 | function convertGainToVolume(gain) {
46 | return maxVolume * Math.sqrt(gain);
47 | }
48 |
49 | audio.addEventListener('canplaythrough', () => {
50 | if (loadingTrack) {
51 | eventSource.trigger(EVENT_AUDIO_TRACK_LOADED);
52 | }
53 | });
54 | audio.addEventListener('playing', () => {
55 | eventSource.trigger(EVENT_AUDIO_PLAY_STARTED);
56 | });
57 | audio.addEventListener('ended', () => eventSource.trigger(EVENT_AUDIO_TRACK_ENDED, event));
58 |
59 | function loadUrl(url) {
60 | console.log('Loading url: ' + url);
61 | audio.src = url;
62 | audio.load();
63 | }
64 |
65 | let currentUrls;
66 | audio.addEventListener('error', event => {
67 | console.error(`Error loading audio from ${audio.src}: ${event}`);
68 | if (currentUrls.length > 0) {
69 | loadUrl(currentUrls.shift());
70 | } else {
71 | console.log('No more urls to try');
72 | eventSource.trigger(EVENT_AUDIO_ERROR, event)
73 | }
74 | });
75 |
76 | return {
77 | on: eventSource.on,
78 | load(urls) {
79 | initAudio();
80 | loadingTrack = true;
81 | currentUrls = Array.isArray(urls) ? urls : [urls];
82 | loadUrl(currentUrls.shift());
83 | },
84 | play(offset=0) {
85 | loadingTrack = false;
86 | audio.currentTime = offset;
87 | audio.play();
88 | },
89 | stop() {
90 | audio.pause();
91 | },
92 | getVolume() {
93 | const gainValue = audioGain ? audioGain.gain.value : initialAudioGainValue;
94 | return convertGainToVolume(gainValue);
95 | },
96 | setVolume(volume) {
97 | const gainValue = convertVolumeToGain(volume);
98 | if (audioGain) {
99 | audioGain.gain.value = gainValue;
100 | } else {
101 | initialAudioGainValue = gainValue;
102 | }
103 | },
104 | getData() {
105 | const dataArray = new Uint8Array(BUFFER_LENGTH);
106 | if (analyser) {
107 | analyser.getByteFrequencyData(dataArray);
108 | }
109 | return dataArray;
110 | }
111 | };
112 | }
--------------------------------------------------------------------------------
/src/webServer.mts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import {config} from "./config.mjs";
3 | import {log} from "./log.mjs";
4 | import {Service} from "./service.mjs";
5 | import type {ChannelId, ShowId} from "./types.mjs";
6 | import type {Seconds} from "./clock.mjs";
7 |
8 | export class WebServer {
9 | private app: express.Application;
10 | private service: Service;
11 |
12 | constructor() {
13 | this.app = express();
14 | this.service = new Service();
15 | }
16 |
17 | private setupEndpointHandlers() {
18 | this.app.use((req, res, next) => {
19 | log.debug(`Request: ${req.method} ${req.path}`);
20 | next();
21 | });
22 |
23 | this.app.use(express.static(config.web.paths.static));
24 |
25 | this.app.use(config.web.paths.listenTo, express.static(config.web.paths.static));
26 |
27 | this.app.get(`${config.web.paths.listenTo}/:show`, (req, res) => {
28 | res.sendFile('public/index.html',{root:'./'});
29 | });
30 |
31 | this.app.get(config.web.paths.api.shows, (req, res) => {
32 | this.service.getShows().then((shows) => {
33 | res.json(shows);
34 | }).catch((err) => {
35 | log.error(`Error fetching shows: ${err}`, err);
36 | res.status(500).send('Internal Server Error');
37 | })
38 | });
39 |
40 | this.app.get(config.web.paths.api.channels, (req, res) => {
41 | this.service.getChannels().then((channelIds) => {
42 | res.json(channelIds);
43 | }).catch((err) => {
44 | log.error(`Error fetching channels: ${err}`, err);
45 | res.status(500).send('Internal Server Error');
46 | });
47 | });
48 |
49 | this.app.get(config.web.paths.api.channel + ':channel', (req, res) => {
50 | const channelId = req.params.channel as ChannelId,
51 | length = Number(req.query.length) as Seconds;
52 | this.service.getScheduleForChannel(channelId, length).then(schedule => {
53 | if (schedule) {
54 | res.status(200).json(schedule);
55 | } else {
56 | res.status(400).send('Unknown channel');
57 | }
58 | }).catch((err) => {
59 | log.error(`Error fetching schedule for channel ${channelId}: ${err}`, err);
60 | res.status(500).send('Internal Server Error');
61 | });
62 | });
63 |
64 | this.app.get(config.web.paths.api.generate + ":ids", (req, res) => {
65 | const ids = req.params.ids.split(',').map(s => Number(s)) as ShowId[];
66 | try{
67 | res.status(200).json(this.service.getCodeForShowIds(ids));
68 | } catch (err: any) {
69 | log.error(`Error generating channel code for ids ${ids}: ${err}`, err);
70 | res.status(400).send('Invalid show IDs');
71 | }
72 | });
73 |
74 | this.app.get(config.web.paths.api.playingNow, (req, res) => {
75 | const channels = (req.query.channels as string || '').split(',').filter(c => c) as ChannelId[];
76 | this.service.getPlayingNowAndNext(channels)
77 | .then(result => res.status(200).json(result))
78 | .catch(err => {
79 | log.error(`Error fetching playing now and next for channels ${channels}: ${err}`, err);
80 | res.status(500).send('Internal Server Error');
81 | });
82 | });
83 |
84 | this.app.get("/sitemap.xml", (req, res) => {
85 | this.service.getSitemapXml().then(xml => {
86 | res.set('Content-Type', 'text/xml');
87 | res.send(xml);
88 | }).catch(err => {
89 | log.error(`Error generating sitemap: ${err}`, err);
90 | res.status(500).send('Internal Server Error');
91 | });
92 | });
93 | }
94 |
95 | start() {
96 | this.setupEndpointHandlers();
97 | this.app.listen(config.web.port, () => {
98 | log.info(`Initialisation complete, listening on port ${config.web.port}...`);
99 | });
100 | }
101 | }
--------------------------------------------------------------------------------
/public/js/playingNowManager.js:
--------------------------------------------------------------------------------
1 | function buildPlayingNowManager(model, elCanvas) {
2 | const ctx = elCanvas.getContext('2d'),
3 | updatePeriodSeconds = 7,
4 | spriteCoords = [
5 | {x: 3, y: 16, w: 540, h: 93},
6 | {x: 633, y: 1, w: 549, h: 125},
7 | {x: 2, y: 264, w: 540, h: 103},
8 | {x: 635, y: 261, w: 548, h: 123},
9 | {x: 2, y: 499, w: 539, h: 147},
10 | {x: 615, y: 531, w: 583, h: 103},
11 | {x: 1, y: 788, w: 540, h: 111},
12 | {x: 630, y: 790, w: 549, h: 82},
13 | {x: 0, y: 1043, w: 540, h: 87},
14 | {x: 632, y: 1037, w: 553, h: 128}
15 | ],
16 | minPrintableRegionHeight = 150,
17 | maxPrintableRegionHeight = 200,
18 | spriteImage = new Image();
19 |
20 | spriteImage.src = 'swirl_sprites.png';
21 |
22 | let updateTimerId, canvasWidth, canvasHeight, spacing, imageHeight, initialY, lineHeight, canvasSizeOk;
23 |
24 | function fillTextMultiLine(textAndOffsets) {
25 | let nextY = initialY;
26 |
27 | textAndOffsets.forEach(textAndOffset => {
28 | const {text, imageCoords} = textAndOffset;
29 | if (text) {
30 | const lineWidth = Math.min(ctx.measureText(text).width, canvasWidth * 0.9),
31 | y = nextY + lineHeight;
32 | ctx.fillText(text, (canvasWidth - lineWidth) / 2, y, lineWidth);
33 | nextY += lineHeight + spacing;
34 |
35 | } else if (imageCoords) {
36 | const {x:sx, y:sy, w:sw, h:sh} = imageCoords,
37 | dh = imageHeight,
38 | dw = dh * sw / sh,
39 | dx = (canvasWidth - dw) / 2,
40 | dy = nextY + spacing;
41 | try {
42 | ctx.drawImage(spriteImage, sx, sy, sw, sh, dx, dy, dw, dh);
43 | } catch (e) {
44 | // ignore, the image hasn't loaded yet
45 | }
46 | nextY += (dh + 2 * spacing);
47 | }
48 | });
49 | }
50 |
51 | function describeChannel(channelId) {
52 | const channel = model.channels.find(channel => channel.id === channelId),
53 | channelName = channel.name.substring(0, 1).toUpperCase() + channel.name.substring(1).toLowerCase();
54 | if (model.isSingleShowMode() || model.isUserChannelMode()) {
55 | return channelName;
56 | } else {
57 | return `The ${channelName} Channel`;
58 | }
59 | }
60 |
61 | function prepareCanvas() {
62 | const ratio = window.devicePixelRatio || 1;
63 | elCanvas.width = (canvasWidth = elCanvas.offsetWidth) * ratio;
64 | elCanvas.height = (canvasHeight = elCanvas.offsetHeight) * ratio;
65 | ctx.scale(ratio, ratio);
66 |
67 | const printableRegionHeight = Math.min(maxPrintableRegionHeight, Math.max(minPrintableRegionHeight, canvasHeight / 2));
68 | ctx.fillStyle = '#ccc';
69 | ctx.font = `${Math.round(printableRegionHeight / 5)}px Bellerose`;
70 | elCanvas.style.animation = `pulse ${updatePeriodSeconds}s infinite`;
71 |
72 | lineHeight = printableRegionHeight / 5;
73 | spacing = printableRegionHeight / 20;
74 | imageHeight = printableRegionHeight / 5;
75 | initialY = (canvasHeight - printableRegionHeight) / 2;
76 | canvasSizeOk = initialY >= 0;
77 | }
78 |
79 | let playingNowData, currentIndex = 0, spriteIndex = 0;
80 | function updateCurrentIndex() {
81 | spriteIndex = (spriteIndex + 1) % spriteCoords.length;
82 | currentIndex = (currentIndex + 1) % playingNowData.length;
83 | }
84 |
85 | let running = false;
86 | function renderCurrentInfo() {
87 | if (!running) {
88 | return;
89 | }
90 | ctx.clearRect(0, 0, elCanvas.width, elCanvas.height);
91 | const channelId = playingNowData[currentIndex].channelId,
92 | channelDescription = describeChannel(channelId),
93 | playingNowName = playingNowData[currentIndex].list[0].showName;
94 |
95 | fillTextMultiLine([
96 | {text: 'Now Playing on'},
97 | {text: channelDescription},
98 | {imageCoords: spriteCoords[spriteIndex]},
99 | {text: playingNowName.toUpperCase()}
100 | ]);
101 |
102 | requestAnimationFrame(renderCurrentInfo);
103 | }
104 |
105 | return {
106 | start(details) {
107 | this.update(details);
108 | prepareCanvas();
109 | if (!updateTimerId && canvasSizeOk) {
110 | running = true;
111 | renderCurrentInfo();
112 | updateTimerId = setInterval(updateCurrentIndex, updatePeriodSeconds * 1000);
113 | }
114 | },
115 | update(details) {
116 | playingNowData = Object.keys(details).map(channelId => {
117 | return {
118 | ...details[channelId],
119 | channelId
120 | }
121 | });
122 | },
123 | stop() {
124 | if (updateTimerId) {
125 | running = false;
126 | clearInterval(updateTimerId);
127 | updateTimerId = 0;
128 | playingNowData = null;
129 | }
130 | }
131 | };
132 | }
--------------------------------------------------------------------------------
/src/types.mts:
--------------------------------------------------------------------------------
1 | import type {Millis, Seconds} from "./clock.mjs";
2 |
3 | export type Config = {
4 | "web": {
5 | "port": number,
6 | "paths": {
7 | "static": UrlPath,
8 | "publicUrlPrefix": UrlPath,
9 | "listenTo": UrlPath,
10 | "api": {
11 | "shows": UrlPath,
12 | "channels": UrlPath,
13 | "channel": UrlPath,
14 | "generate": UrlPath,
15 | "playingNow": UrlPath,
16 | }
17 | }
18 | },
19 | "log": {
20 | "level": string
21 | },
22 | "minRequestIntervalMillis": Millis,
23 | "shows" : ConfigShow[],
24 | "channels" : ConfigChannel[],
25 | "caches": {
26 | "baseDirectory": string,
27 | "scheduleCacheMaxItems": number,
28 | "showsCacheMaxAgeHours": number,
29 | "showsCacheRefetchIntervalHours": number
30 | },
31 | };
32 |
33 | export type ConfigShow = {
34 | name: ShowName,
35 | shortName?: ShowName,
36 | playlists: PlaylistId[],
37 | id: ShowId,
38 | isCommercial: IsCommercial,
39 | skip?: SkipText[]
40 | }
41 |
42 | export type ConfigChannel = {
43 | name: ChannelId,
44 | shows: ShowId[]
45 | }
46 |
47 | export type ArchiveOrgFileMetadata = {
48 | "name": string,
49 | "format": string,
50 | "length": string,
51 | "title"?: string,
52 | };
53 |
54 | export type ArchiveOrgMetadata = {
55 | alternate_locations: {
56 | servers: {
57 | "server": string,
58 | "dir": string,
59 | }[],
60 | "workable": {
61 | "server": string,
62 | "dir": string,
63 | }[]
64 | },
65 | "d1": string,
66 | "d2": string,
67 | "dir": string,
68 | "files": ArchiveOrgFileMetadata[],
69 | "server": string,
70 | "workable_servers": string[],
71 | "is_dark"?: boolean
72 | }
73 |
74 | export type UrlPath = string & { readonly __brand: unique symbol };
75 | export type ChannelName = string & { readonly __brand: unique symbol }; // eg 'action', 'future'
76 | export type ChannelCode = string & { readonly __brand: unique symbol }; // eg '0004', '00000g'
77 | export type ChannelId = ChannelName | ChannelCode
78 | export type ShowName = string & { readonly __brand: unique symbol };
79 | export type EpisodeName = string & { readonly __brand: unique symbol };
80 | export type DescriptiveId = string & { readonly __brand: unique symbol };
81 | export type PlaylistId = string & { readonly __brand: unique symbol };
82 | export type ShowId = number & { readonly __brand: unique symbol };
83 | export type EpisodeIndex = number & { readonly __brand: unique symbol };
84 | export type Url = string & { readonly __brand: unique symbol };
85 | export type Xml = string & { readonly __brand: unique symbol };
86 | export type IsCommercial = boolean & { readonly __brand: unique symbol };
87 | export type SkipText = string & { readonly __brand: unique symbol };
88 |
89 | export type ShowsListItem = {
90 | channels: ChannelId[], // used on client-side in Channel Builder to decide which section to display the show in
91 | id: ShowId, // comes from config.json
92 | isCommercial: IsCommercial, // comes from config.json
93 | name: ShowName, // comes from config.json
94 | shortName: ShowName, // used on client-side in various places where we need to display the name without taking up a lot of space. Comes from config.json but not defined for all shows, we fallback to the full name if nothing is specified in the config file
95 | descriptiveId: DescriptiveId, // a normalised, url-safe version of the show name, used for 'listen-to' urls, sitemap etc
96 | channelCode: ChannelCode // code for a channel with just this show, needed for the 'listen-to' urls so we can retrieve the schedule
97 | }
98 |
99 | export type EpisodeId = string;
100 |
101 | export type Episode = {
102 | index: EpisodeIndex,
103 | showId: ShowId,
104 | showName: ShowName,
105 | length: Seconds
106 | }
107 |
108 | export type EpisodeDetails = {
109 | archivalUrl: Url,
110 | commercial: IsCommercial,
111 | length: Seconds,
112 | name: EpisodeName,
113 | showName: ShowName,
114 | urls: Url[]
115 | }
116 |
117 | export type FullChannelSchedule = {
118 | list: Episode[]
119 | length: Seconds,
120 | }
121 |
122 | export type CurrentChannelSchedule = {
123 | list: Episode[]
124 | initialOffset: Seconds,
125 | }
126 |
127 | export type CurrentChannelScheduleWithDetails = {
128 | list: EpisodeDetails[]
129 | initialOffset: Seconds,
130 | }
131 |
132 | export type PlayingNowAndNext = {
133 | [key in ChannelId]: CurrentChannelSchedule
134 | }
135 |
136 | export type ShowCounts = Map;
137 | export type ScheduleResults = ShowId[]
138 | export type ScheduleResultsAfterInsertHandler = (results: ScheduleResults) => void;
139 |
140 | export type StringTransformer = (input: string) => string;
141 |
142 | export type NameParserConfig = {
143 | playlistIds: PlaylistId | PlaylistId[],
144 | regex?: RegExp,
145 | displayName?: EpisodeName,
146 | showName?: ShowName,
147 | transforms?: {
148 | title?: StringTransformer[],
149 | date?: StringTransformer[],
150 | num?: StringTransformer[],
151 | }
152 | }
153 |
154 | export type NameParserStats = {
155 | ok: number,
156 | failed: number,
157 | playlists: Map
158 | }
159 |
160 | export type Generator = {
161 | next: () => T,
162 | length: number
163 | }
--------------------------------------------------------------------------------
/public/js/visualiserData.js:
--------------------------------------------------------------------------------
1 | function buildVisualiserDataFactory(dataSource) {
2 | "use strict";
3 |
4 | const MAX_FREQ_DATA_VALUE = 255;
5 | const clock = buildClock();
6 |
7 | function sortDataIntoBuckets(data, bucketCount, p=1) {
8 | function bucketIndexes(valueCount, bucketCount, p) {
9 | /*
10 | Each time we sample the audio we get 512 separate values, each one representing the volume for a certain part of the
11 | audio frequency range. In order to visualise this data nicely we usually want to aggregate the data into 'buckets' before
12 | displaying it (for example, if we want to display a frequency bar graph we probably don't want it to have 512 bars).
13 | The simplest way to do this is by dividing the range up into equal sized sections (eg aggregating the 512 values
14 | into 16 buckets of size 32), however for the audio played by this site this tends to give lop-sided visualisations because
15 | low frequencies are much more common.
16 |
17 | This function calculates a set of bucket sizes which distribute the frequency values in a more interesting way, spreading the
18 | low frequency values over a larger number of buckets, so they are more prominent in the visualisation, without discarding any
19 | of the less common high frequency values (they just get squashed into fewer buckets, giving less 'dead space' in the visualisation).
20 |
21 | The parameter 'p' determines how much redistribution is performed. A 'p' value of 1 gives uniformly sized buckets (ie no
22 | redistribution), as 'p' is increased more and more redistribution is performed.
23 |
24 | Note that the function may return fewer than the requested number of buckets. Bucket sizes are calculated as floating-point values,
25 | but since non-integer bucket sizes make no sense, these values get rounded up and then de-duplicated which may result in some getting
26 | discarded.
27 | */
28 | "use strict";
29 | let unroundedBucketSizes;
30 |
31 | if (p===1) {
32 | unroundedBucketSizes = new Array(bucketCount).fill(valueCount / bucketCount);
33 |
34 | } else {
35 | const total = (1 - Math.pow(p, bucketCount)) / (1 - p);
36 | unroundedBucketSizes = new Array(bucketCount).fill(0).map((_,i) => valueCount * Math.pow(p, i) / total);
37 | }
38 |
39 | let total = 0, indexes = unroundedBucketSizes.map(size => {
40 | return Math.floor(total += size);
41 | });
42 |
43 | return [...new Set(indexes)]; // de-duplicate indexes
44 | }
45 |
46 | const indexes = bucketIndexes(data.length, bucketCount, p);
47 |
48 | let currentIndex = 0;
49 | return indexes.map(maxIndexForThisBucket => {
50 | const v = data.slice(currentIndex, maxIndexForThisBucket+1).reduce((total, value) => total + value, 0),
51 | w = maxIndexForThisBucket - currentIndex + 1;
52 | currentIndex = maxIndexForThisBucket+1;
53 | return v / (w * MAX_FREQ_DATA_VALUE);
54 | });
55 | }
56 |
57 | function sortArrayUsingIndexes(arr, indexes) {
58 | const filteredIndexes = indexes.filter(i => i < arr.length);
59 | return arr.map((v,i) => {
60 | return arr[filteredIndexes[i]];
61 | });
62 | }
63 |
64 | function buildAudioDataSource(bucketCount, redistribution, activityThresholdMillis, shuffleBuckets) {
65 | const shuffledIndexes = shuffle(Array.from(Array(bucketCount).keys())),
66 | activityTimestamps = new Array(bucketCount).fill(0);
67 |
68 | return {
69 | get() {
70 | const rawData = dataSource(),
71 | now = clock.nowMillis();
72 | let bucketedData;
73 |
74 | if (bucketCount) {
75 | bucketedData = sortDataIntoBuckets(rawData, bucketCount, redistribution);
76 | } else {
77 | bucketedData = rawData.map(v => v / MAX_FREQ_DATA_VALUE);
78 | }
79 |
80 | if (shuffleBuckets) {
81 | bucketedData = sortArrayUsingIndexes(bucketedData, shuffledIndexes);
82 | }
83 |
84 | if (activityThresholdMillis) {
85 | bucketedData.forEach((value, i) => {
86 | if (value) {
87 | activityTimestamps[i] = now;
88 | }
89 | });
90 | bucketedData = bucketedData.filter((v,i) => {
91 | return now - activityTimestamps[i] < activityThresholdMillis;
92 | });
93 | }
94 |
95 | return bucketedData;
96 | }
97 | };
98 | }
99 |
100 | return {
101 | audioDataSource() {
102 | let bucketCount, redistribution = 1, activityThresholdMillis, shuffleBuckets;
103 |
104 | return {
105 | withBucketCount(count) {
106 | bucketCount = count;
107 | return this;
108 | },
109 | withRedistribution(p) {
110 | redistribution = p;
111 | return this;
112 | },
113 | withFiltering(threshold) {
114 | activityThresholdMillis = threshold;
115 | return this;
116 | },
117 | withShuffling() {
118 | shuffleBuckets = true;
119 | return this;
120 | },
121 | build() {
122 | return buildAudioDataSource(bucketCount, redistribution, activityThresholdMillis, shuffleBuckets);
123 | }
124 | }
125 | }
126 | };
127 | }
--------------------------------------------------------------------------------
/src/shows.mts:
--------------------------------------------------------------------------------
1 | import type {
2 | ArchiveOrgFileMetadata,
3 | ArchiveOrgMetadata, ConfigShow,
4 | Episode,
5 | EpisodeDetails,
6 | EpisodeId,
7 | EpisodeIndex,
8 | EpisodeName,
9 | IsCommercial, PlaylistId,
10 | ShowId,
11 | ShowName,
12 | Url
13 | } from "./types.mjs";
14 | import type {Seconds} from "./clock.mjs";
15 | import {log} from "./log.mjs";
16 | import {Cache} from "./cache.mjs";
17 | import {config, configHelper} from "./config.mjs";
18 | import {archiveOrg} from "./archiveOrg.mjs";
19 | import {nameParser} from "./nameParser.mjs";
20 |
21 | export class Shows {
22 | private readonly cache: Cache
23 |
24 | constructor() {
25 | this.cache = new Cache("shows", showId => this.fetchEpisodeDetailsForShowId(showId), 99999, config.caches.showsCacheMaxAgeHours * 60 * 60 as Seconds);
26 | }
27 |
28 | private validatePlaylist(playlistData: ArchiveOrgMetadata) {
29 | if (!playlistData || !playlistData.files || playlistData.files.length === 0) {
30 | throw new Error(`No files found in playlist`);
31 | }
32 | if (playlistData.is_dark) {
33 | throw new Error(`Playlist is_dark=true, skipping`);
34 | }
35 | }
36 |
37 | private isPartOfSkipListForShow(fileName: string, playlistId: PlaylistId){
38 | return (configHelper.getShowForPlaylistId(playlistId).skip || []).some(skipPattern => fileName.includes(skipPattern));
39 | }
40 |
41 | private convertFileLengthToSeconds(fileLength: string) {
42 | let length;
43 | if (fileLength.match(/^[0-9]+:[0-9]+$/)) {
44 | const [min, sec] = fileLength.split(':')
45 | length = Number(min) * 60 + Number(sec);
46 | } else {
47 | length = Number(fileLength);
48 | }
49 | return length as Seconds;
50 | }
51 |
52 | private isPlayable(file: ArchiveOrgFileMetadata, playlistId: PlaylistId): Boolean {
53 | if (!file.name.toLowerCase().endsWith('.mp3')) {
54 | return false;
55 | }
56 | if (this.isPartOfSkipListForShow(file.name, playlistId)) {
57 | log.debug(`Skipping ${file.name} for ${playlistId}`);
58 | return false;
59 | }
60 | if (!this.convertFileLengthToSeconds(file.length)) {
61 | log.warn(`File ${file.name} in playlist ${playlistId} has invalid/missing length, skipping`);
62 | return false;
63 | }
64 |
65 | return true;
66 | }
67 |
68 | private async fetchEpisodeDetailsForPlaylistId(playlistId: PlaylistId, show: ConfigShow): Promise {
69 | try {
70 | const playlistData = await archiveOrg.get(playlistId);
71 | this.validatePlaylist(playlistData);
72 |
73 | return playlistData.files
74 | .filter(fileMetadata => this.isPlayable(fileMetadata, playlistId))
75 | .map(fileMetadata => {
76 | const encodedFileName = encodeURIComponent(fileMetadata.name),
77 | archivalUrl = `https://archive.org/download/${playlistId}/${encodedFileName}` as Url;
78 |
79 | return {
80 | archivalUrl: archivalUrl,
81 | commercial: show.isCommercial,
82 | length: this.convertFileLengthToSeconds(fileMetadata.length),
83 | name: nameParser.parse(playlistId, fileMetadata),
84 | showName: show.name,
85 | urls: [
86 | archivalUrl,
87 | `https://${playlistData.server}${playlistData.dir}/${encodedFileName}` as Url,
88 | `https://${playlistData.d1}${playlistData.dir}/${encodedFileName}` as Url,
89 | `https://${playlistData.d2}${playlistData.dir}/${encodedFileName}` as Url,
90 | ]
91 | } as EpisodeDetails;
92 | });
93 |
94 | } catch (error: any) {
95 | log.error(`Error fetching playlist '${playlistId}': ${error.message}`);
96 | throw error;
97 | // return [];
98 | }
99 | }
100 |
101 | private async fetchEpisodeDetailsForShowId(showId: ShowId): Promise {
102 | const show = configHelper.getShowFromId(showId),
103 | results = await Promise.all(show.playlists.map(playlistId => this.fetchEpisodeDetailsForPlaylistId(playlistId, show)));
104 |
105 | return results.flat();
106 | }
107 |
108 | async getEpisodesForShow(showId: ShowId): Promise {
109 | const episodes = await this.cache.get(showId);
110 | if (!episodes) {
111 | log.error(`getEpisodesForShow called with unknown showId '${showId}'`);
112 | return [];
113 |
114 | } else if (episodes.length === 0) {
115 | log.error(`getEpisodesForShow called with showId '${showId}' but show has no episodes`);
116 | return [];
117 | }
118 |
119 | return episodes.map((details, index) => ({
120 | index: index as EpisodeIndex,
121 | showId: showId,
122 | showName: details.showName,
123 | length: details.length,
124 | }));
125 | }
126 |
127 | async getEpisodeDetails(episode: Episode): Promise {
128 | const episodeDetailsForShow = await this.cache.get(episode.showId);
129 | if (!episodeDetailsForShow) {
130 | log.error(`getEpisodeDetails called with episode containing unknown showId '${episode.showId}'`);
131 | return null;
132 | } else if (episode.index < 0 || episode.index >= episodeDetailsForShow.length) {
133 | log.error(`getEpisodeDetails called with episode containing index '${episode.index}' which is out of bounds for showId '${episode.showId}'`);
134 | return null;
135 | }
136 | return episodeDetailsForShow[episode.index];
137 | }
138 |
139 | async refetchStaleItems() {
140 | return this.cache.refetchStaleItems();
141 | }
142 | }
143 |
144 | export const shows = new Shows();
--------------------------------------------------------------------------------
/src/scheduler.mts:
--------------------------------------------------------------------------------
1 | import {config, configHelper} from "./config.mjs";
2 | import type {
3 | ChannelCode,
4 | ChannelId,
5 | CurrentChannelSchedule,
6 | ShowId,
7 | FullChannelSchedule, Episode, ScheduleResultsAfterInsertHandler
8 | } from "./types.mjs";
9 | import type {Seconds} from "./clock.mjs";
10 | import {clock} from "./clock.mjs";
11 | import {buildShowIdsFromChannelCode} from "./channelCodes.mjs";
12 | import {shows} from "./shows.mjs";
13 | import {generator, schedule} from "./utils.mjs";
14 | import {Cache} from "./cache.mjs";
15 |
16 | type SchedulerStopCondition = (currentPlaylistDuration: Seconds, currentPlaylistSize: number) => boolean;
17 | type SchedulePosition = {itemIndex: number, itemOffset: Seconds};
18 |
19 | const DEFAULT_SCHEDULE_LENGTH = 60 * 60 as Seconds,
20 | MAX_SCHEDULE_LENGTH = 24 * 60 * 60 as Seconds,
21 | START_TIME = 1595199600 as Seconds; // 2020-07-20 00:00:00
22 |
23 | export class Scheduler {
24 | private cache: Cache;
25 |
26 | constructor() {
27 | this.cache = new Cache("schedules", channelId => this.calculateFullScheduleForChannel(channelId as ChannelId), config.caches.scheduleCacheMaxItems);
28 | }
29 |
30 | private playlistReachedMinDuration(minDuration: Seconds): SchedulerStopCondition {
31 | return (currentPlaylistDuration: Seconds) => {
32 | return currentPlaylistDuration >= minDuration;
33 | };
34 | }
35 |
36 | private playlistContainsRequiredNumberOfItems(numberOfItems: number): SchedulerStopCondition {
37 | return (_: Seconds, currentPlaylistSize: number) => {
38 | return currentPlaylistSize >= numberOfItems;
39 | };
40 | }
41 |
42 | private getShowIdsForChannelId(channelId: ChannelId): ShowId[] {
43 | const configChannel = config.channels.find(channel => channel.name === channelId);
44 | if (configChannel) {
45 | return configChannel.shows;
46 | } else {
47 | return buildShowIdsFromChannelCode(channelId as ChannelCode);
48 | }
49 | }
50 |
51 | private async calculateFullScheduleForChannel(channelId: ChannelId): Promise {
52 | const allShowIds = this.getShowIdsForChannelId(channelId),
53 | nonCommercialShowIds = allShowIds.filter(showId => !configHelper.getShowFromId(showId).isCommercial),
54 | commercialShowIds = allShowIds.filter(showId => configHelper.getShowFromId(showId).isCommercial),
55 | showScheduleItems = (await Promise.all(allShowIds.map(showId => shows.getEpisodesForShow(showId)))).map(generator),
56 | showIdToIndex = new Map(allShowIds.map((showId, index) => [showId, index])),
57 | nonCommercialShowCounts = new Map();
58 |
59 | nonCommercialShowIds.forEach((showId, i) => {
60 | const index = showIdToIndex.get(showId)
61 | nonCommercialShowCounts.set(showId, showScheduleItems[index].length);
62 | });
63 |
64 | let afterEach: ScheduleResultsAfterInsertHandler;
65 | if (commercialShowIds.length > 0) {
66 | const g = generator(commercialShowIds);
67 | afterEach = results => results.push(g.next());
68 | } else {
69 | afterEach = () => {};
70 | }
71 |
72 | const scheduleResults = schedule(nonCommercialShowCounts, afterEach),
73 | fullSchedule = [] as Episode[];
74 |
75 | scheduleResults.forEach((showId) => {
76 | const index = showIdToIndex.get(showId),
77 | nextItem = showScheduleItems[index].next();
78 |
79 | fullSchedule.push(nextItem);
80 | });
81 |
82 | const totalChannelDuration = fullSchedule.map(item => item.length).reduce((a, b) => a + b, 0) as Seconds;
83 |
84 | return {
85 | list: fullSchedule,
86 | length: totalChannelDuration
87 | };
88 | }
89 |
90 | private getCurrentSchedulePosition(fullSchedule: FullChannelSchedule): SchedulePosition {
91 | const scheduleDuration = fullSchedule.length as Seconds,
92 | offsetSinceStartOfPlay = (clock.now() - START_TIME) % scheduleDuration as Seconds,
93 | numberOfItemsInSchedule = fullSchedule.list.length;
94 | let i = 0, playlistItemOffset = 0 as Seconds;
95 |
96 | let initialOffset: Seconds;
97 | while (true) {
98 | const playlistItem = fullSchedule.list[i % numberOfItemsInSchedule],
99 | itemIsPlayingNow = playlistItemOffset + playlistItem.length > offsetSinceStartOfPlay;
100 |
101 | if (itemIsPlayingNow) {
102 | initialOffset = (offsetSinceStartOfPlay - playlistItemOffset) as Seconds;
103 | break;
104 | }
105 | playlistItemOffset = playlistItemOffset + playlistItem.length as Seconds;
106 | i++;
107 | }
108 |
109 | return {
110 | itemIndex: i % numberOfItemsInSchedule,
111 | itemOffset: initialOffset
112 | };
113 | }
114 |
115 | private getCurrentSchedule(fullSchedule: FullChannelSchedule, startPosition: SchedulePosition, stopCondition: SchedulerStopCondition): CurrentChannelSchedule {
116 | const clientPlaylist = [] as Episode[];
117 |
118 | let clientPlaylistDuration = -startPosition.itemOffset as Seconds,
119 | fullScheduleIndex = startPosition.itemIndex;
120 |
121 | while (!stopCondition(clientPlaylistDuration, clientPlaylist.length)) {
122 | const currentItem = fullSchedule.list[fullScheduleIndex];
123 | clientPlaylist.push(currentItem);
124 | clientPlaylistDuration = clientPlaylistDuration + currentItem.length as Seconds;
125 | fullScheduleIndex = (fullScheduleIndex + 1) % fullSchedule.list.length;
126 | }
127 |
128 | return {
129 | list: clientPlaylist,
130 | initialOffset: startPosition.itemOffset
131 | };
132 | }
133 |
134 | private getSchedule(channelId: ChannelId, stopCondition: SchedulerStopCondition): Promise {
135 | return this.cache.get(channelId).then(fullSchedule => {
136 | const currentPosition = this.getCurrentSchedulePosition(fullSchedule);
137 | return this.getCurrentSchedule(fullSchedule, currentPosition, stopCondition);
138 | });
139 | }
140 |
141 | getScheduleForChannel(channelId: ChannelId, length: Seconds) {
142 | return this.getSchedule(channelId, this.playlistReachedMinDuration(Math.min(length || DEFAULT_SCHEDULE_LENGTH, MAX_SCHEDULE_LENGTH) as Seconds));
143 | }
144 |
145 | getPlayingNowAndNext(channelId: ChannelId) {
146 | // min length of '3' guarantees we get the current show and the next show even if there are commercials playing between them
147 | return this.getSchedule(channelId, this.playlistContainsRequiredNumberOfItems(3));
148 | }
149 |
150 | clearCache() {
151 | return this.cache.clear();
152 | }
153 | }
154 |
155 | export const scheduler = new Scheduler();
--------------------------------------------------------------------------------
/src/cache.mts:
--------------------------------------------------------------------------------
1 | import {promises as fs} from "fs";
2 | import path from "path";
3 | import { LRUCache } from 'lru-cache'
4 | import {log} from "./log.mjs";
5 | import type {Seconds} from "./clock.mjs";
6 | import {config} from "./config.mjs";
7 | import {clock, type Millis} from "./clock.mjs";
8 | import {deepEquals} from "./utils.mjs";
9 |
10 | export class Cache {
11 | private cacheName: string;
12 | private diskCache: DiskCache;
13 | private lruCache: LRUCache;
14 | private fetch: (key: K) => Promise;
15 | private isFresh: (key: K) => Promise;
16 |
17 | constructor(cacheName: string, fetch: (key: K) => Promise, maxItems: number, diskCacheMaxAge?: Seconds) {
18 | this.cacheName = cacheName;
19 | this.fetch = fetch;
20 |
21 | if (diskCacheMaxAge) {
22 | this.diskCache = new DiskCache(path.join(config.caches.baseDirectory, cacheName), diskCacheMaxAge as Seconds);
23 | this.isFresh = key => this.diskCache.isFresh(key);
24 |
25 | } else {
26 | this.isFresh = () => Promise.resolve(true);
27 | }
28 |
29 | this.lruCache = new LRUCache({
30 | max: maxItems,
31 | onInsert: (value, key) => {
32 | log.debug(`Added item for [${key}] to ${this.cacheName} memory cache`);
33 | if (this.diskCache) {
34 | this.diskCache.set(key, value).then(didUpdate => {
35 | if (didUpdate) {
36 | log.debug(`Saved item for [${key}] to ${this.cacheName} disk cache`);
37 | }
38 | }).catch((error: { message: string; }) => {
39 | log.error(`Failed to save item for [${key}] to ${this.cacheName} disk cache: ${error.message}`);
40 | })
41 | }
42 | },
43 | dispose: (value, key, reason) => {
44 | log.debug(`Disposing item for [${key}] from ${this.cacheName} cache, reason=${reason}`);
45 | },
46 | });
47 | }
48 |
49 | async refetchStaleItems() {
50 | const staleKeys = [];
51 | for (const key of this.lruCache.keys()) {
52 | if (!await this.isFresh(key as K)) {
53 | staleKeys.push(key as K);
54 | }
55 | }
56 |
57 | log.debug(`Found ${staleKeys.length} stale items in ${this.cacheName} cache`);
58 | for (const key of staleKeys) {
59 | log.debug(`Refetching stale item for key [${key}] in ${this.cacheName} cache`);
60 | try {
61 | const newValue = await this.fetch(key);
62 | this.lruCache.set(key, newValue);
63 |
64 | } catch (error) {
65 | log.error(`Failed to refetch item for key [${key}] in ${this.cacheName} cache: ${(error as Error).message}`);
66 | }
67 | }
68 | }
69 |
70 | async get(key: K): Promise {
71 | if (this.lruCache.has(key)) {
72 | log.debug(`Cache hit for key [${key}] in ${this.cacheName} memory cache`);
73 | return this.lruCache.get(key);
74 |
75 | } else if (this.diskCache) {
76 | if (await this.diskCache.has(key)) {
77 | log.debug(`Cache hit for key [${key}] in ${this.cacheName} disk cache`);
78 | const value = await this.diskCache.get(key);
79 | this.lruCache.set(key, value);
80 | return value;
81 | }
82 | }
83 |
84 | log.debug(`${this.cacheName} cache miss for key [${key}], fetching new value`);
85 | const newValue = await this.fetch(key);
86 | log.debug(`Fetched new value for [${key}], saving to ${this.cacheName} cache`);
87 | this.lruCache.set(key, newValue); // gets saved to diskCache in onInsert
88 | return newValue;
89 | }
90 |
91 | async set(key: K, value: V) {
92 | log.debug(`Setting value for key [${key}] in ${this.cacheName} cache`);
93 | this.lruCache.set(key, value);
94 | }
95 |
96 | async remove(key: K) {
97 | log.debug(`Removing key [${key}] from ${this.cacheName} cache`);
98 | this.lruCache.delete(key);
99 | }
100 |
101 | async clear() {
102 | if (this.diskCache) {
103 | await this.diskCache.clear();
104 | }
105 | this.lruCache.clear();
106 | }
107 | }
108 |
109 | class DiskCache {
110 | private cacheDir: string;
111 | private maxAge: Seconds;
112 | private fileModificationTimes: Map = new Map();
113 |
114 | constructor(cacheDir: string, maxAge: Seconds) {
115 | this.cacheDir = cacheDir;
116 | this.maxAge = maxAge;
117 | }
118 |
119 | private async ensureCacheDir() {
120 | await fs.mkdir(this.cacheDir, { recursive: true });
121 | }
122 |
123 | private getFilePath(key: K) {
124 | return path.join(this.cacheDir, `${key}.json`);
125 | }
126 |
127 | async get(key: K): Promise {
128 | if (await this.has(key)) {
129 | try {
130 | const data = await fs.readFile(this.getFilePath(key), 'utf8');
131 | return JSON.parse(data);
132 | } catch (error) {
133 | const err = error as NodeJS.ErrnoException;
134 | if (err.code === 'ENOENT') {
135 | return null;
136 | }
137 | throw err;
138 | }
139 | } else {
140 | return null;
141 | }
142 | }
143 |
144 | async isFresh(key: K): Promise {
145 | if (!this.fileModificationTimes.has(key)) {
146 | const fileStats = await fs.stat(this.getFilePath(key)),
147 | modifiedTime = fileStats.mtimeMs as Millis;
148 |
149 | this.fileModificationTimes.set(key, modifiedTime);
150 | }
151 |
152 | const currentTime = clock.nowMillis() as Millis,
153 | modifiedTime = this.fileModificationTimes.get(key) as Millis,
154 | modifiedSecondsAgo = (currentTime - modifiedTime) / 1000 as Seconds;
155 |
156 | return modifiedSecondsAgo <= this.maxAge;
157 | }
158 |
159 | async has(key: K): Promise {
160 | try {
161 | await fs.access(this.getFilePath(key));
162 | return true;
163 | } catch (error) {
164 | return false;
165 | }
166 | }
167 |
168 | async set(key: K, value: V) {
169 | await this.ensureCacheDir();
170 | const currentValue = await this.get(key);
171 | if (deepEquals(value, currentValue)) {
172 | log.debug(`Disk cache value for key [${key}] is unchanged, skipping write`);
173 | return false;
174 | } else {
175 | await fs.writeFile(this.getFilePath(key), JSON.stringify(value));
176 | this.fileModificationTimes.set(key, clock.nowMillis());
177 | return true;
178 | }
179 | }
180 |
181 | async remove(key: K) {
182 | try {
183 | await fs.unlink(this.getFilePath(key));
184 | } catch (error) {
185 | const err = error as NodeJS.ErrnoException;
186 | if (err.code !== 'ENOENT') {
187 | throw err;
188 | }
189 | }
190 | }
191 |
192 | async clear() {
193 | try {
194 | const files = await fs.readdir(this.cacheDir);
195 | await Promise.all(
196 | files.filter(f => f.endsWith('.json'))
197 | .map(f => fs.unlink(path.join(this.cacheDir, f)))
198 | );
199 | } catch (error) {
200 | throw new Error(`Failed to clear disk cache: ${(error as Error).message}`);
201 | }
202 | }
203 | }
--------------------------------------------------------------------------------
/public/js/viewStationBuilder.js:
--------------------------------------------------------------------------------
1 | function buildStationBuilderView(eventSource) {
2 | "use strict";
3 |
4 | const
5 | elShowList = document.getElementById('showList'),
6 | elShowsSelected = document.getElementById('showsSelected'),
7 | elCreateChannelButton = document.getElementById('createChannel'),
8 | elStationDetails = document.getElementById('stationDetails'),
9 | elGoToStation = document.getElementById('goToStation'),
10 | elAddAnotherChannel = document.getElementById('addAnotherChannel'),
11 | elChannelCount = document.getElementById('channelCount'),
12 | elDeleteStationButton = document.getElementById('deleteStation'),
13 | elStationBuilderTitle = document.getElementById('stationBuilderTitle'),
14 | elIncludeAdsInChannelButton = document.getElementById('adsInChannel'),
15 |
16 | CSS_CLASS_SELECTED = 'selected';
17 |
18 |
19 | function buildGenreListForShows(shows) {
20 | const unclassifiedShows = [],
21 | genreMap = {};
22 |
23 | function sortShowsByName(s1, s2) {
24 | return s1.name > s2.name ? 1 : -1;
25 | }
26 | shows.forEach(show => {
27 | if (show.channels.length) {
28 | show.channels.forEach(channelName => {
29 | if (!genreMap[channelName]) {
30 | genreMap[channelName] = [];
31 | }
32 | genreMap[channelName].push(show);
33 | });
34 | } else {
35 | unclassifiedShows.push(show);
36 | }
37 | });
38 |
39 | const genreList = [];
40 | Object.keys(genreMap).sort().forEach(genreName => {
41 | genreList.push({name: genreName, shows: genreMap[genreName].sort(sortShowsByName)});
42 | });
43 | genreList.push({name: 'other', shows: unclassifiedShows.sort(sortShowsByName)});
44 |
45 | return genreList;
46 | }
47 |
48 | function buildGenreTitleElement(genreName) {
49 | const el = document.createElement('h3');
50 | el.innerHTML = `${genreName} shows`;
51 | return el;
52 | }
53 |
54 | function buildShowButtonElement(show) {
55 | const button = document.createElement('button');
56 | button.innerHTML = show.name;
57 | button.dataset.id = show.id;
58 | button.classList.add('menuButton');
59 | button.setAttribute('role', 'checkbox');
60 | button.ariaChecked = 'false';
61 | if (!show.elements) {
62 | show.elements = [];
63 | }
64 | show.elements.push(button);
65 | return button;
66 | }
67 |
68 | elIncludeAdsInChannelButton.onclick = () => {
69 | eventSource.trigger(EVENT_STATION_BUILDER_PLAY_COMMERCIALS_CLICK);
70 | };
71 |
72 | elCreateChannelButton.onclick = () => {
73 | eventSource.trigger(EVENT_STATION_BUILDER_CREATE_CHANNEL_CLICK);
74 | };
75 |
76 | elGoToStation.onclick = () => {
77 | eventSource.trigger(EVENT_STATION_BUILDER_GO_TO_CHANNEL_CLICK);
78 | };
79 |
80 | elAddAnotherChannel.onclick = () => {
81 | eventSource.trigger(EVENT_STATION_BUILDER_ADD_CHANNEL_CLICK);
82 | };
83 |
84 | elDeleteStationButton.onclick = () => {
85 | eventSource.trigger(EVENT_STATION_BUILDER_DELETE_STATION_CLICK);
86 | };
87 |
88 | function updateCreateChannelVisibility(selectedChannelsCount) {
89 | const isButtonVisible = selectedChannelsCount > 0;
90 | elCreateChannelButton.style.display = isButtonVisible ? 'inline' : 'none';
91 | }
92 |
93 | function updateCreateChannelButtonText(selectedChannelsCount) {
94 | let buttonText;
95 |
96 | if (selectedChannelsCount === 0) {
97 | buttonText = '';
98 |
99 | } else if (selectedChannelsCount === 1) {
100 | buttonText = 'Create a new channel with just this show';
101 |
102 | } else {
103 | buttonText = `Create a new channel with these ${selectedChannelsCount} shows`;
104 | }
105 |
106 | elCreateChannelButton.innerHTML = buttonText;
107 | }
108 |
109 | function updateStationDescription(selectedChannelsCount, includeCommercials) {
110 | const commercialsStatus = includeCommercials ? 'Commercials will play between programmes' : 'No commercials between programmes';
111 |
112 | let description;
113 |
114 | if (selectedChannelsCount === 0) {
115 | description = 'Pick some shows to add to a new channel';
116 |
117 | } else if (selectedChannelsCount === 1) {
118 | description = `1 show selected
${commercialsStatus}`;
119 |
120 | } else {
121 | description = `${selectedChannelsCount} shows selected
${commercialsStatus}`;
122 | }
123 |
124 | elShowsSelected.innerHTML = description;
125 | }
126 |
127 | function getSelectedChannelCount(stationBuilderModel) {
128 | return stationBuilderModel.shows.filter(show => show.selected).length;
129 | }
130 |
131 | return {
132 | populate(stationBuilderModel) {
133 | elShowList.innerHTML = '';
134 | const genreList = buildGenreListForShows(stationBuilderModel.shows);
135 |
136 | genreList.forEach(genre => {
137 | if (!genre.shows.length) {
138 | return;
139 | }
140 | elShowList.appendChild(buildGenreTitleElement(genre.name));
141 | genre.shows.forEach(show => {
142 | const elShowButton = buildShowButtonElement(show);
143 | elShowButton.onclick = () => {
144 | eventSource.trigger(EVENT_STATION_BUILDER_SHOW_CLICK, show);
145 | };
146 | elShowList.appendChild(elShowButton);
147 | });
148 | });
149 | this.updateShowSelections(stationBuilderModel);
150 | this.updateStationDetails(stationBuilderModel);
151 | },
152 |
153 | updateShowSelections(stationBuilderModel) {
154 | stationBuilderModel.shows.forEach(show => {
155 | show.elements.forEach(el => {
156 | el.classList.toggle(CSS_CLASS_SELECTED, show.selected);
157 | el.ariaChecked = show.selected;
158 | });
159 | });
160 |
161 | const selectedChannelsCount = getSelectedChannelCount(stationBuilderModel);
162 | updateCreateChannelVisibility(selectedChannelsCount);
163 | updateCreateChannelButtonText(selectedChannelsCount);
164 | updateStationDescription(selectedChannelsCount, stationBuilderModel.includeCommercials);
165 | },
166 |
167 | updateIncludeCommercials(stationBuilderModel) {
168 | elIncludeAdsInChannelButton.classList.toggle(CSS_CLASS_SELECTED, stationBuilderModel.includeCommercials);
169 | elIncludeAdsInChannelButton.ariaChecked = stationBuilderModel.includeCommercials;
170 |
171 | const selectedChannelsCount = getSelectedChannelCount(stationBuilderModel);
172 | updateStationDescription(selectedChannelsCount, stationBuilderModel.includeCommercials);
173 | },
174 |
175 | updateStationDetails(stationBuilderModel) {
176 | elStationDetails.style.display = stationBuilderModel.savedChannelCodes.length ? 'block' : 'none';
177 |
178 | const channelCount = stationBuilderModel.savedChannelCodes.length;
179 | elChannelCount.innerHTML = `Your station now has ${channelCount} channel${channelCount === 1 ? '' : 's'}`;
180 | },
181 |
182 | addAnotherChannel() {
183 | elStationBuilderTitle.scrollIntoView({behavior: 'smooth'});
184 | }
185 |
186 | };
187 | }
--------------------------------------------------------------------------------
/public/js/visualiser.js:
--------------------------------------------------------------------------------
1 | function buildVisualiser(dataFactory) {
2 | "use strict";
3 | const BACKGROUND_COLOUR = 'black';
4 |
5 | let isStarted, elCanvas, ctx, width, height, fadeOutTimeout, visualiserId;
6 |
7 | function updateCanvasSize() {
8 | const ratio = window.devicePixelRatio || 1;
9 | elCanvas.width = (width = elCanvas.offsetWidth) * ratio;
10 | elCanvas.height = (height = elCanvas.offsetHeight) * ratio;
11 | ctx.scale(ratio, ratio);
12 | }
13 |
14 | function clearCanvas() {
15 | ctx.fillStyle = BACKGROUND_COLOUR;
16 | ctx.fillRect(0, 0, width, height);
17 | }
18 |
19 | function makeRgb(v) {
20 | return `rgb(${v},${v},${v})`;
21 | }
22 |
23 | const clock = buildClock();
24 |
25 | const phonograph = (() => {
26 | const phonographConfig = config.visualiser.phonograph,
27 | minRadius = phonographConfig.minRadius,
28 | gapTotal = phonographConfig.gapTotal,
29 | snapshotStartColour = phonographConfig.snapshotStartColour,
30 | snapshotStopColour = phonographConfig.snapshotStopColour,
31 | audioData = dataFactory.audioDataSource()
32 | .withBucketCount(phonographConfig.bucketCount)
33 | .withRedistribution(phonographConfig.bucketSpread)
34 | .withFiltering(phonographConfig.silenceThresholdMillis)
35 | .withShuffling()
36 | .build();
37 |
38 | let startTs = clock.nowMillis(), snapshots = [], lastSnapshotTs = clock.nowMillis();
39 |
40 | return () => {
41 | const cx = width / 2,
42 | cy = height / 2,
43 | maxRadius = Math.min(height, width) / 2,
44 | now = clock.nowMillis();
45 |
46 | clearCanvas();
47 |
48 | const dataBuckets = audioData.get();
49 |
50 | const anglePerBucket = Math.PI * 2 / dataBuckets.length;
51 |
52 | const offset = Math.PI * 2 * (now - startTs) * phonographConfig.offsetRate,
53 | createNewSnapshot = now - lastSnapshotTs > phonographConfig.snapshotIntervalMillis,
54 | snapshotData = [],
55 | gradient = ctx.createRadialGradient(cx, cy, minRadius / 2, cx, cy, maxRadius);
56 |
57 | gradient.addColorStop(0, makeRgb(phonographConfig.gradientStartColour));
58 | gradient.addColorStop(1, makeRgb(phonographConfig.gradientStopColour));
59 |
60 | if (createNewSnapshot) {
61 | lastSnapshotTs = now;
62 | }
63 |
64 | const snapshotFadeOutDistance = maxRadius * phonographConfig.snapshotFadeOutFactor;
65 |
66 | snapshots.forEach(snapshot => {
67 | const v = Math.max(0, snapshotStopColour + snapshotStartColour * (1 - snapshot.distance / snapshotFadeOutDistance));
68 | const snapshotGradient = ctx.createRadialGradient(cx, cy, minRadius / 2, cx, cy, snapshotFadeOutDistance);
69 | snapshotGradient.addColorStop(0, makeRgb(0));
70 | snapshotGradient.addColorStop(1, makeRgb(v));
71 | ctx.beginPath();
72 | ctx.strokeStyle = 'black';
73 | ctx.fillStyle = snapshotGradient;
74 | snapshot.data.forEach(data => {
75 | ctx.moveTo(cx, cy);
76 | ctx.arc(cx, cy, data.radius + snapshot.distance, data.startAngle, data.endAngle);
77 | ctx.lineTo(cx, cy);
78 | });
79 | ctx.fill();
80 | ctx.stroke();
81 | });
82 |
83 | dataBuckets.forEach((value, i) => {
84 | const startAngle = offset + anglePerBucket * i + gapTotal / dataBuckets.length,
85 | endAngle = offset + anglePerBucket * (i + 1),
86 | radius = minRadius + value * (maxRadius - minRadius);
87 |
88 | ctx.fillStyle = gradient;
89 |
90 | ctx.beginPath();
91 | ctx.moveTo(cx, cy);
92 | ctx.arc(cx, cy, radius, startAngle, endAngle);
93 | ctx.lineTo(cx, cy);
94 | ctx.fill();
95 |
96 | if (createNewSnapshot) {
97 | snapshotData.unshift({radius, startAngle, endAngle});
98 | }
99 | });
100 |
101 | snapshots.forEach(s => s.distance += phonographConfig.snapshotSpeed);
102 | snapshots = snapshots.filter(s => s.distance < snapshotFadeOutDistance);
103 |
104 | if (createNewSnapshot) {
105 | snapshots.push({
106 | distance: 0,
107 | data: snapshotData
108 | });
109 | }
110 | };
111 | })();
112 |
113 | const oscillograph = (() => {
114 | const audioData = dataFactory.audioDataSource()
115 | .withBucketCount(config.visualiser.oscillograph.bucketCount)
116 | .withFiltering(5000)
117 | .build();
118 |
119 | return () => {
120 | const WAVE_SPEED = config.visualiser.oscillograph.waveSpeed,
121 | PADDING = width > 500 ? 50 : 25,
122 | MIN_WAVE_LIGHTNESS = config.visualiser.oscillograph.minWaveLightness,
123 | TWO_PI = Math.PI * 2,
124 | startX = PADDING,
125 | endX = width - PADDING;
126 |
127 | const dataBuckets = audioData.get();
128 |
129 | clearCanvas();
130 | dataBuckets.forEach((v, i) => {
131 | function calcY(x) {
132 | const scaledX = TWO_PI * (x - startX) / (endX - startX);
133 | return (height / 2) + Math.sin(scaledX * (i + 1)) * v * height / 2;
134 | }
135 |
136 | ctx.strokeStyle = `hsl(0,0%,${Math.floor(MIN_WAVE_LIGHTNESS + (100 - MIN_WAVE_LIGHTNESS) * (1 - v))}%)`;
137 | ctx.beginPath();
138 | let first = true;
139 |
140 | for (let x = startX; x < endX; x++) {
141 | const y = height - calcY(x - step * (i + 1));
142 | if (first) {
143 | ctx.moveTo(x, y);
144 | first = false;
145 | }
146 | ctx.lineTo(x, y);
147 | }
148 | ctx.stroke();
149 | });
150 |
151 | step = (step + WAVE_SPEED);
152 | }
153 | })();
154 |
155 | const spirograph = (() => {
156 | const spirographConfig = config.visualiser.spirograph,
157 | audioData = dataFactory.audioDataSource()
158 | .withBucketCount(spirographConfig.bucketCount)
159 | .withRedistribution(spirographConfig.bucketSpread)
160 | .withShuffling()
161 | .withFiltering(spirographConfig.silenceThresholdMillis)
162 | .build(),
163 | history = [],
164 | rotBase = spirographConfig.rotationBaseValue,
165 | alphaCycleRate = spirographConfig.alphaCycleRate,
166 | aspectRatio = spirographConfig.aspectRatio,
167 | rotationFactor = spirographConfig.rotationFactor,
168 | maxRadiusSize = spirographConfig.maxRadiusSize,
169 | minRadiusSize = spirographConfig.minRadiusSize,
170 | historySize = spirographConfig.historySize,
171 | backgroundLoop = spirographConfig.backgroundLoop,
172 | foregroundLoop = spirographConfig.foregroundLoop,
173 | backgroundAlphaCalc = buildAlphaCalc(backgroundLoop),
174 | foregroundAlphaCalc = buildAlphaCalc(foregroundLoop);
175 |
176 | let t = 0;
177 |
178 | function buildAlphaCalc(config) {
179 | const {minAlpha, maxAlpha, offset} = config;
180 | return (age, value) => {
181 | const f = (offset + t / alphaCycleRate + value * age) % 1;
182 | return minAlpha + (maxAlpha - minAlpha) * f;
183 | };
184 | }
185 |
186 | function drawHistory(cx, cy, minRadius, maxRadius, alphaCalc, angleDiff, initialDirection) {
187 | let age = 0;
188 | history.forEach(p => {
189 | age += 1 / history.length;
190 | let direction = initialDirection;
191 | p.forEach((value, i) => {
192 | const xRadius = minRadius + (maxRadius - minRadius) * value;
193 | let alpha = alphaCalc(age, value);
194 | ctx.strokeStyle = `rgba(255,255,255,${alpha}`;
195 | ctx.beginPath();
196 | ctx.ellipse(cx, cy, xRadius, xRadius * aspectRatio, (angleDiff * i + t * rotBase * (i+1) + age * value * rotationFactor) * direction, 0, Math.PI * 2);
197 | ctx.stroke();
198 | direction *= -1;
199 | });
200 | })
201 | }
202 |
203 | return () => {
204 | const dataBuckets = audioData.get();
205 |
206 | clearCanvas();
207 | const bucketCount = dataBuckets.length,
208 | angleDiff = Math.PI * 2 / bucketCount,
209 | cx = width / 2,
210 | cy = height / 2,
211 | smallestDimension = Math.min(height, width),
212 | bgMaxRadius = maxRadiusSize * smallestDimension * backgroundLoop.maxRadiusFactor,
213 | bgMinRadius = minRadiusSize * smallestDimension * backgroundLoop.minRadiusFactor,
214 | fgMaxRadius = maxRadiusSize * smallestDimension,
215 | fgMinRadius = minRadiusSize * smallestDimension;
216 |
217 | history.push(dataBuckets);
218 | if (history.length > historySize) {
219 | history.shift();
220 | }
221 |
222 | t+=1;
223 | drawHistory(cx, cy, bgMinRadius, bgMaxRadius, backgroundAlphaCalc, angleDiff, -1);
224 | drawHistory(cx, cy, fgMinRadius, fgMaxRadius, foregroundAlphaCalc, angleDiff, 1);
225 | };
226 | })();
227 |
228 | const visualiserLookup = {
229 | "None": () => {},
230 | "Spirograph": spirograph,
231 | "Oscillograph": oscillograph,
232 | "Phonograph": phonograph
233 | };
234 |
235 | let step = 0;
236 | function paint() {
237 | "use strict";
238 | if (isStarted) {
239 | visualiserLookup[visualiserId]();
240 | }
241 |
242 | requestAnimationFrame(paint);
243 | }
244 |
245 | return {
246 | init(_elCanvas) {
247 | elCanvas = _elCanvas;
248 | ctx = elCanvas.getContext('2d', { alpha: false });
249 | updateCanvasSize();
250 | clearCanvas();
251 | paint();
252 | },
253 | getVisualiserIds() {
254 | return Object.keys(visualiserLookup);
255 | },
256 | setVisualiserId(id) {
257 | clearCanvas();
258 | visualiserId = id;
259 | },
260 | start() {
261 | if (fadeOutTimeout) {
262 | clearTimeout(fadeOutTimeout);
263 | fadeOutTimeout = null;
264 | }
265 | isStarted = true;
266 | },
267 | stop(delayMillis = 0) {
268 | if (!fadeOutTimeout) {
269 | fadeOutTimeout = setTimeout(() => {
270 | isStarted = false;
271 | clearCanvas();
272 | }, delayMillis);
273 | }
274 | },
275 | onResize() {
276 | return updateCanvasSize;
277 | }
278 | };
279 | }
280 |
281 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Old Time Radio
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
41 |
42 |
164 |
165 |
166 |
167 |
168 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
--------------------------------------------------------------------------------
/public/js/view.js:
--------------------------------------------------------------------------------
1 | function buildView(eventSource, model) {
2 | "use strict";
3 | const FEW_CHANNELS_LIMIT = 4,
4 | channelButtons = {},
5 | visualiserButtons = {},
6 |
7 | CLASS_LOADING = 'channelLoading',
8 | CLASS_PLAYING = 'channelPlaying',
9 | CLASS_ERROR = 'channelError',
10 | CLASS_SELECTED = 'selected',
11 |
12 | elMenuOpenIcon = document.getElementById('menuOpenIcon'),
13 | elMenuCloseIcon = document.getElementById('menuCloseIcon'),
14 | elMenuButton = document.getElementById('menuButton'),
15 | elMenuBox = document.getElementById('menu'),
16 | elVolumeUp = document.getElementById('volumeUp'),
17 | elVolumeDown = document.getElementById('volumeDown'),
18 | elPrefInfoMessages = document.getElementById('prefInfoMessages'),
19 | elPrefNowPlayingMessages = document.getElementById('prefNowPlayingMessages'),
20 | elMessage = document.getElementById('message'),
21 | elDownloadLink = document.getElementById('downloadLink'),
22 | elButtonContainer = document.getElementById('buttons'),
23 | elVolumeLeds = Array.from(Array(10).keys()).map(i => document.getElementById(`vol${i+1}`)),
24 | elVisualiserCanvas = document.getElementById('visualiserCanvas'),
25 | elPlayingNowCanvas = document.getElementById('playingNowCanvas'),
26 | elVisualiserButtons = document.getElementById('visualiserList'),
27 | elTitle = document.getElementsByTagName('title')[0],
28 |
29 | sleepTimerView = buildSleepTimerView(eventSource),
30 | scheduleView = buildScheduleView(eventSource),
31 | stationBuilderView = buildStationBuilderView(eventSource);
32 |
33 | function forEachChannelButton(fn) {
34 | Object.keys(channelButtons).forEach(channelId => {
35 | fn(channelId, channelButtons[channelId]);
36 | });
37 | }
38 |
39 | function buildChannelButton(channel) {
40 | const channelId = channel.id,
41 | channelName = channel.name,
42 | elButtonBox = document.createElement('div');
43 | elButtonBox.classList.add('buttonBox');
44 |
45 | const elButtonIndicator = document.createElement('div'),
46 | elButton = document.createElement('button'),
47 | elButtonLabel = document.createElement('label');
48 |
49 | elButtonIndicator.classList.add('buttonIndicator');
50 |
51 | elButton.classList.add('raisedButton');
52 | elButton.setAttribute('role', 'radio');
53 | elButton.id = (channelName + '_channel').toLowerCase().replaceAll(' ', '_');
54 | elButtonLabel.classList.add('buttonLabel');
55 | elButtonLabel.innerText = channelName;
56 | elButtonLabel.setAttribute('for', elButton.id);
57 |
58 | elButton.onclick = () => {
59 | eventSource.trigger(EVENT_CHANNEL_BUTTON_CLICK, channelId);
60 | };
61 | elButtonBox.appendChild(elButtonIndicator);
62 | elButtonBox.appendChild(elButton);
63 | elButtonBox.appendChild(elButtonLabel);
64 |
65 | elButtonContainer.appendChild(elButtonBox);
66 | channelButtons[channelId] = elButtonBox;
67 | }
68 |
69 | function buildVisualiserButton(id) {
70 | const button = document.createElement('button');
71 | button.innerHTML = id;
72 | button.classList.add('menuButton');
73 | button.setAttribute('data-umami-event', `visualiser-${id.toLowerCase()}`);
74 | button.setAttribute('role', 'radio');
75 | button.onclick = () => {
76 | eventSource.trigger(EVENT_VISUALISER_BUTTON_CLICK, id);
77 | };
78 | elVisualiserButtons.appendChild(button);
79 | visualiserButtons[id] = button;
80 | }
81 |
82 | const messagePrinter = (() => {
83 | const PRINT_INTERVAL = config.messages.charPrintIntervalMillis;
84 | let interval;
85 |
86 | function stopPrinting() {
87 | clearInterval(interval);
88 | interval = 0;
89 | }
90 |
91 | return {
92 | print(msg) {
93 | if (interval) {
94 | stopPrinting();
95 | }
96 | const msgLen = msg.length;
97 | let i = 1;
98 | interval = setInterval(() => {
99 | elMessage.innerText = (msg.substr(0,i) + (i < msgLen ? '█' : '')).padEnd(msgLen, ' ');
100 | const messageComplete = i === msgLen;
101 | if (messageComplete) {
102 | stopPrinting();
103 | eventSource.trigger(EVENT_MESSAGE_PRINTING_COMPLETE);
104 | } else {
105 | i += 1;
106 | }
107 |
108 | }, PRINT_INTERVAL);
109 | }
110 | };
111 | })();
112 |
113 | const playingNowPrinter = buildPlayingNowManager(model, elPlayingNowCanvas);
114 |
115 | function triggerWake() {
116 | eventSource.trigger(EVENT_WAKE_UP);
117 | }
118 |
119 | let menuOpen = false;
120 | elMenuButton.onclick = () => {
121 | eventSource.trigger(menuOpen ? EVENT_MENU_CLOSE_CLICK : EVENT_MENU_OPEN_CLICK);
122 | };
123 |
124 | elMenuBox.ontransitionend = () => {
125 | if (!menuOpen) {
126 | elMenuBox.style.visibility = 'hidden';
127 | }
128 | };
129 | elMenuBox.style.visibility = 'hidden';
130 |
131 | elVolumeUp.onclick = () => {
132 | eventSource.trigger(EVENT_VOLUME_UP_CLICK);
133 | };
134 | elVolumeDown.onclick = () => {
135 | eventSource.trigger(EVENT_VOLUME_DOWN_CLICK);
136 | };
137 |
138 | elPrefInfoMessages.onclick = () => {
139 | eventSource.trigger(EVENT_PREF_INFO_MESSAGES_CLICK);
140 | }
141 |
142 | elPrefNowPlayingMessages.onclick = () => {
143 | eventSource.trigger(EVENT_PREF_NOW_PLAYING_CLICK);
144 | }
145 |
146 | sleepTimerView.init();
147 |
148 | const snowMachine = buildSnowMachine(elVisualiserCanvas);
149 |
150 | return {
151 | on: eventSource.on,
152 |
153 | setChannels(channels) {
154 | channels.forEach(channel => {
155 | buildChannelButton(channel);
156 | scheduleView.addChannel(channel);
157 | });
158 |
159 | if (channels.length <= FEW_CHANNELS_LIMIT) {
160 | elButtonContainer.classList.add('fewerChannels');
161 | }
162 |
163 | elButtonContainer.scroll({left: 1000});
164 | elButtonContainer.scroll({behavior:'smooth', left: 0});
165 | },
166 |
167 | setNoChannelSelected() {
168 | forEachChannelButton((id, el) => {
169 | el.classList.remove(CLASS_LOADING, CLASS_PLAYING, CLASS_ERROR);
170 | el.ariaChecked = false;
171 | });
172 | },
173 |
174 | setChannelLoading(channelId) {
175 | forEachChannelButton((id, el) => {
176 | el.classList.remove(CLASS_PLAYING, CLASS_ERROR);
177 | el.classList.toggle(CLASS_LOADING, id === channelId);
178 | el.ariaChecked = false;
179 | });
180 | },
181 |
182 | setChannelLoaded(channelId) {
183 | forEachChannelButton((id, el) => {
184 | el.classList.remove(CLASS_LOADING, CLASS_ERROR);
185 | el.classList.toggle(CLASS_PLAYING, id === channelId);
186 | el.ariaChecked = id === channelId;
187 | });
188 | },
189 |
190 | openMenu() {
191 | menuOpen = true;
192 | elMenuBox.style.visibility = 'visible';
193 | elMenuBox.classList.add('visible');
194 | elMenuOpenIcon.style.display = 'none';
195 | elMenuCloseIcon.style.display = 'inline';
196 | elMenuButton.ariaExpanded = "true";
197 | },
198 | closeMenu() {
199 | menuOpen = false;
200 | elMenuBox.classList.remove('visible');
201 | elMenuOpenIcon.style.display = 'inline';
202 | elMenuCloseIcon.style.display = 'none';
203 | elMenuButton.ariaExpanded = "false";
204 | },
205 |
206 | updateVolume(volume, minVolume, maxVolume) {
207 | elVolumeLeds.forEach((el, i) => el.classList.toggle('on', (i + 1) <= volume));
208 | elVolumeDown.classList.toggle('disabled', volume === minVolume);
209 | elVolumeUp.classList.toggle('disabled', volume === maxVolume);
210 | },
211 |
212 | showMessage(message) {
213 | messagePrinter.print(message);
214 | },
215 |
216 | startSleepTimer() {
217 | sleepTimerView.setRunState(true);
218 | },
219 | updateSleepTimer(seconds) {
220 | sleepTimerView.render(seconds);
221 | },
222 | clearSleepTimer() {
223 | sleepTimerView.setRunState(false);
224 | },
225 | sleep() {
226 | this.closeMenu();
227 | sleepTimerView.setRunState(false);
228 | document.body.classList.add('sleeping');
229 | document.body.addEventListener('mousemove', triggerWake);
230 | document.body.addEventListener('touchstart', triggerWake);
231 | document.body.addEventListener('keydown', triggerWake);
232 | },
233 | wakeUp() {
234 | document.body.classList.remove('sleeping');
235 | document.body.removeEventListener('mousemove', triggerWake);
236 | document.body.removeEventListener('touchstart', triggerWake);
237 | document.body.removeEventListener('keydown', triggerWake);
238 | },
239 |
240 | updateScheduleChannelSelection(channelId) {
241 | scheduleView.setSelectedChannel(channelId);
242 | },
243 | displaySchedule(schedule) {
244 | scheduleView.displaySchedule(schedule);
245 | },
246 | hideSchedule() {
247 | scheduleView.hideSchedule();
248 | },
249 |
250 | populateStationBuilderShows(stationBuilderModel) {
251 | stationBuilderView.populate(stationBuilderModel);
252 | },
253 | updateStationBuilderShowSelections(stationBuilderModel) {
254 | stationBuilderView.updateShowSelections(stationBuilderModel);
255 | },
256 | updateStationBuilderIncludeCommercials(stationBuilderModel) {
257 | stationBuilderView.updateIncludeCommercials(stationBuilderModel);
258 | },
259 | updateStationBuilderStationDetails(stationBuilderModel) {
260 | stationBuilderView.updateStationDetails(stationBuilderModel);
261 | },
262 | addAnotherStationBuilderChannel() {
263 | stationBuilderView.addAnotherChannel();
264 | },
265 | setVisualiser(audioVisualiser) {
266 | audioVisualiser.init(elVisualiserCanvas);
267 | },
268 | showPlayingNowDetails(playingNowDetails) {
269 | elPlayingNowCanvas.style.display = 'block';
270 | playingNowPrinter.start(playingNowDetails);
271 | },
272 | updatePlayingNowDetails(playingNowDetails) {
273 | playingNowPrinter.update(playingNowDetails);
274 | },
275 | hidePlayingNowDetails() {
276 | elPlayingNowCanvas.style.display = 'none';
277 | playingNowPrinter.stop();
278 | },
279 | showDownloadLink(mp3Url) {
280 | elDownloadLink.innerHTML = `Download this show as an MP3 file`;
281 | },
282 | hideDownloadLink() {
283 | elDownloadLink.innerHTML = '';
284 | },
285 | showError(errorMsg) {
286 | forEachChannelButton((id, el) => {
287 | el.classList.remove(CLASS_PLAYING, CLASS_ERROR);
288 | el.classList.toggle(CLASS_LOADING, true);
289 | });
290 | },
291 | setVisualiserIds(visualiserIds) {
292 | visualiserIds.forEach(visualiserId => {
293 | buildVisualiserButton(visualiserId);
294 | });
295 | },
296 | updateVisualiserId(selectedVisualiserId) {
297 | Object.keys(visualiserButtons).forEach(visualiserId => {
298 | const el = visualiserButtons[visualiserId];
299 | el.classList.toggle(CLASS_SELECTED, selectedVisualiserId === visualiserId);
300 | el.ariaChecked = selectedVisualiserId === visualiserId;
301 | el.setAttribute('aria-controls', 'canvas');
302 | });
303 | },
304 | updatePrefInfoMessages(showInfoMessages) {
305 | elPrefInfoMessages.classList.toggle(CLASS_SELECTED, showInfoMessages);
306 | elPrefInfoMessages.innerHTML = showInfoMessages ? 'On' : 'Off';
307 | },
308 | updatePrefNowPlayingMessages(showNowPlayingMessages) {
309 | elPrefNowPlayingMessages.classList.toggle(CLASS_SELECTED, showNowPlayingMessages);
310 | elPrefNowPlayingMessages.innerHTML = showNowPlayingMessages ? 'On' : 'Off';
311 | },
312 | addShowTitleToPage(title) {
313 | elTitle.innerHTML += (' - ' + title);
314 | },
315 | startSnowMachine(intensity) {
316 | snowMachine.start(intensity);
317 | },
318 | stopSnowMachine() {
319 | snowMachine.stop();
320 | }
321 | };
322 | }
--------------------------------------------------------------------------------
/public/css/main.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --led-off: #808080;
3 | --led-on: #ffffff;
4 | --led-glow: rgba(0, 0, 0, 0.2) 0px 0px 7px 1px, inset #404040 0 0 10px, #ffffff 0 0 12px;
5 | --led-on-edge: #999999;
6 | --font-color: white;
7 | --font-color-dim: #888;
8 | --panel-background: linear-gradient(135deg, hsla(0, 0%, 30%, 1) 0%, hsla(0, 0%, 20%, 1) 100%);
9 | --button-outer-shadow: #888;
10 | --msg-background: black;
11 | --divider-colour: #333;
12 | --body-background: #777;
13 | --menu-font-color: black;
14 | --hr-bottom-color: #ddd;
15 | }
16 | @font-face {
17 | font-family: 'Bellerose';
18 | src: url('Bellerose.ttf') format('truetype');
19 | }
20 | @font-face {
21 | font-family: 'Courier Prime';
22 | src: url('CourierPrime-Regular.ttf') format('truetype');
23 | }
24 | *:focus-visible {
25 | outline-color: white;
26 | }
27 | body {
28 | background-color: var(--body-background);
29 | min-width: 270px;
30 | transition: background-color 3s;
31 | }
32 | html, body, div {
33 | margin: 0;
34 | padding: 0;
35 | height: 100%;
36 | box-sizing: border-box;
37 | }
38 | #container {
39 | display: flex;
40 | flex-grow: 1;
41 | flex-shrink: 1;
42 | flex-direction: column;
43 | max-width: 800px;
44 | margin: 0 auto;
45 | box-shadow: 0 0 10px 5px #333;
46 | }
47 | @keyframes textGlow {
48 | from {
49 | color: var(--font-color-dim);
50 | }
51 | 50% {
52 | color: var(--font-color);
53 | }
54 | to {
55 | color: var(--font-color-dim);
56 | }
57 | }
58 |
59 | #message, #downloadLink {
60 | font-family: 'Courier Prime', monospace;
61 | text-transform: uppercase;
62 | font-size: 14px;
63 | display: flex;
64 | align-items: center;
65 | justify-content: center;
66 | color: var(--font-color);
67 | background-color: var(--msg-background);
68 | animation: textGlow 5s infinite;
69 | }
70 | #message {
71 | flex: 0 0 50px;
72 | white-space: pre;
73 | overflow-x: hidden;
74 | }
75 | #downloadLink {
76 | flex: 0 0 50px;
77 | }
78 | #downloadLink a {
79 | color: inherit;
80 | text-decoration: none;
81 | }
82 | noscript {
83 | font-family: 'Bellerose', monospace;
84 | text-transform: uppercase;
85 | font-size: 18px;
86 | text-align: center;
87 | color: var(--font-color);
88 | background-color: var(--msg-background);
89 | animation: textGlow 5s infinite;
90 | padding: 0 10px;
91 | }
92 | noscript a, noscript a:visited {
93 | color: var(--font-color);
94 | }
95 | #title {
96 | display: flex;
97 | flex-direction: row;
98 | flex: 0 0 120px;
99 | align-items: center;
100 | justify-content: center;
101 | font-family: Bellerose;
102 | font-size: 48px;
103 | margin-top: -24px;
104 | color: var(--font-color);
105 | background: var(--panel-background);
106 | text-shadow: 0px 0px 15px var(--font-color);
107 | border: 1px solid var(--divider-colour);
108 | overflow: hidden;
109 | position: relative;
110 | }
111 | #title h1 {
112 | flex: 1 1 50%;
113 | display: flex;
114 | font-size: 48px;
115 | justify-content: center;
116 | align-items: center;
117 | text-align: center;
118 | margin: 0;
119 | height: 100%;
120 | z-index: 10;
121 | }
122 | #title h1 a, #title h1 a:visited {
123 | text-decoration: none;
124 | color: inherit;
125 | }
126 | #title .spacer {
127 | flex: 0 0 40px;
128 | position: relative;
129 | display: flex;
130 | flex-direction: column;
131 | justify-content: center;
132 | }
133 | #title svg {
134 | fill: white;
135 | cursor: pointer;
136 | width: 32px;
137 | height: 32px;
138 | }
139 | #menuButton {
140 | background-color: transparent;
141 | border: none;
142 | margin-top: 20px;
143 | }
144 | #menuCloseIcon {
145 | display: none;
146 | }
147 |
148 | @keyframes growCircle{
149 | from {
150 | transform: translate3d(-50%, -50%, 0) scale(0) rotateZ(360deg);
151 | }
152 | to {
153 | transform: translate3d(-50%, -50%, 0) scale(1) rotateZ(360deg);
154 | }
155 | }
156 | @keyframes growLargeCircle{
157 | from {
158 | transform: translate3d(-50%, -50%, 0) scale(0) rotateZ(360deg);
159 | }
160 | to {
161 | transform: translate3d(-50%, -50%, 0) scale(2) rotateZ(360deg);
162 | }
163 | }
164 | #titleInner1 {
165 | animation-delay: 0s;
166 | }
167 | #titleInner2 {
168 | animation-delay: 0.3s;
169 | }
170 | #titleInner3 {
171 | animation-delay: 0.6s;
172 | }
173 | #titleInner4 {
174 | animation-delay: 0.9s;
175 | }
176 | #title div.radioWave {
177 | position: absolute;
178 | margin-top: 10px;
179 | border-radius: 50%;
180 | border: 1px solid #aaa;
181 | width: 100%;
182 | height: 0;
183 | padding-bottom: 100%;
184 | top:50%;
185 | left:50%;
186 | will-change: transform;
187 | transform: translate3d(-50%, -50%, 0) scale(0);
188 | animation-name: growCircle;
189 | animation-timing-function: ease-in;
190 | animation-duration: 3s;
191 | animation-iteration-count: infinite;
192 | }
193 | #visualiser {
194 | display: flex;
195 | flex: 1 1 200px;
196 | min-height: 0;
197 | position: relative;
198 | }
199 | #menu {
200 | font-family: Bellerose;
201 | font-size: 24px;
202 | position: absolute;
203 | top: 0;
204 | left: 0;
205 | width: calc(100% - 20px);
206 | background-image: linear-gradient(to bottom right, #ffffffee, #aaaaaaee);
207 | text-align: center;
208 | height: 0;
209 | transition: height 1s ease;
210 | border-radius: 5px;
211 | overflow-y: scroll;
212 | margin: 0 10px;
213 | z-index: 10;
214 | }
215 | #menu.visible {
216 | height: 100%;
217 | box-shadow: 0px 0px 10px 5px rgba(255,255,255,0.75);
218 | }
219 | #menu h2 {
220 | font-size: 24px;
221 | line-height: 32px;
222 | margin: 16px 8px 8px 8px;
223 | text-decoration: underline;
224 | }
225 | #menu p {
226 | font-size: 18px;
227 | line-height: 24px;
228 | margin: 8px 16px;
229 | }
230 | #menu a, #menu a:visited {
231 | color: var(--menu-font-color)
232 | }
233 | #menu #showList {
234 | padding: 0;
235 | line-height: 36px;
236 | margin-bottom: 0;
237 | margin-top: 8px;
238 | }
239 | #menu .menuButton {
240 | font-family: Bellerose;
241 | background-color: var(--font-color-dim);
242 | border: 2px solid var(--divider-colour);
243 | color: var(--menu-font-color);
244 | border-radius: 10px;
245 | display: inline-block;
246 | white-space: nowrap;
247 | font-size: 16px;
248 | line-height: 16px;
249 | padding: 4px 12px 12px 12px;
250 | cursor: pointer;
251 | margin: 0px 4px;
252 | user-select: none;
253 | outline: none;
254 | }
255 | #menu .menuButton:disabled {
256 | border-color: var(--font-color-dim);
257 | background-color: var(--font-color-dim) ! important;
258 | cursor: default;
259 | }
260 | #menu .menuButton:focus-visible {
261 | outline: 2px solid white;
262 | }
263 | #menu hr {
264 | margin-top: 32px;
265 | border-bottom-color: var(--hr-bottom-color);
266 | }
267 | #menu h3 {
268 | margin: 8px 0 4px 0;
269 | text-transform: capitalize;
270 | font-size: 20px;
271 | }
272 | #menu .menuButton.selected {
273 | background-color: var(--font-color);
274 | }
275 | #menu .hidden {
276 | display: none;
277 | }
278 | #volumeControlContainer {
279 | height: 50px;
280 | margin: 0 auto;
281 | display: inline-flex;
282 | flex-direction: row;
283 | }
284 | #volumeUp, #volumeDown {
285 | margin: 0 10px;
286 | color: black;
287 | display: flex;
288 | align-items: center;
289 | justify-content: center;
290 | }
291 | .raisedButton svg {
292 | height: 25px;
293 | width: 25px;
294 | fill: var(--panel-background);
295 | pointer-events: none;
296 | }
297 | .raisedButton.disabled svg {
298 | fill: var(--font-color-dim);
299 | }
300 | #volumeLevel {
301 | display: flex;
302 | width: 180px;
303 | justify-content: space-around;
304 | align-items: center;
305 | }
306 | .buttonIndicator.miniLed {
307 | height: 10px;
308 | width: 10px;
309 | }
310 | .buttonIndicator.miniLed.on {
311 | background-color: var(--led-on);
312 | box-shadow: rgba(255, 255, 255, 0.8) 0px 0px 7px 1px;
313 | }
314 | #menu p#showsSelected {
315 | margin-bottom: 20px;
316 | }
317 | #channelSettings {
318 | height: auto;
319 | }
320 | #channelSettings h3 {
321 | text-decoration: underline;
322 | margin-top: 36px;
323 | }
324 | #channelSettings ul {
325 | display: flex;
326 | padding: 0;
327 | justify-content: center;
328 | margin: 10px 0 0 0;
329 | }
330 | #channelSettings li {
331 | display: flex;
332 | flex-direction: row;
333 | }
334 | #scheduleList, #channelScheduleLinks, #sleepTimerButtons, #visualiserList {
335 | padding: 0 40px;
336 | }
337 | #scheduleList {
338 | margin: 10px 0 0 0;
339 | }
340 | #channelScheduleLinks, #visualiserList {
341 | margin: 10px 0;
342 | display: flex;
343 | flex-wrap: wrap;
344 | justify-content: center;
345 | }
346 | #channelScheduleLinks .menuButton, #visualiserList .menuButton, #sleepTimerButtons .menuButton {
347 | text-transform: capitalize;
348 | margin: 5px 3px;
349 | cursor: pointer;
350 | }
351 | #scheduleList li {
352 | display: flex;
353 | flex-direction: row;
354 | text-align: left;
355 | font-size: 14px;
356 | font-family: 'Courier Prime', monospace;
357 | }
358 | #scheduleList li .scheduleItemTime {
359 | flex: 0 0 60px;
360 | }
361 | #scheduleList li .scheduleItemName{
362 | flex-grow: 1;
363 | }
364 | #sleepTimerRunningDisplay {
365 | height: auto;
366 | display: flex;
367 | flex-direction: column;
368 | align-items: center;
369 | justify-content: center;
370 | margin-bottom: -10px;
371 | }
372 | #sleepTimerButtons {
373 | display: flex;
374 | justify-content: center;
375 | margin: 20px 0 0 0;
376 | flex-wrap: wrap;
377 | }
378 | #sleepTimerTime {
379 | font-family: 'Courier Prime', monospace;
380 | }
381 | #stationDetails {
382 | height: auto;
383 | }
384 | #buttons {
385 | display: flex;
386 | flex: 0 0 430px;
387 | flex-direction: row;
388 | overflow-x: scroll;
389 | scroll-snap-type: x mandatory;
390 | border-width: 0;
391 | border-left-width: 1px;
392 | border-right-width: 1px;
393 | border-style: solid;
394 | border-color: var(--divider-colour);
395 | scrollbar-width: none;
396 | }
397 |
398 | #buttons:hover, #buttons:focus, #buttons:active {
399 | scrollbar-width: auto;
400 | }
401 |
402 | #buttonsContainer {
403 | display: flex;
404 | flex: 0 0 200px;
405 | flex-direction: row;
406 | justify-content: center;
407 | background: var(--panel-background);
408 | }
409 | .buttonContainerPadding {
410 | flex: 1 0 10px;
411 | z-index: 1;
412 | }
413 | #buttonContainerPaddingLeft {
414 | box-shadow: 5px 0px 5px 0px rgba(0,0,0,0.5);
415 | }
416 | #buttonContainerPaddingRight {
417 | box-shadow: -5px 0px 5px 0px rgba(0,0,0,0.5);
418 | }
419 | .buttonBox {
420 | display: flex;
421 | flex: 0 0 100px;
422 | flex-direction: column;
423 | align-items: center;
424 | justify-content: space-evenly;
425 | border-color: var(--divider-colour);
426 | border-width: 0 1px;
427 | border-style: solid;
428 | margin: 0 -1px 0 0;
429 | scroll-snap-align: center;
430 | }
431 | #buttons.fewerChannels {
432 | justify-content: center;
433 | }
434 | @keyframes blinkGreen {
435 | from {
436 | background-color: var(--led-off);
437 | }
438 | 50% {
439 | background-color: var(--led-on);
440 | box-shadow: var(--led-glow);
441 | }
442 | to {
443 | background-color: var(--led-off);
444 | }
445 | }
446 | .buttonIndicator {
447 | display: flex;
448 | width: 48px;
449 | height: 48px;
450 | border-radius: 50%;
451 | background-color: var(--led-off);
452 | box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 7px 1px, inset #333333 0 0 10px;
453 | border: 1px solid var(--divider-colour);
454 | }
455 | .buttonBox.channelLoading .buttonIndicator {
456 | animation: blinkGreen 2s infinite;
457 | }
458 | .buttonBox.channelPlaying .buttonIndicator {
459 | background-color: var(--led-on);
460 | box-shadow: var(--led-glow);
461 | }
462 | .raisedButton, .raisedButton.disabled:active {
463 | display: flex;
464 | flex: 0 0 50px;
465 | width: 50px;
466 | height: 50px;
467 | border-radius: 50%;
468 | cursor: pointer;
469 | background-image: linear-gradient(to bottom right, #fff, #444);
470 | color: #a7a7a7;
471 | box-shadow: 1px 1px 6px var(--button-outer-shadow), inset 2px 2px 3px #fff;
472 | border: 1px solid var(--divider-colour);
473 | }
474 | .raisedButton:active {
475 | box-shadow: 1px 1px 6px var(--button-outer-shadow);
476 | background-image: linear-gradient(to bottom right, #d8d8d8, #2a2a2a);
477 | border-width: 2px;
478 | }
479 | .buttonLabel {
480 | display: flex;
481 | flex: 0 0 20px;
482 | align-items: center;
483 | color: var(--font-color);
484 | text-shadow: 0px 0px 10px var(--font-color);
485 | padding: 5px;
486 | font-family: Bellerose;
487 | text-transform: capitalize;
488 | font-size: 24px;
489 | line-height: 24px;
490 | margin-top: -10px;
491 | border: none ! important;
492 | white-space: nowrap;
493 | }
494 | canvas {
495 | width: 100%;
496 | height: 100%;
497 | background-color: var(--msg-background);
498 | }
499 | #visualiserCanvas, #playingNowCanvas {
500 | position: absolute;
501 | top: 0;
502 | left: 0;
503 | width: 100%;
504 | height: 100%;
505 | }
506 | #playingNowCanvas {
507 | display: none;
508 | background-color: rgba(0, 0, 0, 0);
509 | filter: blur(25px) brightness(0%);
510 | z-index: 5;
511 | }
512 | #preferenceToggles {
513 | display: inline-grid;;
514 | grid-template-columns: 70% 30%;
515 | margin: 0 auto;
516 | justify-items: center;
517 | align-items: center;
518 | grid-gap: 0 10px;
519 | }
520 | #preferenceToggles label {
521 | font-size: 18px;
522 | margin-bottom: 10px;
523 | white-space: nowrap;
524 | }
525 | @keyframes pulse {
526 | 0% {
527 | filter: blur(25px) brightness(30%);
528 | }
529 | 30% {
530 | filter: blur(0) brightness(100%);
531 | }
532 | 70% {
533 | filter: blur(0) brightness(100%);
534 | }
535 | 100% {
536 | filter: blur(25px) brightness(30%);
537 | }
538 | }
539 | /* Styles for after sleep timer is triggered */
540 | body.sleeping {
541 | background-color: #111;
542 | }
543 | body.sleeping #title div.radioWave {
544 | animation-name: growLargeCircle;
545 | animation-duration: 10s;
546 | border: 1px solid #444;
547 | }
548 | body.sleeping #titleInner2 {
549 | animation-delay: 1s;
550 | }
551 | body.sleeping #titleInner3 {
552 | animation-delay: 2s;
553 | }
554 | body.sleeping #titleInner4 {
555 | animation-delay: 3s;
556 | }
557 | body.sleeping #buttonsContainer {
558 | display: none;
559 | }
560 | body.sleeping #visualiser {
561 | display: none;
562 | }
563 | body.sleeping #title {
564 | background: var(--msg-background);
565 | flex-grow: 1;
566 | transition: flex-grow 3s;
567 | }
568 | body.sleeping #downloadLink {
569 | display: none;
570 | }
571 | body.sleeping #title .spacer {
572 | display: none;
573 | }
574 |
575 | @media screen and (max-width: 500px) {
576 | #title h1 {
577 | padding-top: 8px;
578 | font-size: 36px;
579 | }
580 | #message {
581 | white-space: normal;
582 | text-align: center;
583 | flex-basis: 50px;
584 | }
585 | #menu p {
586 | font-size: 18px;
587 | line-height: 20px;
588 | margin: 8px 16px;
589 | }
590 | #buttonsContainer {
591 | flex: 0 0 180px;
592 | }
593 | .buttonBox {
594 | flex: 0 0 80px;
595 | }
596 | #buttons {
597 | flex: 0 0 90%;
598 | }
599 | #scheduleList li .scheduleItemTime {
600 | flex: 0 0 50px;
601 | }
602 | }
603 | @media screen and (min-width: 350px) and (max-width: 500px) {
604 | #title {
605 | flex: 0 0 100px;
606 | }
607 | .buttonLabel {
608 | font-size: 20px;
609 | }
610 | #title svg {
611 | margin-top: 10px;
612 | }
613 | noscript {
614 | line-height: 30px;
615 | }
616 | #scheduleList, #channelScheduleLinks {
617 | padding: 0;
618 | margin: 10px;
619 | }
620 | #buttons {
621 | flex: 0 0 90%;
622 | }
623 | }
624 | @media screen and (max-width: 349px) {
625 | #title {
626 | flex: 0 0 80px;
627 | margin-top: -16px;
628 | }
629 | #title .spacer {
630 | flex: 0 0 40px;
631 | }
632 | #title h1 {
633 | padding-top: 0;
634 | white-space: nowrap;
635 | font-size: 32px;
636 | }
637 | #buttonsContainer {
638 | flex: 0 0 150px;
639 | }
640 | .buttonLabel {
641 | font-size: 16px;
642 | }
643 | .raisedButton {
644 | flex: 0 0 40px;
645 | }
646 | .raisedButton, .buttonIndicator {
647 | height: 40px;
648 | width: 40px;
649 | }
650 | #downloadLink {
651 | flex: 0 0 30px;
652 | }
653 | noscript {
654 | line-height: 20px;
655 | }
656 | noscript h2 {
657 | line-height: 30px;
658 | margin-top: 0;
659 | }
660 | #scheduleList, #channelScheduleLinks {
661 | padding: 0;
662 | margin: 10px;
663 | }
664 | #buttons {
665 | flex: 0 0 90%;
666 | }
667 | .buttonBox {
668 | flex: 0 0 70px;
669 | }
670 | }
--------------------------------------------------------------------------------
/public/js/main.js:
--------------------------------------------------------------------------------
1 | window.onload = () => {
2 | "use strict";
3 |
4 | const model = buildModel(),
5 | stateMachine = buildStateMachine(),
6 | view = buildView(eventSource('view'), model),
7 | service = buildService(),
8 | audioPlayer = buildAudioPlayer(model.maxVolume, eventSource('audio')),
9 | visualiserDataFactory = buildVisualiserDataFactory(audioPlayer.getData),
10 | visualiser = buildVisualiser(visualiserDataFactory),
11 | messageManager = buildMessageManager(model, eventSource('msg')),
12 | sleepTimer = buildSleepTimer(eventSource('sleep'));
13 |
14 | function eventSource(name) {
15 | return buildEventSource(name, stateMachine);
16 | }
17 |
18 | function onError(error) {
19 | console.error(error);
20 | stateMachine.error();
21 | model.selectedChannelId = model.playlist = model.track = null;
22 | audioPlayer.stop();
23 | visualiser.stop();
24 | tempMessageTimer.stop();
25 | scheduleRefresher.stop();
26 | playingNowTimer.stop();
27 | view.setNoChannelSelected();
28 | view.hideDownloadLink();
29 | view.showError(error);
30 | startSnowMachineIfAppropriate();
31 | messageManager.showError();
32 | }
33 |
34 | function startSnowMachineIfAppropriate() {
35 | function getSnowIntensityForToday(day, month) {
36 | if (month === 11) {
37 | if (day <= 25) {
38 | return 1 - (25 - day) / 25;
39 | } else {
40 | return (32 - day) / 7;
41 | }
42 | }
43 | }
44 | const today = new Date(),
45 | snowIntensity = getSnowIntensityForToday(today.getDate(), today.getMonth());
46 | if (snowIntensity) {
47 | view.startSnowMachine(snowIntensity);
48 | }
49 | }
50 |
51 | function loadNextFromPlaylist() {
52 | function playNextFromPlaylist() {
53 | const nextItem = model.playlist.shift();
54 | model.track = nextItem;
55 | audioPlayer.load(nextItem.urls);
56 | stateMachine.loadingTrack();
57 | }
58 |
59 | if (model.playlist && model.playlist.length) {
60 | playNextFromPlaylist();
61 |
62 | } else {
63 | playingNowTimer.stop();
64 | stateMachine.tuningIn();
65 | service.getPlaylistForChannel(model.selectedChannelId).then(playlist => {
66 | model.playlist = playlist.list;
67 | model.nextTrackOffset = playlist.initialOffset;
68 | playNextFromPlaylist();
69 |
70 | }).catch(onError);
71 | }
72 | }
73 |
74 | // Message Manager event handler
75 | messageManager.on(EVENT_NEW_MESSAGE).then(event => {
76 | const {text} = event.data;
77 | view.showMessage(text);
78 | });
79 |
80 | // Audio Player event handlers
81 | audioPlayer.on(EVENT_AUDIO_TRACK_LOADED).ifState(STATE_LOADING_TRACK).then(() => {
82 | view.stopSnowMachine();
83 | visualiser.start();
84 | audioPlayer.play(model.nextTrackOffset);
85 | model.nextTrackOffset = 0;
86 | view.showDownloadLink(model.track.archivalUrl);
87 | });
88 |
89 | audioPlayer.on(EVENT_AUDIO_PLAY_STARTED).ifState(STATE_LOADING_TRACK).then(() => {
90 | stateMachine.playing();
91 | view.setChannelLoaded(model.selectedChannelId);
92 | messageManager.showNowPlaying(model.track.name);
93 | });
94 |
95 | audioPlayer.on(EVENT_AUDIO_TRACK_ENDED).ifState(STATE_PLAYING).then(() => {
96 | loadNextFromPlaylist();
97 | });
98 |
99 | audioPlayer.on(EVENT_AUDIO_ERROR).ifState(STATE_LOADING_TRACK).then(event => {
100 | onError(event.data);
101 | });
102 |
103 | // Sleep Timer event handlers
104 | sleepTimer.on(EVENT_SLEEP_TIMER_TICK).then(event => {
105 | const secondsLeft = event.data;
106 | view.updateSleepTimer(secondsLeft);
107 | });
108 |
109 | sleepTimer.on(EVENT_SLEEP_TIMER_DONE).ifState(STATE_PLAYING).then(() => {
110 | view.sleep();
111 | tempMessageTimer.stop();
112 | messageManager.showSleeping();
113 | scheduleRefresher.stop();
114 |
115 | const interval = setInterval(() => {
116 | if (stateMachine.state === STATE_GOING_TO_SLEEP) {
117 | const newVolume = audioPlayer.getVolume() - config.sleepTimer.fadeOutDelta;
118 | if (newVolume > 0) {
119 | audioPlayer.setVolume(newVolume);
120 | } else {
121 | model.selectedChannelId = model.track = model.playlist = null;
122 | audioPlayer.stop();
123 | visualiser.stop();
124 | view.hideDownloadLink();
125 | view.setNoChannelSelected();
126 | stateMachine.sleeping();
127 | }
128 | } else {
129 | clearInterval(interval);
130 | }
131 | }, config.sleepTimer.fadeOutIntervalMillis);
132 |
133 | stateMachine.goingToSleep();
134 | });
135 |
136 | sleepTimer.on(EVENT_SLEEP_TIMER_DONE).ifState(STATE_IDLE, STATE_TUNING_IN, STATE_LOADING_TRACK, STATE_ERROR).then(() => {
137 | view.sleep();
138 | view.stopSnowMachine();
139 | model.selectedChannelId = model.track = model.playlist = null;
140 | view.setNoChannelSelected();
141 | view.hideDownloadLink();
142 | tempMessageTimer.stop();
143 | messageManager.showSleeping();
144 | visualiser.stop();
145 | playingNowTimer.stop();
146 | scheduleRefresher.stop();
147 |
148 | stateMachine.sleeping();
149 | });
150 |
151 | // View event handlers
152 | view.on(EVENT_CHANNEL_BUTTON_CLICK).then(event => {
153 | const channelId = event.data;
154 |
155 | if (channelId === model.selectedChannelId) {
156 | model.selectedChannelId = model.playlist = model.track = model.nextTrackOffset = null;
157 |
158 | audioPlayer.stop();
159 |
160 | view.setNoChannelSelected();
161 | view.hideDownloadLink();
162 | visualiser.stop(config.visualiser.fadeOutIntervalMillis);
163 | startSnowMachineIfAppropriate();
164 | playingNowTimer.startIfApplicable();
165 |
166 | messageManager.showSelectChannel();
167 |
168 | stateMachine.idle();
169 |
170 | } else {
171 | model.selectedChannelId = channelId;
172 | model.playlist = model.track = model.nextTrackOffset = null;
173 |
174 | view.setChannelLoading(model.selectedChannelId);
175 | const channel = model.channels.find(channel => channel.id === model.selectedChannelId);
176 | messageManager.showTuningInToChannel(channel.name);
177 | view.hideDownloadLink();
178 |
179 | /* Hack to make iOS web audio work, starting around iOS 15.5 browsers refuse to play media loaded from
180 | within an AJAX event handler (such as loadNextFromPlaylist -> service.getPlaylistForChannel) but if we load
181 | something (anything) outside the callback without playing it, then subsequent load/plays from within the
182 | callback all work - Godammit Safari */
183 | audioPlayer.load('silence.mp3');
184 |
185 | loadNextFromPlaylist();
186 | }
187 | });
188 |
189 | view.on(EVENT_MENU_OPEN_CLICK).then(() => {
190 | view.openMenu();
191 | if (model.selectedChannelId) {
192 | model.selectedScheduleChannelId = model.selectedChannelId;
193 | view.updateScheduleChannelSelection(model.selectedScheduleChannelId);
194 | scheduleRefresher.start();
195 | }
196 | });
197 |
198 | view.on(EVENT_MENU_CLOSE_CLICK).then(() => {
199 | view.closeMenu();
200 | model.selectedScheduleChannelId = null;
201 | view.updateScheduleChannelSelection();
202 | view.hideSchedule();
203 | scheduleRefresher.stop();
204 | });
205 |
206 | function applyModelVolume() {
207 | view.updateVolume(model.volume, model.minVolume, model.maxVolume);
208 | audioPlayer.setVolume(model.volume, model.maxVolume);
209 | model.save();
210 | }
211 |
212 | function applyModelPrefs() {
213 | view.updatePrefInfoMessages(model.showInfoMessages);
214 | view.updatePrefNowPlayingMessages(model.showNowPlayingMessages);
215 | model.save();
216 | }
217 |
218 | function applyModelVisualiser() {
219 | view.updateVisualiserId(model.visualiserId);
220 | visualiser.setVisualiserId(model.visualiserId);
221 | model.save();
222 | }
223 |
224 | view.on(EVENT_VOLUME_UP_CLICK).then(() => {
225 | model.volume++;
226 | applyModelVolume();
227 | });
228 |
229 | view.on(EVENT_VOLUME_DOWN_CLICK).then(() => {
230 | model.volume--;
231 | applyModelVolume();
232 | });
233 |
234 | view.on(EVENT_PREF_INFO_MESSAGES_CLICK).then(() => {
235 | if (model.showInfoMessages = !model.showInfoMessages) {
236 | tempMessageTimer.startIfApplicable();
237 | } else {
238 | tempMessageTimer.stop();
239 | }
240 |
241 | applyModelPrefs();
242 | });
243 |
244 | view.on(EVENT_PREF_NOW_PLAYING_CLICK).then(() => {
245 | if (model.showNowPlayingMessages = !model.showNowPlayingMessages) {
246 | playingNowTimer.startIfApplicable();
247 | } else {
248 | playingNowTimer.stop();
249 | }
250 |
251 | applyModelPrefs();
252 | });
253 |
254 | const tempMessageTimer = (() => {
255 | let interval;
256 |
257 | return {
258 | startIfApplicable(){
259 | if (!interval && model.showInfoMessages) {
260 | interval = setInterval(() => {
261 | messageManager.showTempMessage();
262 | }, config.messages.tempMessageIntervalMillis);
263 | }
264 | },
265 | stop() {
266 | if (interval) {
267 | clearInterval(interval);
268 | interval = null;
269 | }
270 | }
271 | }
272 | })();
273 |
274 | const playingNowTimer = (() => {
275 | let timerId, channelIds;
276 |
277 | function updatePlayingNowDetails() {
278 | service.getPlayingNow(channelIds).then(playingNow => {
279 | if (playingNow) {
280 | view.updatePlayingNowDetails(playingNow);
281 | }
282 | });
283 | }
284 |
285 | return {
286 | startIfApplicable(){
287 | if (!timerId && model.showNowPlayingMessages) {
288 | channelIds = shuffle(model.channels.map(c => c.id));
289 | if (channelIds.length > 1) {
290 | // Only show 'playing now' details if there are multiple channels
291 | service.getPlayingNow(channelIds).then(playingNow => {
292 | view.showPlayingNowDetails(playingNow);
293 | timerId = setInterval(updatePlayingNowDetails, config.playingNow.apiCallIntervalMillis);
294 | });
295 | }
296 | }
297 | },
298 | stop() {
299 | if (timerId) {
300 | clearInterval(timerId);
301 | timerId = null;
302 | view.hidePlayingNowDetails();
303 | }
304 | }
305 | }
306 | })();
307 |
308 | view.on(EVENT_SLEEP_TIMER_CLICK).then(event => {
309 | const minutes = event.data;
310 | if (minutes === sleepTimer.getMinutesRequested()) {
311 | // the already-selected button has been clicked a second time, so turn it off
312 | sleepTimer.stop();
313 | view.clearSleepTimer();
314 | } else {
315 | sleepTimer.start(minutes);
316 | view.startSleepTimer();
317 | }
318 | });
319 |
320 | view.on(EVENT_WAKE_UP).ifState(STATE_GOING_TO_SLEEP).then(() => {
321 | view.wakeUp();
322 | audioPlayer.setVolume(model.volume);
323 | tempMessageTimer.startIfApplicable();
324 |
325 | messageManager.showNowPlaying(model.track.name);
326 | stateMachine.playing();
327 | });
328 |
329 | view.on(EVENT_WAKE_UP).ifState(STATE_SLEEPING).then(() => {
330 | view.wakeUp();
331 | startSnowMachineIfAppropriate();
332 | audioPlayer.setVolume(model.volume);
333 | tempMessageTimer.startIfApplicable();
334 | playingNowTimer.startIfApplicable();
335 |
336 | messageManager.showSelectChannel();
337 | stateMachine.idle();
338 | });
339 |
340 | const scheduleRefresher = (() => {
341 | let interval;
342 |
343 | const refresher = {
344 | start() {
345 | this.refreshNow();
346 | if (!interval) {
347 | interval = setInterval(() => {
348 | refresher.refreshNow();
349 | }, config.schedule.refreshIntervalMillis);
350 | }
351 | },
352 | refreshNow() {
353 | const channelId = model.selectedScheduleChannelId;
354 | service.getPlaylistForChannel(channelId, config.schedule.lengthInSeconds).then(schedule => {
355 | if (channelId === model.selectedScheduleChannelId) {
356 | view.displaySchedule(schedule);
357 | }
358 | });
359 | },
360 | stop() {
361 | if (interval) {
362 | clearInterval(interval);
363 | interval = null;
364 | }
365 | }
366 | };
367 | return refresher;
368 | })();
369 |
370 | view.on(EVENT_SCHEDULE_BUTTON_CLICK).then(event => {
371 | const channelId = event.data,
372 | selectedChannelWasClicked = model.selectedScheduleChannelId === channelId;
373 |
374 | // clicking the channel that was already selected should de-select it, leaving no channel selected
375 | const selectedChannel = selectedChannelWasClicked ? null : channelId;
376 | model.selectedScheduleChannelId = selectedChannel;
377 | view.updateScheduleChannelSelection(selectedChannel);
378 |
379 | if (selectedChannel) {
380 | scheduleRefresher.start();
381 |
382 | } else {
383 | view.hideSchedule();
384 | scheduleRefresher.stop();
385 | }
386 | });
387 |
388 | view.on(EVENT_STATION_BUILDER_SHOW_CLICK).then(event => {
389 | const clickedShow = event.data;
390 | model.stationBuilder.shows.filter(show => show.id === clickedShow.id).forEach(show => show.selected = !show.selected);
391 | view.updateStationBuilderShowSelections(model.stationBuilder);
392 | });
393 |
394 | view.on(EVENT_STATION_BUILDER_PLAY_COMMERCIALS_CLICK).then(() => {
395 | const includeCommercials = !model.stationBuilder.includeCommercials;
396 | model.stationBuilder.includeCommercials = includeCommercials;
397 | view.updateStationBuilderIncludeCommercials(model.stationBuilder);
398 | });
399 |
400 | view.on(EVENT_STATION_BUILDER_CREATE_CHANNEL_CLICK).then(() => {
401 | const selectedShowIds = model.stationBuilder.shows.filter(show => show.selected).map(show => show.id);
402 | if (model.stationBuilder.includeCommercials) {
403 | selectedShowIds.push(...model.stationBuilder.commercialShowIds);
404 | }
405 |
406 | model.stationBuilder.shows.forEach(show => show.selected = false);
407 | view.updateStationBuilderShowSelections(model.stationBuilder);
408 |
409 | service.getChannelCodeForShows(selectedShowIds).then(channelCode => {
410 | model.stationBuilder.savedChannelCodes.push(channelCode);
411 | view.updateStationBuilderStationDetails(model.stationBuilder);
412 | });
413 | });
414 |
415 | view.on(EVENT_STATION_BUILDER_GO_TO_CHANNEL_CLICK).then(() => {
416 | window.location.href = `/?channels=${model.stationBuilder.savedChannelCodes.join(',')}`;
417 | });
418 |
419 | view.on(EVENT_STATION_BUILDER_ADD_CHANNEL_CLICK).then(() => {
420 | view.addAnotherStationBuilderChannel();
421 | });
422 |
423 | view.on(EVENT_STATION_BUILDER_DELETE_STATION_CLICK).then(() => {
424 | model.stationBuilder.savedChannelCodes.length = 0;
425 | view.updateStationBuilderStationDetails(model.stationBuilder);
426 | });
427 |
428 | view.on(EVENT_VISUALISER_BUTTON_CLICK).then(event => {
429 | const visualiserId = event.data;
430 | model.visualiserId = visualiserId;
431 | applyModelVisualiser();
432 | });
433 |
434 | function getChannels() {
435 | messageManager.showLoadingChannels();
436 |
437 | const urlChannelCodes = new URLSearchParams(window.location.search).get('channels');
438 | if (urlChannelCodes) {
439 | model.setModelUserChannels();
440 |
441 | const channels = urlChannelCodes.split(',').map((code, i) => {
442 | return {
443 | id: code,
444 | name: `Channel ${i + 1}`,
445 | userChannel: true
446 | };
447 | });
448 | return Promise.resolve(channels);
449 |
450 | } else {
451 | const pathParts = window.location.pathname.split('/');
452 | if (pathParts[1] === 'listen-to') {
453 | model.setModeSingleShow();
454 |
455 | const descriptiveShowId = pathParts[2].toLowerCase(),
456 | showObject = model.shows.find(show => show.descriptiveId === descriptiveShowId);
457 |
458 | view.addShowTitleToPage(showObject.name);
459 |
460 | return Promise.resolve([{
461 | id: showObject.channelCode,
462 | name: showObject.shortName,
463 | userChannel: true
464 | }]);
465 |
466 | } else {
467 | model.setModeNormal();
468 |
469 | return service.getChannels().then(channelIds => {
470 | return channelIds.map(channelId => {
471 | return {
472 | id: channelId,
473 | name: channelId,
474 | userChannel: false
475 | };
476 | });
477 | });
478 | }
479 | }
480 | }
481 |
482 | // State Machine event handlers
483 | function startUp(){
484 | stateMachine.initialising();
485 | model.channels = model.selectedChannelId = model.playlist = model.track = null;
486 |
487 | applyModelVolume();
488 |
489 | view.setVisualiser(visualiser);
490 | view.setVisualiserIds(visualiser.getVisualiserIds());
491 | applyModelVisualiser();
492 | applyModelPrefs();
493 |
494 | startSnowMachineIfAppropriate();
495 |
496 | service.getShowList()
497 | .then(shows => {
498 | model.shows = [...shows.map(show => {
499 | return {
500 | index: show.index,
501 | name: show.name,
502 | shortName: show.shortName,
503 | descriptiveId: show.descriptiveId,
504 | channelCode: show.channelCode
505 | };
506 | })];
507 |
508 | model.stationBuilder.shows = [...shows.filter(show => !show.isCommercial).map(show => {
509 | return {
510 | id: show.id,
511 | name: show.name,
512 | selected: false,
513 | channels: show.channels
514 | };
515 | })];
516 |
517 | view.populateStationBuilderShows(model.stationBuilder);
518 |
519 | return getChannels();
520 | })
521 | .then(channels => {
522 | model.channels = channels;
523 | view.setChannels(model.channels);
524 | tempMessageTimer.startIfApplicable();
525 | messageManager.init();
526 | messageManager.showSelectChannel();
527 | playingNowTimer.startIfApplicable();
528 |
529 | stateMachine.idle();
530 | })
531 | .catch(onError);
532 | }
533 | startUp();
534 |
535 | };
536 |
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "web": {
3 | "port": 3000,
4 | "paths": {
5 | "static": "public",
6 | "publicUrlPrefix": "https://oldtime.radio",
7 | "listenTo": "/listen-to",
8 | "api": {
9 | "shows": "/api/shows",
10 | "channels": "/api/channels",
11 | "channel": "/api/channel/",
12 | "generate": "/api/channel/generate/",
13 | "playingNow": "/api/playing-now"
14 | }
15 | }
16 | },
17 | "minRequestIntervalMillis": 1500,
18 | "caches": {
19 | "baseDirectory": "cache",
20 | "scheduleCacheMaxItems": 100,
21 | "showsCacheMaxAgeHours": 4,
22 | "showsCacheRefetchIntervalHours": 1
23 | },
24 | "scheduler": {
25 | "radical": 1.8
26 | },
27 | "log": {
28 | "file": "/var/log/oldtimeradio/access.log",
29 | "level": "info"
30 | },
31 | "shows" : [
32 | {
33 | "name": "Commercials",
34 | "playlists": ["Old_Radio_Adverts_01"],
35 | "id": 0,
36 | "isCommercial": true
37 | },
38 | {
39 | "name": "X Minus One",
40 | "playlists": ["OTRR_X_Minus_One_Singles"],
41 | "id": 1
42 | },
43 | {
44 | "name": "Dimension X",
45 | "playlists": ["OTRR_Dimension_X_Singles"],
46 | "id": 2
47 | },
48 | {
49 | "name": "Space Patrol",
50 | "playlists": ["OTRR_Space_Patrol_Singles"],
51 | "id": 3
52 | },
53 | {
54 | "name": "Tom Corbett, Space Cadet",
55 | "playlists": ["SpaceCadet", "SpaceCadet2"],
56 | "id": 4
57 | },
58 | {
59 | "name": "Flash Gordon",
60 | "playlists": ["Flash_Gordon1935"],
61 | "id": 5
62 | },
63 | {
64 | "name": "Buck Rogers",
65 | "playlists": ["otr_buckrogers"],
66 | "id": 6
67 | },
68 | {
69 | "name": "Exploring Tomorrow",
70 | "playlists": ["Exploring_Tomorrow"],
71 | "id": 7
72 | },
73 | {
74 | "name": "2000 Plus",
75 | "playlists": ["otr_2000Plus"],
76 | "id": 8
77 | },
78 | {
79 | "name": "Planet Man",
80 | "playlists": ["OTRR_Planet_Man_Ver2_Singles"],
81 | "id": 9
82 | },
83 | {
84 | "name": "The Adventures of Superman",
85 | "shortName": "Superman",
86 | "playlists": ["TheAdventuresOfSuperman_201805"],
87 | "id": 10
88 | },
89 | {
90 | "name": "Tarzan",
91 | "playlists": ["OTRR_Tarzan_Singles_TotA", "OTRR_Tarzan_Singles_TatDoA", "OTRR_Tarzan_Singles_TatFoT", "OTRR_Tarzan_Singles_TLotJ"],
92 | "id": 11
93 | },
94 | {
95 | "name": "The Green Hornet",
96 | "playlists": ["TheGreenHornet"],
97 | "id": 12
98 | },
99 | {
100 | "name": "Dragnet",
101 | "playlists": ["Dragnet_OTR"],
102 | "id": 13
103 | },
104 | {
105 | "name": "Speed Gibson Of The International Secret Police",
106 | "shortName": "Speed Gibson",
107 | "playlists": ["Speed_Gibson_Of_The_International_Secret_Police"],
108 | "id": 14
109 | },
110 | {
111 | "name": "The Blue Beetle",
112 | "playlists": ["OTRR_Blue_Beetle_Singles"],
113 | "id": 15
114 | },
115 | {
116 | "name": "The Falcon",
117 | "playlists": ["OTRR_Falcon_Singles"],
118 | "id": 16
119 | },
120 | {
121 | "name": "Dark Fantasy",
122 | "playlists": ["OTRR_Dark_Fantasy_Singles"],
123 | "id": 17
124 | },
125 | {
126 | "name": "I Love a Mystery",
127 | "playlists": ["otr_iloveamystery"],
128 | "id": 18,
129 | "skip": ["interview"]
130 | },
131 | {
132 | "name": "Suspense",
133 | "playlists": ["SUSPENSE", "SUSPENSE2", "SUSPENSE3"],
134 | "id": 19
135 | },
136 | {
137 | "name": "Molle Mystery Theatre",
138 | "playlists": ["OTRR_Molle_Mystery_Theatre_Singles"],
139 | "id": 20
140 | },
141 | {
142 | "name": "Mystery House",
143 | "playlists": ["OTRR_Mystery_House_Singles"],
144 | "id": 21
145 | },
146 | {
147 | "name": "The Adventures of Philip Marlowe",
148 | "shortName": "Philip Marlowe",
149 | "playlists": ["OTRR_Philip_Marlowe_Singles"],
150 | "id": 22
151 | },
152 | {
153 | "name": "Boston Blackie",
154 | "playlists": ["OTRR_Boston_Blackie_Singles"],
155 | "id": 23
156 | },
157 | {
158 | "name": "The New Adventures of Sherlock Holmes",
159 | "shortName": "Sherlock Holmes",
160 | "playlists": ["sherlockholmes_otr"],
161 | "id": 24
162 | },
163 | {
164 | "name": "Gunsmoke",
165 | "playlists": ["GUNSMOKE01", "GUNSMOKE02", "GUNSMOKE03", "GUNSMOKE04"],
166 | "id": 25
167 | },
168 | {
169 | "name": "The Lone Ranger",
170 | "playlists": ["The_Lone_Ranger_Page_01", "The_Lone_Ranger_Page_02"],
171 | "id": 26
172 | },
173 | {
174 | "name": "Have Gun Will Travel",
175 | "playlists": ["HaveGunWillTravel_OldTimeRadio"],
176 | "id": 27,
177 | "skip": ["64kb"]
178 | },
179 | {
180 | "name": "Tales of the Texas Rangers",
181 | "shortName": "Texas Rangers",
182 | "playlists": ["TalesOfTheTexasRangers"],
183 | "id": 28
184 | },
185 | {
186 | "name": "The Six Shooter",
187 | "playlists": ["OTRR_The_Six_Shooter_Singles"],
188 | "id": 29,
189 | "skip": ["OTRR Introduction", "The Six Shooter Intro", "Jimmy Stewart Biography", "Hollywood Star Playhouse"]
190 | },
191 | {
192 | "name": "Fort Laramie",
193 | "playlists": ["OTRR_Fort_Laramie_Singles"],
194 | "id": 30
195 | },
196 | {
197 | "name": "Our Miss Brooks",
198 | "playlists": ["OurMissBrooks"],
199 | "id": 31
200 | },
201 | {
202 | "name": "The Great Gildersleeve",
203 | "playlists": ["Great_Gildersleeve"],
204 | "id": 32
205 | },
206 | {
207 | "name": "The Harold Peary Show",
208 | "shortName": "Harold Peary",
209 | "playlists": ["OTRR_Harold_Peary_Show_Singles"],
210 | "id": 33
211 | },
212 | {
213 | "name": "The Jack Benny Show",
214 | "shortName": "Jack Benny",
215 | "playlists": ["OTRR_Jack_Benny_Singles_1932-1934", "OTRR_Jack_Benny_Singles_1934-1935", "OTRR_Jack_Benny_Singles_1935-1936", "OTRR_Jack_Benny_Singles_1936-1937", "OTRR_Jack_Benny_Singles_1937-1938", "OTRR_Jack_Benny_Singles_1938-1939", "OTRR_Jack_Benny_Singles_1939-1940", "OTRR_Jack_Benny_Singles_1940-1941", "OTRR_Jack_Benny_Singles_1941-1942", "OTRR_Jack_Benny_Singles_1942-1943", "OTRR_Jack_Benny_Singles_1943-1944", "OTRR_Jack_Benny_Singles_1944-1945", "OTRR_Jack_Benny_Singles_1945-1946", "OTRR_Jack_Benny_Singles_1946-1947", "OTRR_Jack_Benny_Singles_1947-1948", "OTRR_Jack_Benny_Singles_1948-1949", "OTRR_Jack_Benny_Singles_1949-1950", "OTRR_Jack_Benny_Singles_1950-1951", "OTRR_Jack_Benny_Singles_1951-1952", "OTRR_Jack_Benny_Singles_1952-1953", "OTRR_Jack_Benny_Singles_1953-1954", "OTRR_Jack_Benny_Singles_1954-1955"],
216 | "id": 34
217 | },
218 | {
219 | "name": "The Phil Harris - Alice Faye Show",
220 | "shortName": "Phil Harris - Alice Faye",
221 | "playlists": ["OTRR_Harris_Faye_Singles"],
222 | "id": 35,
223 | "skip": ["Audio Bio", "audio synopsis"]
224 | },
225 | {
226 | "name": "Fibber McGee and Molly",
227 | "shortName": "Fibber McGee",
228 | "playlists": ["fibber-mc-gee-and-molly"],
229 | "id": 36,
230 | "skip": ["Partial", "Rebroadcast", "AFRTS", "Interviews"]
231 | },
232 | {
233 | "name": "Pat Novak, For Hire",
234 | "shortName": "Pat Novak",
235 | "playlists": ["PatNovakForHire"],
236 | "id": 37
237 | },
238 | {
239 | "name": "Escape",
240 | "playlists": ["otr_escape"],
241 | "id": 38
242 | },
243 | {
244 | "name": "The Lives of Harry Lime",
245 | "shortName": "Harry Lime",
246 | "playlists": ["TheLivesOfHarryLime"],
247 | "id": 39
248 | },
249 | {
250 | "name": "The Saint",
251 | "playlists": ["TheSaintVincentPriceOTR"],
252 | "id": 40
253 | },
254 | {
255 | "name": "Broadway Is My Beat",
256 | "playlists": ["OTRR_Broadway_Is_My_Beat_Singles"],
257 | "id": 41
258 | },
259 | {
260 | "name": "Bold Venture",
261 | "playlists": ["BoldVenture57Episodes"],
262 | "id": 42
263 | },
264 | {
265 | "name": "Inner Sanctum Mysteries",
266 | "playlists": ["OTRR_Inner_Sanctum_Mysteries_Singles"],
267 | "id": 43
268 | },
269 | {
270 | "name": "Lights Out",
271 | "playlists": ["LightsOutoldTimeRadio"],
272 | "id": 44
273 | },
274 | {
275 | "name": "The Mysterious Traveler",
276 | "playlists": ["TheMysteriousTraveler"],
277 | "id": 45
278 | },
279 | {
280 | "name": "Abbott and Costello",
281 | "playlists": ["otr_abbottandcostello"],
282 | "id": 46
283 | },
284 | {
285 | "name": "New Adventures of Nero Wolfe",
286 | "shortName": "Nero Wolfe",
287 | "playlists": ["OTRR_New_Adventures_of_Nero_Wolfe_Singles"],
288 | "id": 47
289 | },
290 | {
291 | "name": "The Black Museum",
292 | "playlists": ["OTRR_Black_Museum_Singles"],
293 | "id": 48,
294 | "skip": ["Intro", "Biography"]
295 | },
296 | {
297 | "name": "My Favorite Husband",
298 | "playlists": ["MyFavoriteHusband"],
299 | "id": 49
300 | },
301 | {
302 | "name": "Command Performance",
303 | "playlists": ["CommandPerformance"],
304 | "id": 50
305 | },
306 | {
307 | "name": "The Whistler",
308 | "playlists": ["OTRR_Whistler_Singles"],
309 | "id": 51
310 | },
311 | {
312 | "name": "Calling All Cars",
313 | "playlists": ["OTRR_Calling_All_Cars_Singles"],
314 | "id": 52
315 | },
316 | {
317 | "name": "Weird Circle",
318 | "playlists": ["OTRR_Weird_Circle_Singles"],
319 | "id": 53
320 | },
321 | {
322 | "name": "The Hermit's Cave",
323 | "playlists": ["The_Hermits_Cave"],
324 | "id": 54
325 | },
326 | {
327 | "name": "Hopalong Cassidy",
328 | "playlists": ["HopalongCassidy"],
329 | "id": 55
330 | },
331 | {
332 | "name": "CBS Radio Mystery Theater [Fantasy]",
333 | "shortName": "CBS Radio Mystery Theater",
334 | "playlists": ["CBSMTFantasy1", "cbsmtfs2", "cbsmtfs3", "cbsmtfs4", "cbsmtfs5", "cbsmtfs6", "cbsmtfs7", "cbsmtfs8"],
335 | "id": 56
336 | },
337 | {
338 | "name": "CBS Radio Mystery Theater",
339 | "shortName": "CBS Radio Mystery Theater",
340 | "playlists": ["CbsRadioMysteryTheater1975Page1", "CbsRadioMysteryTheater1976Page1", "CbsRadioMysteryTheater1976Page2", "CbsRadioMysteryTheater1976Page3", "CbsRadioMysteryTheater1976Page4", "CbsRadioMysteryTheater1976Page5"],
341 | "id": 57
342 | },
343 | {
344 | "name": "Richard Diamond, Private Detective",
345 | "shortName": "Richard Diamond",
346 | "playlists": ["OTRR_Richard_Diamond_Private_Detective_Singles"],
347 | "id": 58
348 | },
349 | {
350 | "name": "Ranger Bill",
351 | "playlists": ["OTRR_Ranger_Bill_Singles"],
352 | "id": 59
353 | },
354 | {
355 | "name": "Let George Do It",
356 | "playlists": ["OTRR_Let_George_Do_It_Singles"],
357 | "id": 60,
358 | "skip": ["Audition"]
359 | },
360 | {
361 | "name": "Father Knows Best",
362 | "playlists": ["FatherKnowsBest45Episodes"],
363 | "id": 61
364 | },
365 | {
366 | "name": "Secrets of Scotland Yard",
367 | "playlists": ["OTRR_Secrets_Of_Scotland_Yard_Singles"],
368 | "id": 62
369 | },
370 | {
371 | "name": "Mr District Attorney",
372 | "playlists": ["OTRR_Mr_District_Attorney_Singles"],
373 | "id": 63
374 | },
375 | {
376 | "name": "The Life of Riley",
377 | "playlists": ["TheLifeOfRiley"],
378 | "id": 64
379 | },
380 | {
381 | "name": "Lux Radio Theatre",
382 | "playlists": ["OTRR_Lux_Radio_Theater_Singles"],
383 | "id": 65
384 | },
385 | {
386 | "name": "The Campbell Playhouse",
387 | "playlists": ["otr_campbellplayhouse"],
388 | "id": 66
389 | },
390 | {
391 | "name": "Mercury Theatre",
392 | "playlists": ["OrsonWelles-MercuryTheater-1938Recordings"],
393 | "id": 67
394 | },
395 | {
396 | "name": "On Stage",
397 | "playlists": ["OTRR_On_Stage_Singles_201901"],
398 | "id": 68,
399 | "skip": ["audio brief", "Bio"]
400 | },
401 | {
402 | "name": "Screen Guild Theater",
403 | "playlists": ["ScreenGuildTheater"],
404 | "id": 69
405 | },
406 | {
407 | "name": "Academy Award",
408 | "playlists": ["OTRR_Academy_Award_Theater_Singles"],
409 | "id": 70
410 | },
411 | {
412 | "name": "First Nighter",
413 | "playlists": ["first-nighter"],
414 | "id": 71
415 | },
416 | {
417 | "name": "Screen Director's Playhouse",
418 | "playlists": ["ScreenDirectorsPlayhouse"],
419 | "id": 72
420 | },
421 | {
422 | "name": "NBC University Theater",
423 | "playlists": ["NBC_University_Theater"],
424 | "id": 73
425 | },
426 | {
427 | "name": "Dr. Kildare",
428 | "playlists": ["OTRR_Dr_Kildare_Singles"],
429 | "id": 74
430 | },
431 | {
432 | "name": "Yours Truly, Johnny Dollar",
433 | "playlists": ["OTRR_YoursTrulyJohnnyDollar_Singles"],
434 | "id": 75
435 | },
436 | {
437 | "name": "Vic and Sade",
438 | "playlists": ["VicSade1937-1939", "VicSade1940-1941", "VicSade1942-1947"],
439 | "id": 76
440 | },
441 | {
442 | "name": "SF 68",
443 | "playlists": ["Sf_68"],
444 | "id": 77
445 | },
446 | {
447 | "name": "The Haunting Hour",
448 | "playlists": ["haunting_hour"],
449 | "id": 78
450 | },
451 | {
452 | "name": "Moon Over Africa",
453 | "playlists": ["OTRR_Moon_Over_Africa_Singles"],
454 | "id": 79
455 | },
456 | {
457 | "name": "Dangerous Assignment",
458 | "playlists": ["Otrr_Dangerous_Assignment_Singles"],
459 | "id": 80
460 | },
461 | {
462 | "name": "Sealed Book",
463 | "playlists": ["OTRR_Sealed_Book_Singles"],
464 | "id": 81
465 | },
466 | {
467 | "name": "Whitehall 1212",
468 | "playlists": ["OTRR_Whitehall_1212_Singles"],
469 | "id": 82,
470 | "skip": ["Series Synopsis", "star bio", "Audition"]
471 | },
472 | {
473 | "name": "The Adventures of Frank Race",
474 | "playlists": ["OTRR_Frank_Race_Singles"],
475 | "id": 83
476 | },
477 | {
478 | "name": "The Halls of Ivy",
479 | "playlists": ["OTRR_Halls_Of_Ivy_Singles"],
480 | "id": 84
481 | },
482 | {
483 | "name": "The Clock",
484 | "playlists": ["TheClock"],
485 | "id": 85
486 | },
487 | {
488 | "name": "John Steele Adventurer",
489 | "playlists": ["OTRR_John_Steele_Adventurer_Singles"],
490 | "id": 86
491 | },
492 | {
493 | "name": "The Mel Blanc Show",
494 | "playlists": ["OTRR_Mel_Blanc_Singles"],
495 | "id": 87
496 | },
497 | {
498 | "name": "Burns and Allen",
499 | "playlists": ["the-burns-and-allen-show-1934-09-26-2-leaving-for-america"],
500 | "id": 88
501 | },
502 | {
503 | "name": "This Is Your FBI",
504 | "playlists": ["OTRR_This_Is_Your_FBI_Singles"],
505 | "id": 89
506 | },
507 | {
508 | "name": "The Man Called X",
509 | "playlists": ["OTRR_Man_Called_X_Singles"],
510 | "id": 90,
511 | "skip": ["Series Synopsis", "Herbert Marshall", "Leon Blasco"]
512 | },
513 | {
514 | "name": "Counter-Spy",
515 | "playlists": ["OTRR_Counterspy_Singles"],
516 | "id": 91
517 | },
518 | {
519 | "name": "Magic Island",
520 | "playlists": ["OTRR_Magic_Island_Singles"],
521 | "id": 92
522 | },
523 | {
524 | "name": "Frontier Gentleman",
525 | "playlists": ["FrontierGentleman-All41Episodes"],
526 | "id": 93
527 | },
528 | {
529 | "name": "Philo Vance",
530 | "playlists": ["OTRR_Philo_Vance_Singles"],
531 | "id": 94
532 | },
533 | {
534 | "name": "Ozzie & Harriet",
535 | "playlists": ["OzzieHarriet"],
536 | "id": 95
537 | },
538 | {
539 | "name": "Duffy's Tavern",
540 | "playlists": ["DuffysTavern_524"],
541 | "id": 96
542 | },
543 | {
544 | "name": "Frontier Town",
545 | "playlists": ["OTRR_Frontier_Town_Singles"],
546 | "id": 97
547 | },
548 | {
549 | "name": "Hall of Fantasy",
550 | "playlists": ["470213ThePerfectScript"],
551 | "id": 98
552 | },
553 | {
554 | "name": "Wild Bill Hickok",
555 | "playlists": ["OTRR_Wild_Bill_Hickock_Singles"],
556 | "id": 99,
557 | "skip": ["Series Synopsis", "Bio"]
558 | },
559 | {
560 | "name": "Candy Matson",
561 | "playlists": ["OTRR_Candy_Matson_Singles"],
562 | "id": 100
563 | },
564 | {
565 | "name": "Sam Spade",
566 | "playlists": ["OTRR_Sam_Spade_Singles"],
567 | "id": 101,
568 | "skip": ["Intro", "Biography", "SamShovel", "Bbc", "Spoof", "Suspense", "TheMalteseFalcon", "BurnsAllen"]
569 | },
570 | {
571 | "name": "Radio Reader's Digest",
572 | "playlists": ["RadioReadersDigest"],
573 | "id": 102
574 | },
575 | {
576 | "name": "Classic Baseball",
577 | "playlists": ["classicmlbbaseballradio"],
578 | "id": 103
579 | },
580 | {
581 | "name": "Black Flame of the Amazon",
582 | "playlists": ["OTRR_Black_Flame_Singles"],
583 | "id": 104,
584 | "skip": ["Introduction"]
585 | },
586 | {
587 | "name": "The Shadow",
588 | "playlists": ["the-shadow-1938-10-09-141-death-stalks-the-shadow"],
589 | "id": 105
590 | },
591 | {
592 | "name": "Box 13",
593 | "playlists": ["OTRR_Box_13_Singles"],
594 | "id": 106
595 | },
596 | {
597 | "name": "Barrie Craig, Confidential Investigator",
598 | "playlists": ["OTRR_Barrie_Craig_Singles"],
599 | "id": 107
600 | },
601 | {
602 | "name": "Mr. Keen, Tracer of Lost Persons",
603 | "playlists": ["OTRR_Mr_Keen_Tracer_Of_Lost_Persons_Singles"],
604 | "id": 108,
605 | "skip": ["Introduction", "Producers", "Biography"]
606 | },
607 | {
608 | "name": "Rocky Jordan",
609 | "playlists": ["RockyJordan"],
610 | "id": 109,
611 | "skip": ["Audition"]
612 | },
613 | {
614 | "name": "Nick Carter, Master Detective",
615 | "playlists": ["OTRR_Nick_Carter_Master_Detective_Singles"],
616 | "id": 110
617 | },
618 | {
619 | "name": "The Aldrich Family",
620 | "playlists": ["TheAldrichFamily"],
621 | "id": 111
622 | },
623 | {
624 | "name": "Gang Busters",
625 | "playlists": ["gang-busters-1955-04-02-885-the-case-of-the-mistreated-lady"],
626 | "id": 112
627 | },
628 | {
629 | "name": "Night Beat",
630 | "playlists": ["night-beat-1950-07-24-25-the-devils-bible"],
631 | "id": 113
632 | },
633 | {
634 | "name": "Lum and Abner",
635 | "playlists": ["l-a-1953-11-20-xx-thanksgiving-in-pine-ridge"],
636 | "id": 114
637 | },
638 | {
639 | "name": "Voyage of the Scarlet Queen",
640 | "playlists": ["VoyageOfTheScarletQueen"],
641 | "id": 115
642 | },
643 | {
644 | "name": "The Bob Hope Show",
645 | "playlists": ["The_Bob_Hope_Program"],
646 | "id": 116
647 | },
648 | {
649 | "name": "The Martin and Lewis Show",
650 | "playlists": ["MartinAndLewis_OldTimeRadio"],
651 | "id": 117
652 | },
653 | {
654 | "name": "It's Higgins, Sir!",
655 | "playlists": ["ItsHigginsSir"],
656 | "id": 118
657 | },
658 | {
659 | "name": "The Fred Allen Show",
660 | "playlists": ["town-hall-tonight-1938-06-08-232-music-publisher-needs-a-tune"],
661 | "id": 119
662 | },
663 | {
664 | "name": "The Bickersons",
665 | "playlists": ["bickersons-1947-03-02-12-blanche-has-a-stomach-ache"],
666 | "id": 120
667 | },
668 | {
669 | "name": "The Big Show",
670 | "playlists": ["OTRR_The_Big_Show_Singles"],
671 | "id": 121
672 | },
673 | {
674 | "name": "The Bing Crosby Show",
675 | "playlists": ["general-electric-show-52-54-1952-12-25-12-guest-gary-crosby"],
676 | "id": 122
677 | },
678 | {
679 | "name": "Amos and Andy",
680 | "playlists": ["a-a-1948-11-14-183-tourist-sightseeing-agency-aka-ny-sightseeing-agency-aka-andy"],
681 | "id": 123
682 | },
683 | {
684 | "name": "Perry Mason",
685 | "playlists": ["Perry_Mason_Radio_Show"],
686 | "id": 124
687 | },
688 | {
689 | "name": "Eddie Cantor",
690 | "playlists": ["chaseandsanbornhour1931122015firstsongcarolinamoon"],
691 | "id": 125
692 | },
693 | {
694 | "name": "Edgar Bergen and Charlie McCarthy",
695 | "playlists": ["edgar-bergen-1937-12-12-32-guest-mae-west"],
696 | "id": 126
697 | },
698 | {
699 | "name": "The Damon Runyon Theatre",
700 | "playlists": ["OTRR_Damon_Runyon_Singles"],
701 | "id": 127
702 | },
703 | {
704 | "name": "Quiet, Please",
705 | "playlists": ["QuietPlease_806"],
706 | "id": 128
707 | },
708 | {
709 | "name": "The Witch's Tale",
710 | "playlists": ["TheWitchsTale"],
711 | "id": 129
712 | },
713 | {
714 | "name": "The Shadow of Fu Manchu",
715 | "playlists": ["390903-102-the-joy-shop"],
716 | "id": 130
717 | },
718 | {
719 | "name": "Chandu the Magician",
720 | "playlists": ["otr_chanduthemagician"],
721 | "id": 131
722 | },
723 | {
724 | "name": "Challenge of the Yukon",
725 | "playlists": ["OTRR_Challenge_of_the_Yukon_Singles"],
726 | "id": 132
727 | },
728 | {
729 | "name": "Michael Shayne",
730 | "playlists": ["Michael_Shayne"],
731 | "id": 133
732 | },
733 | {
734 | "name": "Mr Moto",
735 | "playlists": ["MrMoto"],
736 | "id": 134
737 | },
738 | {
739 | "name": "It Pays To Be Ignorant",
740 | "playlists": ["ItPaysToBeIgnorant"],
741 | "id": 135
742 | },
743 | {
744 | "name": "Jeff Regan",
745 | "playlists": ["OTRR_Jeff_Regan_Singles"],
746 | "id": 136
747 | },
748 | {
749 | "name": "You Bet Your Life",
750 | "playlists": ["you-bet-your-life-1952-02-20-160-secret-word-heart"],
751 | "id": 137
752 | },
753 | {
754 | "name": "The Scarlet Pimpernel",
755 | "playlists": ["The_Scarlet_Pimpernel"],
756 | "id": 138
757 | },
758 | {
759 | "name": "Dr. Christian",
760 | "playlists": ["Dr.Christian_911"],
761 | "id": 139
762 | },
763 | {
764 | "name": "One Man's Family",
765 | "playlists": ["OneMansFamily"],
766 | "id": 140
767 | },
768 | {
769 | "name": "The Goldbergs",
770 | "playlists": ["Goldbergs"],
771 | "id": 141
772 | },
773 | {
774 | "name": "Ma Perkins",
775 | "playlists": ["MaPerkins021950"],
776 | "id": 142
777 | },
778 | {
779 | "name": "Adventures of Frank Merriwell",
780 | "playlists": ["AdventuresofFrankMerriwell"],
781 | "id": 143
782 | },
783 | {
784 | "name": "Adventures of Maisie",
785 | "playlists": ["Maisie"],
786 | "id": 144
787 | },
788 | {
789 | "name": "Red Ryder",
790 | "playlists": ["red-ryder"],
791 | "id": 145
792 | },
793 | {
794 | "name": "Horizons West",
795 | "playlists": ["OtrHorizonsWest13Of13Eps"],
796 | "id": 146
797 | },
798 | {
799 | "name": "Mark Trail",
800 | "playlists": ["mark_trail"],
801 | "id": 147
802 | },
803 | {
804 | "name": "Luke Slaughter",
805 | "playlists": ["OTRR_Luke_Slaughter_Of_Tombstone_Singles"],
806 | "id": 148
807 | },
808 | {
809 | "name": "Information, Please",
810 | "playlists": ["Information-Please"],
811 | "id": 149
812 | },
813 | {
814 | "name": "My Friend Irma",
815 | "playlists": ["my-friend-irma"],
816 | "id": 150
817 | },
818 | {
819 | "name": "Eb and Zeb",
820 | "playlists": ["EbZeb"],
821 | "id": 151
822 | },
823 | {
824 | "name": "The Milton Berle Show",
825 | "playlists": ["Milton_Berle_47-48"],
826 | "id": 152
827 | },
828 | {
829 | "name": "The Goon Show",
830 | "playlists": ["TheGoonShow1950to1960"],
831 | "id": 153
832 | },
833 | {
834 | "name": "The Spike Jones Show",
835 | "playlists": ["SpikeJones"],
836 | "id": 154
837 | },
838 | {
839 | "name": "Easy Aces",
840 | "playlists": ["otr_easyaces"],
841 | "id": 155
842 | },
843 | {
844 | "name": "The Red Skelton Show",
845 | "playlists": ["red-skelton-show_202008"],
846 | "id": 156
847 | },
848 | {
849 | "name": "Ghost Corps",
850 | "playlists": ["otr_ghostcorps"],
851 | "id": 157
852 | },
853 | {
854 | "name": "Dick Barton, Special Agent",
855 | "playlists": ["otr_dickbartonspecialagent"],
856 | "id": 158
857 | },
858 | {
859 | "name": "21st Precinct",
860 | "playlists": ["OTRR_21st_Precinct_Singles"],
861 | "id": 159
862 | },
863 | {
864 | "name": "Pete Kelly's Blues",
865 | "playlists": ["PeteKellysBlues"],
866 | "id": 160,
867 | "skip": ["64kb"]
868 | },
869 | {
870 | "name": "The Line-Up",
871 | "playlists": ["OTRR_Line_Up_Singles"],
872 | "id": 161
873 | },
874 | {
875 | "name": "Diary of Fate",
876 | "playlists": ["DiaryOfFate"],
877 | "id": 162
878 | },
879 | {
880 | "name": "Ripley's Believe It or Not",
881 | "playlists": ["believe-it-or-not"],
882 | "id": 163
883 | },
884 | {
885 | "name": "Bill Stern Sports Reel",
886 | "playlists": ["Bill_Sterns_Sports_Newsreel"],
887 | "id": 164
888 | },
889 | {
890 | "name": "World Adventurer's Club",
891 | "playlists": ["OTRR_World_Adventurer_Club_Singles"],
892 | "id": 165
893 | }
894 | ],
895 | "channels" : [
896 | {
897 | "name" : "future",
898 | "shows": [0,1,2,3,4,5,6,7,8,9,77]
899 | },
900 | {
901 | "name" : "action",
902 | "shows": [0,10,11,14,15,16,18,39,40,42,59,79,80,83,86,90,91,92,104,115,131,132,138,165]
903 | },
904 | {
905 | "name" : "crime",
906 | "shows": [0,12,13,22,23,24,37,41,47,48,52,58,60,62,63,75,82,89,94,100,101,107,108,110,112,124,127,130,133,136,159,160,161]
907 | },
908 | {
909 | "name" : "horror",
910 | "shows": [0,17,43,44,53,54,78,81,98,128,129,162]
911 | },
912 | {
913 | "name" : "suspense",
914 | "shows": [0,19,20,21,38,45,51,56,57,85,105,106,109,113,134,157,158]
915 | },
916 | {
917 | "name" : "western",
918 | "shows": [0,25,26,27,28,29,30,55,93,97,99,145,146,147,148]
919 | },
920 | {
921 | "name" : "comedy",
922 | "shows": [0,31,32,33,34,35,36,46,49,61,64,76,84,87,88,95,96,111,114,116,117,118,119,120,121,123,125,126,135,137,149,150,151,152,153,154,155,156]
923 | },
924 | {
925 | "name" : "drama",
926 | "shows": [0,65,66,67,68,69,70,71,72,73,74,102,139,140,141,142,143,144]
927 | },
928 | {
929 | "name" : "sports",
930 | "shows": [0,103,164]
931 | }
932 | ]
933 | }
--------------------------------------------------------------------------------