├── .gitignore
├── LICENSE
├── README.md
├── config.json
├── notes.txt
├── package-lock.json
├── package.json
├── public
├── audio_test.html
├── css
│ ├── Bellerose.ttf
│ ├── CourierPrime-Regular.ttf
│ └── main.css
├── favicon.png
├── index.html
├── js
│ ├── audioPlayer.js
│ ├── clock.js
│ ├── config.js
│ ├── events.js
│ ├── main.js
│ ├── messageManager.js
│ ├── model.js
│ ├── playingNowManager.js
│ ├── service.js
│ ├── sleepTimer.js
│ ├── snowMachine.js
│ ├── stateMachine.js
│ ├── utils.js
│ ├── view.js
│ ├── viewSchedule.js
│ ├── viewSleepTimer.js
│ ├── viewStationBuilder.js
│ ├── visualiser.js
│ └── visualiserData.js
├── otr.png
├── robots.txt
├── silence.mp3
└── swirl_sprites.png
├── src
├── app.js
├── archiveOrg.js
├── cache.js
├── channelCodes.js
├── clock.js
├── configChecker.js
├── configHelper.js
├── log.js
├── nameParser.js
├── playlistData.js
├── scheduler.js
├── service.js
├── sitemap.js
└── webClient.js
└── test
├── cache-test.js
├── channelCodes-test.js
├── scheduler-test.js
└── support
└── jasmine.json
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "web": {
3 | "port": 3000,
4 | "paths": {
5 | "static": "public",
6 | "listenTo": "https://oldtime.radio/listen-to/",
7 | "api": {
8 | "shows": "/api/shows",
9 | "channels": "/api/channels",
10 | "channel": "/api/channel/",
11 | "generate": "/api/channel/generate/",
12 | "playingNow": "/api/playing-now"
13 | }
14 | }
15 | },
16 | "minRequestIntervalMillis": 1500,
17 | "caches": {
18 | "location": "./cache",
19 | "expirySeconds": {
20 | "web": 36000,
21 | "channelFullSchedule": 36000,
22 | "shows": 36000,
23 | "channels": 36000
24 | }
25 | },
26 | "scheduler": {
27 | "radical": 1.8
28 | },
29 | "log": {
30 | "file": "/var/log/oldtimeradio/access.log",
31 | "level": "info"
32 | },
33 | "shows" : [
34 | {
35 | "name": "Commercials",
36 | "playlists": ["Old_Radio_Adverts_01"],
37 | "index": 0,
38 | "isCommercial": true
39 | },
40 | {
41 | "name": "X Minus One",
42 | "playlists": ["OTRR_X_Minus_One_Singles"],
43 | "index": 1
44 | },
45 | {
46 | "name": "Dimension X",
47 | "playlists": ["OTRR_Dimension_X_Singles"],
48 | "index": 2
49 | },
50 | {
51 | "name": "Space Patrol",
52 | "playlists": ["OTRR_Space_Patrol_Singles"],
53 | "index": 3
54 | },
55 | {
56 | "name": "Tom Corbett, Space Cadet",
57 | "playlists": ["SpaceCadet", "SpaceCadet2"],
58 | "index": 4
59 | },
60 | {
61 | "name": "Flash Gordon",
62 | "playlists": ["Flash_Gordon1935"],
63 | "index": 5
64 | },
65 | {
66 | "name": "Buck Rogers",
67 | "playlists": ["otr_buckrogers"],
68 | "index": 6
69 | },
70 | {
71 | "name": "Exploring Tomorrow",
72 | "playlists": ["Exploring_Tomorrow"],
73 | "index": 7
74 | },
75 | {
76 | "name": "2000 Plus",
77 | "playlists": ["otr_2000Plus"],
78 | "index": 8
79 | },
80 | {
81 | "name": "Planet Man",
82 | "playlists": ["OTRR_Planet_Man_Ver2_Singles"],
83 | "index": 9
84 | },
85 | {
86 | "name": "The Adventures of Superman",
87 | "shortName": "Superman",
88 | "playlists": ["TheAdventuresOfSuperman_201805"],
89 | "index": 10
90 | },
91 | {
92 | "name": "Tarzan",
93 | "playlists": ["OTRR_Tarzan_Singles_TotA", "OTRR_Tarzan_Singles_TatDoA", "OTRR_Tarzan_Singles_TatFoT", "OTRR_Tarzan_Singles_TLotJ"],
94 | "index": 11
95 | },
96 | {
97 | "name": "The Green Hornet",
98 | "playlists": ["TheGreenHornet"],
99 | "index": 12
100 | },
101 | {
102 | "name": "Dragnet",
103 | "playlists": ["Dragnet_OTR"],
104 | "index": 13
105 | },
106 | {
107 | "name": "Speed Gibson Of The International Secret Police",
108 | "shortName": "Speed Gibson",
109 | "playlists": ["Speed_Gibson_Of_The_International_Secret_Police"],
110 | "index": 14
111 | },
112 | {
113 | "name": "The Blue Beetle",
114 | "playlists": ["OTRR_Blue_Beetle_Singles"],
115 | "index": 15
116 | },
117 | {
118 | "name": "The Falcon",
119 | "playlists": ["OTRR_Falcon_Singles"],
120 | "index": 16
121 | },
122 | {
123 | "name": "Dark Fantasy",
124 | "playlists": ["OTRR_Dark_Fantasy_Singles"],
125 | "index": 17
126 | },
127 | {
128 | "name": "I Love a Mystery",
129 | "playlists": ["otr_iloveamystery"],
130 | "index": 18,
131 | "skip": ["interview"]
132 | },
133 | {
134 | "name": "Suspense",
135 | "playlists": ["SUSPENSE", "SUSPENSE2", "SUSPENSE3"],
136 | "index": 19
137 | },
138 | {
139 | "name": "Molle Mystery Theatre",
140 | "playlists": ["OTRR_Molle_Mystery_Theatre_Singles"],
141 | "index": 20
142 | },
143 | {
144 | "name": "Mystery House",
145 | "playlists": ["OTRR_Mystery_House_Singles"],
146 | "index": 21
147 | },
148 | {
149 | "name": "The Adventures of Philip Marlowe",
150 | "shortName": "Philip Marlowe",
151 | "playlists": ["OTRR_Philip_Marlowe_Singles"],
152 | "index": 22
153 | },
154 | {
155 | "name": "Boston Blackie",
156 | "playlists": ["OTRR_Boston_Blackie_Singles"],
157 | "index": 23
158 | },
159 | {
160 | "name": "The New Adventures of Sherlock Holmes",
161 | "shortName": "Sherlock Holmes",
162 | "playlists": ["sherlockholmes_otr"],
163 | "index": 24
164 | },
165 | {
166 | "name": "Gunsmoke",
167 | "playlists": ["GUNSMOKE01", "GUNSMOKE02", "GUNSMOKE03", "GUNSMOKE04"],
168 | "index": 25
169 | },
170 | {
171 | "name": "The Lone Ranger",
172 | "playlists": ["The_Lone_Ranger_Page_01", "The_Lone_Ranger_Page_02"],
173 | "index": 26
174 | },
175 | {
176 | "name": "Have Gun Will Travel",
177 | "playlists": ["HaveGunWillTravel_OldTimeRadio"],
178 | "index": 27,
179 | "skip": ["64kb"]
180 | },
181 | {
182 | "name": "Tales of the Texas Rangers",
183 | "shortName": "Texas Rangers",
184 | "playlists": ["TalesOfTheTexasRangers"],
185 | "index": 28
186 | },
187 | {
188 | "name": "The Six Shooter",
189 | "playlists": ["OTRR_The_Six_Shooter_Singles"],
190 | "index": 29,
191 | "skip": ["OTRR Introduction", "The Six Shooter Intro", "Jimmy Stewart Biography", "Hollywood Star Playhouse"]
192 | },
193 | {
194 | "name": "Fort Laramie",
195 | "playlists": ["OTRR_Fort_Laramie_Singles"],
196 | "index": 30
197 | },
198 | {
199 | "name": "Our Miss Brooks",
200 | "playlists": ["OurMissBrooks"],
201 | "index": 31
202 | },
203 | {
204 | "name": "The Great Gildersleeve",
205 | "playlists": ["Great_Gildersleeve"],
206 | "index": 32
207 | },
208 | {
209 | "name": "The Harold Peary Show",
210 | "shortName": "Harold Peary",
211 | "playlists": ["OTRR_Harold_Peary_Show_Singles"],
212 | "index": 33
213 | },
214 | {
215 | "name": "The Jack Benny Show",
216 | "shortName": "Jack Benny",
217 | "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"],
218 | "index": 34
219 | },
220 | {
221 | "name": "The Phil Harris - Alice Faye Show",
222 | "shortName": "Phil Harris - Alice Faye",
223 | "playlists": ["OTRR_Harris_Faye_Singles"],
224 | "index": 35,
225 | "skip": ["Audio Bio", "audio synopsis"]
226 | },
227 | {
228 | "name": "Fibber McGee and Molly",
229 | "shortName": "Fibber McGee",
230 | "playlists": ["FibberMcgeeAndMollyHq"],
231 | "index": 36
232 | },
233 | {
234 | "name": "Pat Novak, For Hire",
235 | "shortName": "Pat Novak",
236 | "playlists": ["PatNovakForHire"],
237 | "index": 37
238 | },
239 | {
240 | "name": "Escape",
241 | "playlists": ["otr_escape"],
242 | "index": 38
243 | },
244 | {
245 | "name": "The Lives of Harry Lime",
246 | "shortName": "Harry Lime",
247 | "playlists": ["TheLivesOfHarryLime"],
248 | "index": 39
249 | },
250 | {
251 | "name": "The Saint",
252 | "playlists": ["TheSaintVincentPriceOTR"],
253 | "index": 40
254 | },
255 | {
256 | "name": "Broadway Is My Beat",
257 | "playlists": ["OTRR_Broadway_Is_My_Beat_Singles"],
258 | "index": 41
259 | },
260 | {
261 | "name": "Bold Venture",
262 | "playlists": ["BoldVenture57Episodes"],
263 | "index": 42
264 | },
265 | {
266 | "name": "Inner Sanctum Mysteries",
267 | "playlists": ["OTRR_Inner_Sanctum_Mysteries_Singles"],
268 | "index": 43
269 | },
270 | {
271 | "name": "Lights Out",
272 | "playlists": ["LightsOutoldTimeRadio"],
273 | "index": 44
274 | },
275 | {
276 | "name": "The Mysterious Traveler",
277 | "playlists": ["TheMysteriousTraveler"],
278 | "index": 45
279 | },
280 | {
281 | "name": "Abbott and Costello",
282 | "playlists": ["otr_abbottandcostello"],
283 | "index": 46
284 | },
285 | {
286 | "name": "New Adventures of Nero Wolfe",
287 | "shortName": "Nero Wolfe",
288 | "playlists": ["OTRR_New_Adventures_of_Nero_Wolfe_Singles"],
289 | "index": 47
290 | },
291 | {
292 | "name": "The Black Museum",
293 | "playlists": ["OTRR_Black_Museum_Singles"],
294 | "index": 48,
295 | "skip": ["Intro", "Biography"]
296 | },
297 | {
298 | "name": "My Favorite Husband",
299 | "playlists": ["MyFavoriteHusband"],
300 | "index": 49
301 | },
302 | {
303 | "name": "Command Performance",
304 | "playlists": ["CommandPerformance"],
305 | "index": 50
306 | },
307 | {
308 | "name": "The Whistler",
309 | "playlists": ["OTRR_Whistler_Singles"],
310 | "index": 51
311 | },
312 | {
313 | "name": "Calling All Cars",
314 | "playlists": ["OTRR_Calling_All_Cars_Singles"],
315 | "index": 52
316 | },
317 | {
318 | "name": "Weird Circle",
319 | "playlists": ["OTRR_Weird_Circle_Singles"],
320 | "index": 53
321 | },
322 | {
323 | "name": "The Hermit's Cave",
324 | "playlists": ["The_Hermits_Cave"],
325 | "index": 54
326 | },
327 | {
328 | "name": "Hopalong Cassidy",
329 | "playlists": ["HopalongCassidy"],
330 | "index": 55
331 | },
332 | {
333 | "name": "CBS Radio Mystery Theater [Fantasy]",
334 | "shortName": "CBS Radio Mystery Theater",
335 | "playlists": ["CBSMTFantasy1", "cbsmtfs2", "cbsmtfs3", "cbsmtfs4", "cbsmtfs5", "cbsmtfs6", "cbsmtfs7", "cbsmtfs8"],
336 | "index": 56
337 | },
338 | {
339 | "name": "CBS Radio Mystery Theater",
340 | "shortName": "CBS Radio Mystery Theater",
341 | "playlists": ["CbsRadioMysteryTheater1975Page1", "CbsRadioMysteryTheater1976Page1", "CbsRadioMysteryTheater1976Page2", "CbsRadioMysteryTheater1976Page3", "CbsRadioMysteryTheater1976Page4", "CbsRadioMysteryTheater1976Page5"],
342 | "index": 57
343 | },
344 | {
345 | "name": "Richard Diamond, Private Detective",
346 | "shortName": "Richard Diamond",
347 | "playlists": ["OTRR_Richard_Diamond_Private_Detective_Singles"],
348 | "index": 58
349 | },
350 | {
351 | "name": "Ranger Bill",
352 | "playlists": ["OTRR_Ranger_Bill_Singles"],
353 | "index": 59
354 | },
355 | {
356 | "name": "Let George Do It",
357 | "playlists": ["OTRR_Let_George_Do_It_Singles"],
358 | "index": 60,
359 | "skip": ["Audition"]
360 | },
361 | {
362 | "name": "Father Knows Best",
363 | "playlists": ["FatherKnowsBest45Episodes"],
364 | "index": 61
365 | },
366 | {
367 | "name": "Secrets of Scotland Yard",
368 | "playlists": ["OTRR_Secrets_Of_Scotland_Yard_Singles"],
369 | "index": 62
370 | },
371 | {
372 | "name": "Mr District Attorney",
373 | "playlists": ["OTRR_Mr_District_Attorney_Singles"],
374 | "index": 63
375 | },
376 | {
377 | "name": "The Life of Riley",
378 | "playlists": ["TheLifeOfRiley"],
379 | "index": 64
380 | },
381 | {
382 | "name": "Lux Radio Theatre",
383 | "playlists": ["OTRR_Lux_Radio_Theater_Singles"],
384 | "index": 65
385 | },
386 | {
387 | "name": "The Campbell Playhouse",
388 | "playlists": ["otr_campbellplayhouse"],
389 | "index": 66
390 | },
391 | {
392 | "name": "Mercury Theatre",
393 | "playlists": ["OrsonWelles-MercuryTheater-1938Recordings"],
394 | "index": 67
395 | },
396 | {
397 | "name": "On Stage",
398 | "playlists": ["OTRR_On_Stage_Singles_201901"],
399 | "index": 68,
400 | "skip": ["audio brief", "Bio"]
401 | },
402 | {
403 | "name": "Screen Guild Theater",
404 | "playlists": ["ScreenGuildTheater"],
405 | "index": 69
406 | },
407 | {
408 | "name": "Academy Award",
409 | "playlists": ["OTRR_Academy_Award_Theater_Singles"],
410 | "index": 70
411 | },
412 | {
413 | "name": "First Nighter",
414 | "playlists": ["first-nighter"],
415 | "index": 71
416 | },
417 | {
418 | "name": "Screen Director's Playhouse",
419 | "playlists": ["ScreenDirectorsPlayhouse"],
420 | "index": 72
421 | },
422 | {
423 | "name": "NBC University Theater",
424 | "playlists": ["NBC_University_Theater"],
425 | "index": 73
426 | },
427 | {
428 | "name": "Dr. Kildare",
429 | "playlists": ["OTRR_Dr_Kildare_Singles"],
430 | "index": 74
431 | },
432 | {
433 | "name": "Yours Truly, Johnny Dollar",
434 | "playlists": ["OTRR_YoursTrulyJohnnyDollar_Singles"],
435 | "index": 75
436 | },
437 | {
438 | "name": "Vic and Sade",
439 | "playlists": ["VicSade1937-1939", "VicSade1940-1941", "VicSade1942-1947"],
440 | "index": 76
441 | },
442 | {
443 | "name": "SF 68",
444 | "playlists": ["Sf_68"],
445 | "index": 77
446 | },
447 | {
448 | "name": "The Haunting Hour",
449 | "playlists": ["haunting_hour"],
450 | "index": 78
451 | },
452 | {
453 | "name": "Moon Over Africa",
454 | "playlists": ["OTRR_Moon_Over_Africa_Singles"],
455 | "index": 79
456 | },
457 | {
458 | "name": "Dangerous Assignment",
459 | "playlists": ["Otrr_Dangerous_Assignment_Singles"],
460 | "index": 80
461 | },
462 | {
463 | "name": "Sealed Book",
464 | "playlists": ["OTRR_Sealed_Book_Singles"],
465 | "index": 81
466 | },
467 | {
468 | "name": "Whitehall 1212",
469 | "playlists": ["OTRR_Whitehall_1212_Singles"],
470 | "index": 82,
471 | "skip": ["Series Synopsis", "star bio", "Audition"]
472 | },
473 | {
474 | "name": "The Adventures of Frank Race",
475 | "playlists": ["OTRR_Frank_Race_Singles"],
476 | "index": 83
477 | },
478 | {
479 | "name": "The Halls of Ivy",
480 | "playlists": ["OTRR_Halls_Of_Ivy_Singles"],
481 | "index": 84
482 | },
483 | {
484 | "name": "The Clock",
485 | "playlists": ["TheClock"],
486 | "index": 85
487 | },
488 | {
489 | "name": "John Steele Adventurer",
490 | "playlists": ["OTRR_John_Steele_Adventurer_Singles"],
491 | "index": 86
492 | },
493 | {
494 | "name": "The Mel Blanc Show",
495 | "playlists": ["OTRR_Mel_Blanc_Singles"],
496 | "index": 87
497 | },
498 | {
499 | "name": "Burns and Allen",
500 | "playlists": ["the-burns-and-allen-show-1934-09-26-2-leaving-for-america"],
501 | "index": 88
502 | },
503 | {
504 | "name": "This Is Your FBI",
505 | "playlists": ["OTRR_This_Is_Your_FBI_Singles"],
506 | "index": 89
507 | },
508 | {
509 | "name": "The Man Called X",
510 | "playlists": ["OTRR_Man_Called_X_Singles"],
511 | "index": 90,
512 | "skip": ["Series Synopsis", "Herbert Marshall", "Leon Blasco"]
513 | },
514 | {
515 | "name": "Counter-Spy",
516 | "playlists": ["OTRR_Counterspy_Singles"],
517 | "index": 91
518 | },
519 | {
520 | "name": "Magic Island",
521 | "playlists": ["OTRR_Magic_Island_Singles"],
522 | "index": 92
523 | },
524 | {
525 | "name": "Frontier Gentleman",
526 | "playlists": ["FrontierGentleman-All41Episodes"],
527 | "index": 93
528 | },
529 | {
530 | "name": "Philo Vance",
531 | "playlists": ["OTRR_Philo_Vance_Singles"],
532 | "index": 94
533 | },
534 | {
535 | "name": "Ozzie & Harriet",
536 | "playlists": ["OzzieHarriet"],
537 | "index": 95
538 | },
539 | {
540 | "name": "Duffy's Tavern",
541 | "playlists": ["DuffysTavern_524"],
542 | "index": 96
543 | },
544 | {
545 | "name": "Frontier Town",
546 | "playlists": ["OTRR_Frontier_Town_Singles"],
547 | "index": 97
548 | },
549 | {
550 | "name": "Hall of Fantasy",
551 | "playlists": ["470213ThePerfectScript"],
552 | "index": 98
553 | },
554 | {
555 | "name": "Wild Bill Hickok",
556 | "playlists": ["OTRR_Wild_Bill_Hickock_Singles"],
557 | "index": 99,
558 | "skip": ["Series Synopsis", "Bio"]
559 | },
560 | {
561 | "name": "Candy Matson",
562 | "playlists": ["OTRR_Candy_Matson_Singles"],
563 | "index": 100
564 | },
565 | {
566 | "name": "Sam Spade",
567 | "playlists": ["OTRR_Sam_Spade_Singles"],
568 | "index": 101,
569 | "skip": ["Intro", "Biography", "SamShovel", "Bbc", "Spoof", "Suspense", "TheMalteseFalcon", "BurnsAllen"]
570 | },
571 | {
572 | "name": "Radio Reader's Digest",
573 | "playlists": ["RadioReadersDigest"],
574 | "index": 102
575 | },
576 | {
577 | "name": "Classic Baseball",
578 | "playlists": ["classicmlbbaseballradio"],
579 | "index": 103
580 | },
581 | {
582 | "name": "Black Flame of the Amazon",
583 | "playlists": ["OTRR_Black_Flame_Singles"],
584 | "index": 104,
585 | "skip": ["Introduction"]
586 | },
587 | {
588 | "name": "The Shadow",
589 | "playlists": ["the-shadow-1938-10-09-141-death-stalks-the-shadow"],
590 | "index": 105
591 | },
592 | {
593 | "name": "Box 13",
594 | "playlists": ["OTRR_Box_13_Singles"],
595 | "index": 106
596 | },
597 | {
598 | "name": "Barrie Craig, Confidential Investigator",
599 | "playlists": ["OTRR_Barrie_Craig_Singles"],
600 | "index": 107
601 | },
602 | {
603 | "name": "Mr. Keen, Tracer of Lost Persons",
604 | "playlists": ["OTRR_Mr_Keen_Tracer_Of_Lost_Persons_Singles"],
605 | "index": 108,
606 | "skip": ["Introduction", "Producers", "Biography"]
607 | },
608 | {
609 | "name": "Rocky Jordan",
610 | "playlists": ["RockyJordan"],
611 | "index": 109,
612 | "skip": ["Audition"]
613 | },
614 | {
615 | "name": "Nick Carter, Master Detective",
616 | "playlists": ["OTRR_Nick_Carter_Master_Detective_Singles"],
617 | "index": 110
618 | },
619 | {
620 | "name": "The Aldrich Family",
621 | "playlists": ["TheAldrichFamily"],
622 | "index": 111
623 | },
624 | {
625 | "name": "Gang Busters",
626 | "playlists": ["gang-busters-1955-04-02-885-the-case-of-the-mistreated-lady"],
627 | "index": 112
628 | },
629 | {
630 | "name": "Night Beat",
631 | "playlists": ["night-beat-1950-07-24-25-the-devils-bible"],
632 | "index": 113
633 | },
634 | {
635 | "name": "Lum and Abner",
636 | "playlists": ["l-a-1953-11-20-xx-thanksgiving-in-pine-ridge"],
637 | "index": 114
638 | },
639 | {
640 | "name": "Voyage of the Scarlet Queen",
641 | "playlists": ["VoyageOfTheScarletQueen"],
642 | "index": 115
643 | },
644 | {
645 | "name": "The Bob Hope Show",
646 | "playlists": ["The_Bob_Hope_Program"],
647 | "index": 116
648 | },
649 | {
650 | "name": "The Martin and Lewis Show",
651 | "playlists": ["MartinAndLewis_OldTimeRadio"],
652 | "index": 117
653 | },
654 | {
655 | "name": "It's Higgins, Sir!",
656 | "playlists": ["ItsHigginsSir"],
657 | "index": 118
658 | },
659 | {
660 | "name": "The Fred Allen Show",
661 | "playlists": ["town-hall-tonight-1938-06-08-232-music-publisher-needs-a-tune"],
662 | "index": 119
663 | },
664 | {
665 | "name": "The Bickersons",
666 | "playlists": ["bickersons-1947-03-02-12-blanche-has-a-stomach-ache"],
667 | "index": 120
668 | },
669 | {
670 | "name": "The Big Show",
671 | "playlists": ["OTRR_The_Big_Show_Singles"],
672 | "index": 121
673 | },
674 | {
675 | "name": "The Bing Crosby Show",
676 | "playlists": ["general-electric-show-52-54-1952-12-25-12-guest-gary-crosby"],
677 | "index": 122
678 | },
679 | {
680 | "name": "Amos and Andy",
681 | "playlists": ["a-a-1948-11-14-183-tourist-sightseeing-agency-aka-ny-sightseeing-agency-aka-andy"],
682 | "index": 123
683 | },
684 | {
685 | "name": "Perry Mason",
686 | "playlists": ["Perry_Mason_Radio_Show"],
687 | "index": 124
688 | },
689 | {
690 | "name": "Eddie Cantor",
691 | "playlists": ["chaseandsanbornhour1931122015firstsongcarolinamoon"],
692 | "index": 125
693 | },
694 | {
695 | "name": "Edgar Bergen and Charlie McCarthy",
696 | "playlists": ["edgar-bergen-1937-12-12-32-guest-mae-west"],
697 | "index": 126
698 | },
699 | {
700 | "name": "The Damon Runyon Theatre",
701 | "playlists": ["OTRR_Damon_Runyon_Singles"],
702 | "index": 127
703 | },
704 | {
705 | "name": "Quiet, Please",
706 | "playlists": ["QuietPlease_806"],
707 | "index": 128
708 | },
709 | {
710 | "name": "The Witch's Tale",
711 | "playlists": ["TheWitchsTale"],
712 | "index": 129
713 | },
714 | {
715 | "name": "The Shadow of Fu Manchu",
716 | "playlists": ["390903-102-the-joy-shop"],
717 | "index": 130
718 | },
719 | {
720 | "name": "Chandu the Magician",
721 | "playlists": ["otr_chanduthemagician"],
722 | "index": 131
723 | },
724 | {
725 | "name": "Challenge of the Yukon",
726 | "playlists": ["OTRR_Challenge_of_the_Yukon_Singles"],
727 | "index": 132
728 | },
729 | {
730 | "name": "Michael Shayne",
731 | "playlists": ["Michael_Shayne"],
732 | "index": 133
733 | },
734 | {
735 | "name": "Mr Moto",
736 | "playlists": ["MrMoto"],
737 | "index": 134
738 | },
739 | {
740 | "name": "It Pays To Be Ignorant",
741 | "playlists": ["ItPaysToBeIgnorant"],
742 | "index": 135
743 | },
744 | {
745 | "name": "Jeff Regan",
746 | "playlists": ["OTRR_Jeff_Regan_Singles"],
747 | "index": 136
748 | },
749 | {
750 | "name": "You Bet Your Life",
751 | "playlists": ["you-bet-your-life-1952-02-20-160-secret-word-heart"],
752 | "index": 137
753 | },
754 | {
755 | "name": "The Scarlet Pimpernel",
756 | "playlists": ["The_Scarlet_Pimpernel"],
757 | "index": 138
758 | },
759 | {
760 | "name": "Dr. Christian",
761 | "playlists": ["Dr.Christian_911"],
762 | "index": 139
763 | },
764 | {
765 | "name": "One Man's Family",
766 | "playlists": ["OneMansFamily"],
767 | "index": 140
768 | },
769 | {
770 | "name": "The Goldbergs",
771 | "playlists": ["Goldbergs"],
772 | "index": 141
773 | },
774 | {
775 | "name": "Ma Perkins",
776 | "playlists": ["MaPerkins021950"],
777 | "index": 142
778 | },
779 | {
780 | "name": "Adventures of Frank Merriwell",
781 | "playlists": ["AdventuresofFrankMerriwell"],
782 | "index": 143
783 | },
784 | {
785 | "name": "Adventures of Maisie",
786 | "playlists": ["Maisie"],
787 | "index": 144
788 | },
789 | {
790 | "name": "Red Ryder",
791 | "playlists": ["red-ryder"],
792 | "index": 145
793 | },
794 | {
795 | "name": "Horizons West",
796 | "playlists": ["OtrHorizonsWest13Of13Eps"],
797 | "index": 146
798 | },
799 | {
800 | "name": "Mark Trail",
801 | "playlists": ["mark_trail"],
802 | "index": 147
803 | },
804 | {
805 | "name": "Luke Slaughter",
806 | "playlists": ["OTRR_Luke_Slaughter_Of_Tombstone_Singles"],
807 | "index": 148
808 | },
809 | {
810 | "name": "Information, Please",
811 | "playlists": ["Information-Please"],
812 | "index": 149
813 | },
814 | {
815 | "name": "My Friend Irma",
816 | "playlists": ["my-friend-irma"],
817 | "index": 150
818 | },
819 | {
820 | "name": "Eb and Zeb",
821 | "playlists": ["EbZeb"],
822 | "index": 151
823 | },
824 | {
825 | "name": "The Milton Berle Show",
826 | "playlists": ["Milton_Berle_47-48"],
827 | "index": 152
828 | },
829 | {
830 | "name": "The Goon Show",
831 | "playlists": ["TheGoonShow1950to1960"],
832 | "index": 153
833 | },
834 | {
835 | "name": "The Spike Jones Show",
836 | "playlists": ["SpikeJones"],
837 | "index": 154
838 | },
839 | {
840 | "name": "Easy Aces",
841 | "playlists": ["otr_easyaces"],
842 | "index": 155
843 | },
844 | {
845 | "name": "The Red Skelton Show",
846 | "playlists": ["red-skelton-show_202008"],
847 | "index": 156
848 | },
849 | {
850 | "name": "Ghost Corps",
851 | "playlists": ["otr_ghostcorps"],
852 | "index": 157
853 | },
854 | {
855 | "name": "Dick Barton, Special Agent",
856 | "playlists": ["otr_dickbartonspecialagent"],
857 | "index": 158
858 | },
859 | {
860 | "name": "21st Precinct",
861 | "playlists": ["OTRR_21st_Precinct_Singles"],
862 | "index": 159
863 | },
864 | {
865 | "name": "Pete Kelly's Blues",
866 | "playlists": ["PeteKellysBlues"],
867 | "index": 160,
868 | "skip": ["64kb"]
869 | },
870 | {
871 | "name": "The Line-Up",
872 | "playlists": ["OTRR_Line_Up_Singles"],
873 | "index": 161
874 | },
875 | {
876 | "name": "Diary of Fate",
877 | "playlists": ["DiaryOfFate"],
878 | "index": 162
879 | },
880 | {
881 | "name": "Ripley's Believe It or Not",
882 | "playlists": ["believe-it-or-not"],
883 | "index": 163
884 | },
885 | {
886 | "name": "Bill Stern Sports Reel",
887 | "playlists": ["Bill_Sterns_Sports_Newsreel"],
888 | "index": 164
889 | },
890 | {
891 | "name": "World Adventurer's Club",
892 | "playlists": ["OTRR_World_Adventurer_Club_Singles"],
893 | "index": 165
894 | }
895 | ],
896 | "channels" : [
897 | {
898 | "name" : "future",
899 | "shows": [0,1,2,3,4,5,6,7,8,9,77]
900 | },
901 | {
902 | "name" : "action",
903 | "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]
904 | },
905 | {
906 | "name" : "crime",
907 | "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]
908 | },
909 | {
910 | "name" : "horror",
911 | "shows": [0,17,43,44,53,54,78,81,98,128,129,162]
912 | },
913 | {
914 | "name" : "suspense",
915 | "shows": [0,19,20,21,38,45,51,56,57,85,105,106,109,113,134,157,158]
916 | },
917 | {
918 | "name" : "western",
919 | "shows": [0,25,26,27,28,29,30,55,93,97,99,145,146,147,148]
920 | },
921 | {
922 | "name" : "comedy",
923 | "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]
924 | },
925 | {
926 | "name" : "drama",
927 | "shows": [0,65,66,67,68,69,70,71,72,73,74,102,139,140,141,142,143,144]
928 | },
929 | {
930 | "name" : "sports",
931 | "shows": [0,103,164]
932 | }
933 | ]
934 | }
--------------------------------------------------------------------------------
/notes.txt:
--------------------------------------------------------------------------------
1 | Tests
2 | -----
3 | To run jasmine tests, from the root of the projet do:
4 | jasmine --config=test/support/jasmine.json
5 |
6 | - When everything on current playlist has been played, the API is queried again and playback continues
7 | - When playing a show the Download as MP3 link is visible
8 | - When not playing a show the Download as MP3 link is not visible
9 | - Stop/start playing resumes at correct point
10 | - Interrupting going to sleep process does not interrupt playback
11 |
12 | Volume Control
13 | - Volume is at max on first visit
14 | - Volume + button is disabled on first visit
15 | - Volume +/- are disabled/enabled correctly
16 | - Volume is saved between page refreshes
17 | - Adjusting volume before playing audio works correctly
18 | - Volume is saved in browser storage and set next time site is opened
19 |
20 | Sleep
21 | - Sleeping message is displayed
22 | - Periodic messages stop
23 | - Volume fades out
24 | - Triggering wake before sleep volume decrease completes, resets volume to pre-sleep level
25 |
26 | Wake
27 | - Volume restored to pre-sleep level
28 | - Select channel message shown
29 | - Periodic messages resume
30 | - Message set to 'Select a Channel'
31 |
32 | Schedule
33 | - Schedule for playing channel is auto-selected when menu is opened
34 | - Schedule is updated automatically if left open
35 |
36 | Station Builder
37 | - When listening to a custom station, schedule should show correct channels
38 |
39 | ---------------------
40 | New backend design
41 |
42 | Inputs ->
43 | data.json
44 | archive.org API / cache
45 |
46 | app.js
47 | /api/shows
48 | /api/channels
49 | /api/channel/:channel
50 | /api/channel/generate/:indexes
51 |
52 | service.js
53 | getShows()
54 | getChannels()
55 | getChannelPlaylist()
56 | getChannelCode()
57 |
58 |
--------------------------------------------------------------------------------
/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 | "proxyquire": "^2.1.3",
24 | "winston": "^3.3.3"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/public/audio_test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Old Time Radio - Audio Test
6 |
7 |
8 |
9 |
10 |
11 |
12 |
33 |
34 |
--------------------------------------------------------------------------------
/public/css/Bellerose.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/c7657e5ee4a94622427b5349c11d575ab58e9b01/public/css/Bellerose.ttf
--------------------------------------------------------------------------------
/public/css/CourierPrime-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/c7657e5ee4a94622427b5349c11d575ab58e9b01/public/css/CourierPrime-Regular.ttf
--------------------------------------------------------------------------------
/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/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/c7657e5ee4a94622427b5349c11d575ab58e9b01/public/favicon.png
--------------------------------------------------------------------------------
/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/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 | }
--------------------------------------------------------------------------------
/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/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/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/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.index === clickedShow.index).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 selectedShowIndexes = model.stationBuilder.shows.filter(show => show.selected).map(show => show.index);
402 | if (model.stationBuilder.includeCommercials) {
403 | selectedShowIndexes.push(...model.stationBuilder.commercialShowIds);
404 | }
405 |
406 | model.stationBuilder.shows.forEach(show => show.selected = false);
407 | view.updateStationBuilderShowSelections(model.stationBuilder);
408 |
409 | service.getChannelCodeForShows(selectedShowIndexes).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 | index: show.index,
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 |
--------------------------------------------------------------------------------
/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/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 | }
--------------------------------------------------------------------------------
/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 = details;
117 | },
118 | stop() {
119 | if (updateTimerId) {
120 | running = false;
121 | clearInterval(updateTimerId);
122 | updateTimerId = 0;
123 | playingNowData = null;
124 | }
125 | }
126 | };
127 | }
--------------------------------------------------------------------------------
/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/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 | }
--------------------------------------------------------------------------------
/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/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 | }
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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/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 | }
--------------------------------------------------------------------------------
/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/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.index = show.index;
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/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 | }
--------------------------------------------------------------------------------
/public/otr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/c7657e5ee4a94622427b5349c11d575ab58e9b01/public/otr.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/c7657e5ee4a94622427b5349c11d575ab58e9b01/public/silence.mp3
--------------------------------------------------------------------------------
/public/swirl_sprites.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebox/old-time-radio/c7657e5ee4a94622427b5349c11d575ab58e9b01/public/swirl_sprites.png
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const config = require('../config.json'),
4 | log = require('./log.js'),
5 | service = require('./service.js'),
6 | { checkConfig } = require('./configChecker.js'),
7 | express = require('express'),
8 | app = express();
9 |
10 | checkConfig(config);
11 |
12 | const port = config.web.port;
13 |
14 | app.use((req, res, next) => {
15 | log.debug(`Request: ${req.method} ${req.path}`);
16 | next();
17 | });
18 |
19 | app.use(express.static(config.web.paths.static));
20 |
21 | app.use('/listen-to', express.static(config.web.paths.static));
22 |
23 | app.get("/listen-to/:show", (req, res) => {
24 | res.sendFile('public/index.html',{root:'./'});
25 | });
26 |
27 | // [{channels:["future"], index: 1, isCommercial: false, name: "X Minus One"}, ...]
28 | app.get(config.web.paths.api.shows, (req, res) => {
29 | service.getShows().then(shows => {
30 | res.status(200).json(shows);
31 | });
32 | });
33 |
34 | // ["future", "action", ...]
35 | app.get(config.web.paths.api.channels, (req, res) => {
36 | service.getChannels().then(channels => {
37 | res.status(200).json(channels);
38 | });
39 | });
40 |
41 | // {initialOffset: 123.456, list: [{archivalUrl: "http://...", length: 1234.56, name: "X Minus One - Episode 079", url: "http://...", commercial: false}, ...]}
42 | app.get(config.web.paths.api.channel + ':channel', (req, res) => {
43 | const channelId = req.params.channel,
44 | length = req.query.length;
45 | service.getScheduleForChannel(channelId, length).then(schedule => {
46 | if (schedule) {
47 | res.status(200).json(schedule);
48 | } else {
49 | res.status(400).send('Unknown channel');
50 | }
51 | });
52 | });
53 |
54 | // "1g0000g000000"
55 | app.get(config.web.paths.api.generate + ":indexes", (req, res) => {
56 | const indexes = req.params.indexes.split(',').map(s => Number(s));
57 | res.status(200).json(service.getCodeForShowIndexes(indexes));
58 | });
59 |
60 | // [{channelId: 'action', initialOffset: 123.456, list: [{archivalUrl: "http://...", length: 1234.56, name: "X Minus One - Episode 079", url: "http://...", commercial: false}, ...]}, ...]
61 | app.get(config.web.paths.api.playingNow, (req, res) => {
62 | const channels = (req.query.channels || '').split(',').filter(c => c);
63 | service.getPlayingNowAndNext(channels).then(result => res.status(200).json(result));
64 | });
65 |
66 | app.get("/sitemap.xml", (req, res) => {
67 | service.getSitemapXml().then(xml => {
68 | res.set('Content-Type', 'text/xml');
69 | res.send(xml);
70 | });
71 | });
72 |
73 | app.use((error, req, res, next) => {
74 | log.error(error.stack);
75 | res.status(500).json({'error':''})
76 | });
77 |
78 | service.init().then(() => {
79 | app.listen(port, () => {
80 | log.info(`Initialisation complete, listening on port ${port}...`);
81 | });
82 | });
83 |
84 |
--------------------------------------------------------------------------------
/src/archiveOrg.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const webClient = require('./webClient.js');
3 |
4 | module.exports = {
5 | /*
6 | Example JSON (only showing data used by the application):
7 | {
8 | "dir": "/32/items/OTRR_Space_Patrol_Singles",
9 | "files": [{
10 | "name": "Space_Patrol_52-10-25_004_The_Hole_in_Empty_Space.mp3",
11 | "length": "1731.12",
12 | "title": "The Hole in Empty Space"
13 | }, {
14 | ...
15 | }],
16 | "metadata": {
17 | "identifier": "OTRR_Space_Patrol_Singles"
18 | }
19 | "server": "ia801306.us.archive.org"
20 | }
21 | */
22 | getPlaylist(id) {
23 | return webClient.get(`https://archive.org/metadata/${id}`);
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/src/cache.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const config = require('../config.json'),
3 | clock = require('./clock.js'),
4 | log = require('./log.js'),
5 | fs = require('fs').promises,
6 | mkdirSync = require('fs').mkdirSync,
7 | path = require('path'),
8 | ENCODING = 'utf-8',
9 | MILLISECONDS_PER_SECOND = 1000;
10 |
11 | function memoize(fn, name) {
12 | const resultCache = buildCache(name, values => fn(...values));
13 | return (...args) => {
14 | return resultCache.get(args);
15 | };
16 | }
17 |
18 | function buildCache(name, source) {
19 | const expiryIntervalSeconds = config.caches.expirySeconds[name] || 0;
20 |
21 | log.info(`Created cache ${name} with expiry interval ${expiryIntervalSeconds} seconds`);
22 |
23 | function hasTsExpired(ts) {
24 | if (expiryIntervalSeconds) {
25 | return clock.now() - ts > expiryIntervalSeconds;
26 | }
27 | return false;
28 | }
29 |
30 | const memory = (() => {
31 | const data = {};
32 |
33 | return {
34 | get(id) {
35 | const entry = data[id];
36 | if (entry) {
37 | if (hasTsExpired(entry.ts)) {
38 | log.debug(`Cache MISS for ${name} (memory) - item [${id}] has expired`);
39 | delete data[id];
40 | return Promise.reject();
41 | }
42 | log.debug(`Cache HIT for ${name} (memory) - item [${id}] found`);
43 | return Promise.resolve(entry.value);
44 | }
45 | log.debug(`Cache MISS for ${name} (memory) - item [${id}] does not exist`);
46 | return Promise.reject();
47 | },
48 | put(id, value) {
49 | const ts = clock.now();
50 | data[id] = {ts, value};
51 | log.debug(`Cache WRITE for ${name} (memory) - item [${id}] stored`);
52 | return Promise.resolve(value);
53 | }
54 | }
55 | })();
56 |
57 | const disk = (() => {
58 | const cacheDir = path.join(config.caches.location, name);
59 | mkdirSync(cacheDir, {recursive: true});
60 |
61 | function getCacheFilePath(id) {
62 | return path.join(cacheDir, `${id}.json`);
63 | }
64 |
65 | return {
66 | get(id) {
67 | const filePath = getCacheFilePath(id);
68 |
69 | return fs.stat(filePath)
70 | .then(stat => {
71 | const modificationTime = stat.mtimeMs / MILLISECONDS_PER_SECOND;
72 | if (hasTsExpired(modificationTime)) {
73 | log.debug(`Cache MISS for ${name} (disk) - item [${id}] has expired`);
74 | return fs.unlink(filePath)
75 | .then(() => Promise.reject());
76 | } else {
77 | return fs.readFile(filePath);
78 | }
79 | })
80 | .catch(() => {
81 | log.debug(`Cache MISS for ${name} (disk) - item [${id}] does not exist`);
82 | return Promise.reject();
83 | })
84 | .then(buffer => {
85 | log.debug(`Cache HIT for ${name} (disk) - item [${id}] found`);
86 | return JSON.parse(buffer.toString());
87 | });
88 | },
89 | put(id, value) {
90 | const filePath = getCacheFilePath(id),
91 | valueAsJson = JSON.stringify(value, null, 4);
92 |
93 | return fs.writeFile(filePath, valueAsJson, {encoding: ENCODING}).then(() => {
94 | log.debug(`Cache WRITE for ${name} (disk) - item [${id}] stored`);
95 | return value;
96 | });
97 | }
98 | };
99 | })();
100 |
101 | return {
102 | get(rawId) {
103 | const id = JSON.stringify(rawId).replace(/[^A-Za-z0-9]/g, '_');
104 | return memory.get(id)
105 | .catch(() => disk.get(id)
106 | .then(value => memory.put(id, value))
107 | .catch(() => Promise.resolve(source(rawId))
108 | .then(value => disk.put(id, value))
109 | .then(value => memory.put(id, value))));
110 | }
111 | };
112 | }
113 |
114 | module.exports = {
115 | memoize,
116 | buildCache
117 | };
--------------------------------------------------------------------------------
/src/channelCodes.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const TOO_BIG_INDEX = 200,
3 | SHOWS_PER_CHAR = 6,
4 | CHAR_MAP = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_';
5 |
6 | function numToString(n) {
7 | console.assert(n<64, n);
8 | return CHAR_MAP.charAt(n);
9 | }
10 |
11 | function stringToNum(s) {
12 | console.assert(s.length === 1, s);
13 | const n = CHAR_MAP.indexOf(s);
14 | if (n < 0) {
15 | throw new Error(`Invalid character in channel code: '${s}'`);
16 | }
17 | return n;
18 | }
19 |
20 | module.exports = {
21 | buildChannelCodeFromShowIndexes(indexes) {
22 | const numericIndexes = indexes.map(Number).filter(n=>!isNaN(n)),
23 | uniqueNumericIndexes = new Set(numericIndexes);
24 |
25 | const maxIndex = numericIndexes.length ? Math.max(...numericIndexes) : 0;
26 | if (maxIndex > TOO_BIG_INDEX) {
27 | throw new Error('Index is too large: ' + maxIndex);
28 | }
29 | const groupTotals = new Array(Math.ceil((maxIndex+1) / SHOWS_PER_CHAR)).fill(0);
30 |
31 | for (let i=0; i <= maxIndex; i++) {
32 | const groupIndex = Math.floor(i / SHOWS_PER_CHAR);
33 | if (uniqueNumericIndexes.has(i)) {
34 | groupTotals[groupIndex] += Math.pow(2, i - groupIndex * SHOWS_PER_CHAR);
35 | }
36 | }
37 | return groupTotals.map(numToString).join('');
38 | },
39 |
40 | buildShowIndexesFromChannelCode(channelCode) {
41 | const indexes = [];
42 | channelCode.split('').forEach((c, charIndex) => {
43 | const num = stringToNum(c);
44 | indexes.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 indexes;
47 | }
48 | }
--------------------------------------------------------------------------------
/src/clock.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const MILLISECONDS_PER_SECOND = 1000;
3 |
4 | module.exports = {
5 | now() {
6 | return this.nowMillis() / MILLISECONDS_PER_SECOND;
7 | },
8 | nowMillis() {
9 | return Date.now();
10 | }
11 | };
12 |
--------------------------------------------------------------------------------
/src/configChecker.js:
--------------------------------------------------------------------------------
1 | const log = require('./log.js'),
2 | LOG_ID = 'configChecker';
3 |
4 | function assertNoRepeatedShowIndexes(shows) {
5 | const showIndexes = new Set();
6 | shows.forEach(show => {
7 | if (showIndexes.has(show.index)) {
8 | log.error(`${LOG_ID}: duplicate show index: ${show.index}`);
9 | }
10 | showIndexes.add(show.index);
11 | });
12 | }
13 |
14 | function logShowsNotOnChannels(shows, channels) {
15 | const showsInChannels = new Set(channels.flatMap(c => c.shows)),
16 | showsNotInChannels = shows.filter(show => !showsInChannels.has(show.index));
17 | log.info(`${LOG_ID} These ${showsNotInChannels.length} shows are not in any channels: ${showsNotInChannels.map(s => s.name).join(', ')}`);
18 | }
19 |
20 | function logShowsInMultipleChannels(shows, channels) {
21 | const channelShows = {};
22 | channels.forEach(channel => {
23 | channelShows[channel.name] = new Set(channel.shows);
24 | });
25 | shows.forEach(show => {
26 | const index = show.index,
27 | channels = Object.keys(channelShows).filter(channelId => channelShows[channelId].has(index));
28 | if (channels.length > 1) {
29 | log.info(`${LOG_ID}: '${show.name}' is in multiple channels: ${channels.join(',')}`);
30 | }
31 | });
32 | }
33 |
34 | module.exports.checkConfig = config => {
35 | assertNoRepeatedShowIndexes(config.shows);
36 | logShowsNotOnChannels(config.shows, config.channels);
37 | logShowsInMultipleChannels(config.shows, config.channels);
38 | }
--------------------------------------------------------------------------------
/src/configHelper.js:
--------------------------------------------------------------------------------
1 | const config = require('../config.json'),
2 | memoize = require('./cache.js').memoize;
3 |
4 | const configHelper = {
5 | getShowForPlaylistId(playlistId) {
6 | return config.shows.find(show => show.playlists.includes(playlistId));
7 | },
8 | getAllPlaylistIds() {
9 | return config.shows.flatMap(show => show.playlists)
10 | },
11 | getChannelNamesForShowIndex(showIndex) {
12 | return config.channels.filter(channel => channel.shows.includes(showIndex)).map(channel => channel.name);
13 | },
14 | /*
15 | [
16 | {
17 | "channels" : ["future"],
18 | "index": 3,
19 | "isCommercial": false,
20 | "name": "Space Patrol",
21 | "playlists": ["OTRR_Space_Patrol_Singles"]
22 | }, {
23 | ...
24 | }
25 | ]
26 | */
27 | getShows: memoize(() => {
28 | return config.shows.map(show => {
29 | return {
30 | channels: configHelper.getChannelNamesForShowIndex(show.index),
31 | index: show.index,
32 | isCommercial: !! show.isCommercial,
33 | name: show.name,
34 | shortName: show.shortName || show.name,
35 | playlists: show.playlists
36 | };
37 | });
38 | }, "shows"),
39 |
40 | /*
41 | ["future", "action", ... ]
42 | */
43 | getChannels: memoize(() => {
44 | return config.channels.map(channel => channel.name);
45 | }, "channels")
46 | };
47 |
48 | module.exports = {configHelper};
--------------------------------------------------------------------------------
/src/log.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const winston = require('winston'),
3 | config = require('../config.json');
4 |
5 | const transports = [],
6 | format = winston.format.combine(
7 | winston.format.timestamp(),
8 | winston.format.printf(info => {
9 | return `${info.timestamp} ${info.level.toUpperCase().padStart(5, ' ')}: ${info.message}`;
10 | })
11 | );
12 | try {
13 | transports.push(new (winston.transports.File)({
14 | level: config.log.level,
15 | filename: config.log.file,
16 | format
17 | }))
18 | } catch (e) {
19 | transports.push(new (winston.transports.Console)({
20 | level: config.log.level,
21 | format
22 | }))
23 | }
24 |
25 | winston.configure({
26 | transports
27 | });
28 |
29 | module.exports = {
30 | debug(...params) {
31 | winston.log('debug', ...params);
32 | },
33 | info(...params) {
34 | winston.log('info', ...params);
35 | },
36 | warn(...params) {
37 | winston.log('warning', ...params);
38 | },
39 | error(...params) {
40 | winston.log('error', ...params);
41 | }
42 | };
--------------------------------------------------------------------------------
/src/playlistData.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const archiveOrg = require('./archiveOrg.js'),
4 | log = require('./log.js'),
5 | {buildNameParser} = require('./nameParser.js'),
6 | {configHelper} = require('./configHelper.js'),
7 | LOG_ID = 'playlistData';
8 |
9 | const playlistsById = {},
10 | skippedShows = new Set();
11 |
12 | function isPartOfSkipListForShow(fileName, playlistId){
13 | return (configHelper.getShowForPlaylistId(playlistId).skip || []).some(skipPattern => fileName.includes(skipPattern));
14 | }
15 | /*
16 | Shows have one or more 'playlists' associated with them (see config file) - each playlist comprises a list episodes
17 | including mp3 file urls.
18 | */
19 | function extractUsefulPlaylistData(playlistId, playlist, nameParser) {
20 | return playlist.files
21 | .filter(f => f.name.toLowerCase().endsWith('.mp3'))
22 | .filter(f => {
23 | const isOnSkipList = isPartOfSkipListForShow(f.name, playlistId);
24 | if (isOnSkipList) {
25 | skippedShows.add(`${playlistId} ${f.name}`);
26 | log.debug(`${LOG_ID}: skipping ${f.name} for ${playlistId}`);
27 | }
28 | return ! isOnSkipList;
29 | })
30 | .filter(f => f.length)
31 | .map(fileMetadata => {
32 | const readableName = nameParser.parse(playlistId, fileMetadata);
33 |
34 | let length;
35 | if (fileMetadata.length.match(/^[0-9]+:[0-9]+$/)) {
36 | const [min, sec] = fileMetadata.length.split(':')
37 | length = Number(min) * 60 + Number(sec);
38 | } else {
39 | length = Number(fileMetadata.length);
40 | }
41 |
42 | /* Sometimes archive.org returns an mp3 without adding the 'Access-Control-Allow-Origin: *' header in to the response
43 | * so we provide a list of possible urls to the client and it will try them one at a time until one of them works */
44 | const encodedFileName = encodeURIComponent(fileMetadata.name),
45 | archivalUrl = `https://archive.org/download/${playlistId}/${encodedFileName}`,
46 | urls = [
47 | archivalUrl,
48 | `https://${playlist.server}${playlist.dir}/${encodedFileName}`,
49 | `https://${playlist.d1}${playlist.dir}/${encodedFileName}`,
50 | `https://${playlist.d2}${playlist.dir}/${encodedFileName}`,
51 | ];
52 |
53 | return {
54 | name: readableName,
55 | urls,
56 | archivalUrl,
57 | length
58 | };
59 | });
60 | }
61 |
62 | module.exports = {
63 | init() {
64 | const allPlaylistIds = configHelper.getAllPlaylistIds(),
65 | allPlaylistDataPromises = allPlaylistIds.map(playlistId => archiveOrg.getPlaylist(playlistId)),
66 | nameParser = buildNameParser();
67 |
68 | return Promise.all(allPlaylistDataPromises).then(allPlaylistData => {
69 | allPlaylistData
70 | .filter(playlistData => !playlistData.is_dark)
71 | .filter(playlistData => playlistData.metadata)
72 | .forEach(playlistData => {
73 | const id = playlistData.metadata.identifier,
74 | usefulPlaylistData = extractUsefulPlaylistData(id, playlistData, nameParser);
75 | playlistsById[id] = usefulPlaylistData;
76 | });
77 | nameParser.logStats();
78 | }).then(() => {
79 | log.info(`${LOG_ID}: Skipped ${skippedShows.size} files`);
80 | });
81 | },
82 | // [{archivalUrl: "http://...", length: 1234.56, name: "X Minus One - Episode 079", url: "http://...", commercial: false}, ...]
83 | getPlaylist(id) {
84 | return playlistsById[id] || [];
85 | }
86 | };
87 |
--------------------------------------------------------------------------------
/src/scheduler.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const {configHelper} = require('./configHelper.js'),
3 | playlistData = require('./playlistData.js'),
4 | channelCodes = require('./channelCodes'),
5 | clock = require('./clock.js'),
6 | config = require('../config.json'),
7 | log = require('./log.js'),
8 | memoize = require('./cache.js').memoize,
9 | START_TIME = 1595199600, // 2020-07-20 00:00:00
10 | MAX_SCHEDULE_LENGTH = 24 * 60 * 60;
11 |
12 | const getFullScheduleForChannel = memoize(async channelNameOrCode => { //TODO limit how many we store in memory
13 | async function getShowListForChannel(channelNameOrCode) {
14 | const allShows = await configHelper.getShows(),
15 | showsForPredefinedChannel = allShows.filter(show => show.channels.includes(channelNameOrCode));
16 |
17 | if (showsForPredefinedChannel.length) {
18 | return showsForPredefinedChannel;
19 |
20 | } else {
21 | const showIndexes = channelCodes.buildShowIndexesFromChannelCode(channelNameOrCode);
22 | return allShows.filter(show => showIndexes.includes(show.index));
23 | }
24 | }
25 |
26 | function balanceFileCounts(unbalancedShowsToFiles) {
27 | const fileCounts = {}, showsToFiles = {};
28 | Object.keys(unbalancedShowsToFiles).forEach(showName => {
29 | fileCounts[showName] = unbalancedShowsToFiles[showName].length;
30 | });
31 |
32 | const maxFileCount = Math.max(...Object.values(fileCounts)),
33 | radical = Math.max(config.scheduler.radical, 1);
34 |
35 | function getCopyCount(count) {
36 | /*
37 | The 'config.scheduler.radical' value is used to determine how much to boost shows with only a small number of episodes within a channel schedule. Setting the value close to 0 will cause a schedule to contain roughly equal numbers of episodes from each show, and consequently lots of repeated episodes from shows with low episode counts. Setting a higher value (eg 3 or 4) will result in far fewer repeats, but at the cost of having shows with many episodes dominate the schedule, perhaps causing multiple episodes from the same show to be played consecutively.
38 | */
39 | return Math.round(Math.pow(maxFileCount/count, 1 / radical));
40 | }
41 |
42 | Object.keys(unbalancedShowsToFiles).forEach(showName => {
43 | const fileCount = fileCounts[showName],
44 | filesForShow = unbalancedShowsToFiles[showName];
45 | let copyCount = getCopyCount(fileCount);
46 | showsToFiles[showName] = [];
47 |
48 | while(copyCount > 0) {
49 | showsToFiles[showName].push(...filesForShow);
50 | copyCount--;
51 | }
52 | });
53 |
54 | return showsToFiles;
55 | }
56 |
57 | function getFullScheduleFromShowList(showListForChannel) {
58 | const unbalancedShowsToFiles = {}, commercials = [],
59 | isOnlyCommercials = showListForChannel.every(show => show.isCommercial);
60 |
61 | showListForChannel.forEach(show => {
62 | const files = show.playlists.flatMap(playlistName => playlistData.getPlaylist(playlistName)).flatMap(file => {
63 | if (show.isCommercial) {
64 | file.commercial = true;
65 | }
66 | return file;
67 | });
68 |
69 | if (show.isCommercial && !isOnlyCommercials) {
70 | commercials.push(...files);
71 | } else {
72 | unbalancedShowsToFiles[show.shortName] = files;
73 | }
74 | });
75 |
76 | const showsToFiles = balanceFileCounts(unbalancedShowsToFiles);
77 | Object.keys(showsToFiles).forEach(showName => {
78 | log.debug(`${channelNameOrCode}: ${showName} [${showsToFiles[showName].length/unbalancedShowsToFiles[showName].length}x] ${unbalancedShowsToFiles[showName].length}/${showsToFiles[showName].length}`)
79 | });
80 |
81 | const originalFileCounts = {};
82 | Object.keys(showsToFiles).forEach(showName => {
83 | originalFileCounts[showName] = showsToFiles[showName].length;
84 | });
85 |
86 | const schedule = [],
87 | hasCommercials = !! commercials.length,
88 | nextCommercial = (() => {
89 | let nextIndex = 0;
90 | return () => {
91 | const commercial = commercials[nextIndex];
92 | nextIndex = (nextIndex + 1) % commercials.length;
93 | return commercial;
94 | };
95 | })();
96 |
97 | while (true) {
98 | let largestFractionToRemain = -1, listToReduce = [], showNameToReduce;
99 |
100 | Object.entries(showsToFiles).forEach(entry => {
101 | const [showName, files] = entry,
102 | originalFileCount = originalFileCounts[showName],
103 | fractionToRemain = (files.length - 1) / originalFileCount;
104 |
105 | if (fractionToRemain > largestFractionToRemain) {
106 | largestFractionToRemain = fractionToRemain;
107 | listToReduce = files;
108 | showNameToReduce = showName;
109 | }
110 | });
111 |
112 | if (listToReduce.length) {
113 | schedule.push({...listToReduce.shift(), showName: showNameToReduce});
114 | if (hasCommercials) {
115 | schedule.push({...nextCommercial(), showName: "Commercial"});
116 | }
117 |
118 | } else {
119 | break;
120 | }
121 | }
122 | const scheduleLength = schedule.reduce((total, item) => item.length + total, 0);
123 | return {schedule, length: scheduleLength};
124 | }
125 |
126 | log.info(`Calculating schedule for channel [${channelNameOrCode}]`);
127 | const showListForChannel = await getShowListForChannel(channelNameOrCode);
128 |
129 | return getFullScheduleFromShowList(showListForChannel);
130 | }, "channelFullSchedule");
131 |
132 | function getCurrentPlaylistPosition(playlist, playlistDuration) {
133 | const offsetSinceStartOfPlay = (clock.now() - START_TIME) % playlistDuration;
134 | let i = 0, playlistItemOffset = 0;
135 |
136 | let initialOffset;
137 | while (true) {
138 | const playlistItem = playlist[i % playlist.length],
139 | itemIsPlayingNow = playlistItemOffset + playlistItem.length > offsetSinceStartOfPlay;
140 |
141 | if (itemIsPlayingNow) {
142 | initialOffset = offsetSinceStartOfPlay - playlistItemOffset;
143 | break;
144 | }
145 | playlistItemOffset += playlistItem.length;
146 | i++;
147 | //TODO safety value here
148 | }
149 |
150 | return {
151 | index: i % playlist.length,
152 | offset: initialOffset
153 | };
154 | }
155 |
156 | function getCurrentSchedule(fullSchedule, stopCondition) {
157 | const episodeList = fullSchedule.schedule,
158 | playlistLength = fullSchedule.length,
159 | clientPlaylist = [];
160 |
161 | let {index, offset} = getCurrentPlaylistPosition(episodeList, playlistLength);
162 |
163 | let currentPlaylistDuration = -offset;
164 | while (!stopCondition(currentPlaylistDuration, clientPlaylist.length)) {
165 | const currentItem = episodeList[index % episodeList.length];
166 | clientPlaylist.push(currentItem);
167 | currentPlaylistDuration += currentItem.length;
168 | index++;
169 | }
170 |
171 | return {
172 | list: clientPlaylist,
173 | initialOffset: offset
174 | };
175 | }
176 |
177 | function playlistReachedMinDuration(minDuration) {
178 | return (currentPlaylistDuration) => {
179 | return currentPlaylistDuration >= minDuration;
180 | };
181 | }
182 |
183 | function playlistContainsRequiredNumberOfItems(numberOfItems) {
184 | return (_, currentPlaylistSize) => {
185 | return currentPlaylistSize >= numberOfItems;
186 | };
187 | }
188 |
189 | module.exports = {
190 | async getScheduleForChannel(channelNameOrCode, lengthInSeconds) {
191 | const fullSchedule = await getFullScheduleForChannel(channelNameOrCode),
192 | currentSchedule = getCurrentSchedule(fullSchedule, playlistReachedMinDuration(Math.min(lengthInSeconds, MAX_SCHEDULE_LENGTH)));
193 |
194 | return currentSchedule;
195 | },
196 | async getPlayingNowAndNext(channelNameOrCode) {
197 | const fullSchedule = await getFullScheduleForChannel(channelNameOrCode),
198 | // min length of '3' guarantees we get the current show and the next show even if there are commercials playing between them
199 | currentSchedule = getCurrentSchedule(fullSchedule, playlistContainsRequiredNumberOfItems(3));
200 |
201 | return {
202 | channelId: channelNameOrCode,
203 | list: currentSchedule.list.filter(item => !item.commercial).slice(0, 2),
204 | initialOffset: currentSchedule.initialOffset
205 | };
206 | }
207 | }
208 |
--------------------------------------------------------------------------------
/src/service.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const {configHelper} = require('./configHelper.js'),
3 | channelCodes = require('./channelCodes'),
4 | scheduler = require('./scheduler.js'),
5 | playlistData = require('./playlistData.js'),
6 | sitemap = require('./sitemap.js'),
7 | ONE_HOUR = 60 * 60;
8 |
9 | module.exports = {
10 | init() {
11 | return playlistData.init();
12 | },
13 | async getShows() {
14 | return (await configHelper.getShows()).map(show => {
15 | const channelCode = channelCodes.buildChannelCodeFromShowIndexes([show.index]);
16 | return {
17 | channels: show.channels,
18 | index: show.index,
19 | isCommercial: show.isCommercial,
20 | name: show.name,
21 | shortName: show.shortName,
22 | descriptiveId: show.name.toLowerCase().replace(/ /g, '-').replace(/-+/g, '-').replace(/[^a-zA-Z0-9-]/g, ''),
23 | channelCode
24 | };
25 | });
26 | },
27 | async getChannels() {
28 | return await configHelper.getChannels();
29 | },
30 | async getScheduleForChannel(channelId, length = ONE_HOUR) {
31 | return await scheduler.getScheduleForChannel(channelId, length);
32 | },
33 | async getSitemapXml() {
34 | return this.getShows().then(shows => sitemap.getSitemapXml(shows));
35 | },
36 | getCodeForShowIndexes(showIndexes = []) {
37 | return channelCodes.buildChannelCodeFromShowIndexes(showIndexes);
38 | },
39 | async getPlayingNowAndNext(channels) {
40 | const channelIds = channels.length ? channels : await configHelper.getChannels();
41 | return Promise.all(channelIds.map(channelId => scheduler.getPlayingNowAndNext(channelId)));
42 | }
43 | };
--------------------------------------------------------------------------------
/src/sitemap.js:
--------------------------------------------------------------------------------
1 | const config = require('../config.json');
2 |
3 | module.exports.getSitemapXml = shows => {
4 | "use strict";
5 | const urlPrefix = config.web.paths.listenTo,
6 | urlElements = shows
7 | .map(show => show.descriptiveId)
8 | .map(id => `${urlPrefix}${id}`);
9 |
10 | return [
11 | '',
12 | ...urlElements,
13 | ''
14 | ].join('');
15 | };
--------------------------------------------------------------------------------
/src/webClient.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const axios = require('axios'),
3 | log = require('./log.js'),
4 | cacheBuilder = require('./cache.js'),
5 | config = require("../config.json"),
6 | clock = require("./clock");
7 |
8 | const requestQueue = (() => {
9 | const pendingRequests = [];
10 | let lastRequestMillis = 0, running = false, interval;
11 |
12 | function ensureRequestProcessorIsRunning(){
13 | if (!running) {
14 | log.debug('Starting request processor');
15 | running = true;
16 |
17 | function processNext() {
18 | const nextRequestPermittedTs = lastRequestMillis + config.minRequestIntervalMillis,
19 | timeUntilNextRequestPermitted = Math.max(0, nextRequestPermittedTs - clock.nowMillis());
20 | setTimeout(() => {
21 | const {url, resolve, reject} = pendingRequests.shift();
22 | log.debug(`Requesting ${url}...`);
23 | axios.get(url)
24 | .then(response => {
25 | log.debug(`Request for ${url} succeeded: ${response.status} - ${response.statusText}`);
26 | resolve(response.data)
27 | })
28 | .catch(response => {
29 | log.error(`Request for ${url} failed: ${response.status} - ${response.statusText}`);
30 | reject(response);
31 | })
32 | .finally(() => {
33 | lastRequestMillis = clock.nowMillis();
34 | if (pendingRequests.length === 0) {
35 | log.debug('Request queue is empty, shutting down processor');
36 | running = false;
37 | } else {
38 | processNext();
39 | }
40 | });
41 | }, timeUntilNextRequestPermitted);
42 | }
43 |
44 | processNext();
45 | }
46 | }
47 |
48 | return {
49 | push(url) {
50 | return new Promise((resolve, reject) => {
51 | pendingRequests.push({url, resolve, reject});
52 | ensureRequestProcessorIsRunning();
53 | });
54 | }
55 | };
56 | })();
57 |
58 | const cache = cacheBuilder.buildCache('web', url => {
59 | log.info(`Queueing request for ${url}`);
60 | return requestQueue.push(url);
61 | });
62 |
63 | module.exports = {
64 | get(url) {
65 | return cache.get(url);
66 | }
67 | };
68 |
--------------------------------------------------------------------------------
/test/cache-test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const cacheBuilder = require('../src/cache.js'),
3 | config = require('../config.json'),
4 | fs = require('fs').promises;
5 |
6 | describe("cache", () => {
7 | let sourceIds;
8 | function source(id) {
9 | sourceIds.push(id);
10 | return Promise.resolve(`result=${id}`);
11 | }
12 |
13 | beforeEach(async () => {
14 | await fs.rmdir('cache/test', {recursive: true})
15 | sourceIds = [];
16 | });
17 |
18 | describe("with no expiry time", () => {
19 | let cache;
20 |
21 | beforeEach(() => {
22 | cache = cacheBuilder.buildCache("test", source);
23 | });
24 |
25 | it("source only called once per id", async () => {
26 | expect(sourceIds).toEqual([]);
27 |
28 | expect(await cache.get('a')).toBe('result=a');
29 | expect(sourceIds).toEqual(['a']);
30 |
31 | expect(await cache.get('a')).toBe('result=a');
32 | expect(sourceIds).toEqual(['a']);
33 |
34 | expect(await cache.get('b')).toBe('result=b');
35 | expect(sourceIds).toEqual(['a','b']);
36 |
37 | expect(await cache.get('a')).toBe('result=a');
38 | expect(sourceIds).toEqual(['a', 'b']);
39 |
40 | expect(await cache.get('b')).toBe('result=b');
41 | expect(sourceIds).toEqual(['a', 'b']);
42 | });
43 | });
44 |
45 | describe("items expire correctly", () => {
46 | let cache;
47 |
48 | beforeEach(() => {
49 | config.caches.expirySeconds.test = 2;
50 | cache = cacheBuilder.buildCache("test", source);
51 | });
52 |
53 | it("source only called once per id", async done => {
54 | expect(sourceIds).toEqual([]);
55 |
56 | expect(await cache.get('a')).toBe('result=a');
57 | expect(sourceIds).toEqual(['a']);
58 |
59 | expect(await cache.get('a')).toBe('result=a');
60 | expect(sourceIds).toEqual(['a']);
61 |
62 | setTimeout(async () => {
63 | expect(await cache.get('a')).toBe('result=a');
64 | expect(sourceIds).toEqual(['a', 'a']);
65 | done();
66 | }, 3000);
67 | });
68 | });
69 |
70 | describe("can memoize function", () => {
71 | let callCounter;
72 |
73 | function add(...nums) {
74 | callCounter += 1;
75 | return Promise.resolve(nums.reduce((a,b) => a + b, 0));
76 | }
77 |
78 | beforeEach(() => {
79 | callCounter = 0;
80 | });
81 |
82 | afterEach(async () => {
83 | await fs.rmdir('cache/no_params', {recursive: true});
84 | await fs.rmdir('cache/with_params', {recursive: true});
85 | });
86 |
87 | it("with no parameters", async () => {
88 | const addMemo = cacheBuilder.memoize(add, "no_params");
89 | expect(callCounter).toBe(0);
90 | expect(await addMemo()).toEqual(0);
91 | expect(callCounter).toBe(1);
92 | expect(await addMemo()).toEqual(0);
93 | expect(callCounter).toBe(1);
94 | });
95 |
96 | it("with parameters", async () => {
97 | const addMemo = cacheBuilder.memoize(add, "with_params");
98 |
99 | expect(callCounter).toBe(0);
100 | expect(await addMemo(1)).toEqual(1);
101 | expect(callCounter).toBe(1);
102 | expect(await addMemo(2)).toEqual(2);
103 | expect(callCounter).toBe(2);
104 | expect(await addMemo(1)).toEqual(1);
105 | expect(callCounter).toBe(2);
106 | expect(await addMemo(2)).toEqual(2);
107 | expect(callCounter).toBe(2);
108 | expect(await addMemo(1,2,3)).toEqual(6);
109 | expect(callCounter).toBe(3);
110 | expect(await addMemo(1,2,3)).toEqual(6);
111 | expect(callCounter).toBe(3);
112 | expect(await addMemo(5,6)).toEqual(11);
113 | expect(callCounter).toBe(4);
114 | });
115 | });
116 | });
117 |
--------------------------------------------------------------------------------
/test/channelCodes-test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const channelCodes = require('../src/channelCodes.js');
3 |
4 | describe("channelCodes", () => {
5 | describe("buildChannelCodeFromShowIndexes", () => {
6 | function expectIndexes(indexes) {
7 | return {
8 | toGiveCode(expectedCode) {
9 | expect(channelCodes.buildChannelCodeFromShowIndexes(indexes)).toBe(expectedCode);
10 | },
11 | toThrowError(msg) {
12 | expect(() => channelCodes.buildChannelCodeFromShowIndexes(indexes)).toThrowError(msg);
13 | }
14 | };
15 | }
16 |
17 | it("gives correct codes for valid indexes", () => {
18 | expectIndexes([]).toGiveCode('0');
19 | expectIndexes([0]).toGiveCode('1');
20 | expectIndexes([1]).toGiveCode('2');
21 | expectIndexes([0,1]).toGiveCode('3');
22 | expectIndexes([1,0]).toGiveCode('3');
23 | expectIndexes([0,1,0,0,1]).toGiveCode('3');
24 | expectIndexes([0,1,2,3,4,5]).toGiveCode('_');
25 | expectIndexes([0,1,2,3,4,5,6]).toGiveCode('_1');
26 | expectIndexes([200]).toGiveCode('0000000000000000000000000000000004');
27 | expectIndexes([0,10,100,200]).toGiveCode('1g00000000000000g00000000000000004');
28 | });
29 |
30 | it("ignores invalid values", () => {
31 | expectIndexes(['not a number']).toGiveCode('0');
32 | expectIndexes([true, false, -Infinity, undefined, 0, '', 1, 'x', null, NaN]).toGiveCode('3');
33 | });
34 |
35 | it("throws error if index is too large", () => {
36 | expectIndexes([201]).toThrowError('Index is too large: 201');
37 | expectIndexes([Infinity]).toThrowError('Index is too large: Infinity');
38 | });
39 | });
40 |
41 | describe("buildShowIndexesFromChannelCode", () => {
42 | function expectCode(code) {
43 | return {
44 | toGiveIndexes(expectedIndexes) {
45 | expect(channelCodes.buildShowIndexesFromChannelCode(code)).toEqual(expectedIndexes);
46 | },
47 | toThrowError(msg) {
48 | expect(() => channelCodes.buildShowIndexesFromChannelCode(code)).toThrowError(msg);
49 | }
50 | };
51 | }
52 |
53 | it("gives correct indexes for valid codes", () => {
54 | expectCode('').toGiveIndexes([]);
55 | expectCode('0').toGiveIndexes([]);
56 | expectCode('1').toGiveIndexes([0]);
57 | expectCode('2').toGiveIndexes([1]);
58 | expectCode('3').toGiveIndexes([0,1]);
59 | expectCode('_').toGiveIndexes([0,1,2,3,4,5]);
60 | expectCode('_1').toGiveIndexes([0,1,2,3,4,5,6]);
61 | expectCode('0000000000000000000000000000000004').toGiveIndexes([200]);
62 | expectCode('1g00000000000000g00000000000000004').toGiveIndexes([0,10,100,200]);
63 | });
64 |
65 | it("throws error if code contains invalid values", () => {
66 | expectCode('1%2').toThrowError("Invalid character in channel code: '%'");
67 | expectCode(' 12').toThrowError("Invalid character in channel code: ' '");
68 | expectCode(' ').toThrowError("Invalid character in channel code: ' '");
69 | });
70 |
71 | });
72 |
73 | });
74 |
--------------------------------------------------------------------------------
/test/scheduler-test.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | let shows, playlists, showIndexes, timeNow;
4 |
5 | const proxyquire = require('proxyquire'),
6 | scheduler = proxyquire('../src/scheduler.js', {
7 | './configHelper.js': {
8 | getShows(){
9 | return shows;
10 | }
11 | },
12 | './cache.js': {
13 | memoize(fn){
14 | return fn;
15 | }
16 | },
17 | './playlistData.js': {
18 | getPlaylist(name) {
19 | return playlists[name];
20 | }
21 | },
22 | './channelCodes.js': {
23 | buildShowIndexesFromChannelCode(channelCodeOrName) {
24 | return showIndexes[channelCodeOrName];
25 | }
26 | },
27 | './clock.js': {
28 | now() {
29 | return timeNow;
30 | }
31 | }
32 | });
33 |
34 | describe("schedule", () => {
35 | let schedule;
36 |
37 | beforeEach(() => {
38 | shows = [];
39 | playlists = {};
40 | showIndexes = {};
41 | timeNow = 1595199600; // 2020-07-20 00:00:00
42 | });
43 |
44 | function givenAShow(name, channels, index, playlists, isCommercial=false) {
45 | shows.push({channels, index, name, playlists, isCommercial});
46 | }
47 |
48 | function givenAPlaylist(name, server, dir, files) { // files: [{name, length}]
49 | playlists[name] = files.map(f => {
50 | const url = `https://${server}/${dir}/${f.name}`;
51 | return {
52 | archivalUrl: url + '/archive',
53 | length: f.length,
54 | commercial: false,
55 | name: f.name,
56 | url
57 | };
58 | });
59 | }
60 |
61 | function givenTimeOffsetIs(offset) {
62 | timeNow += offset;
63 | }
64 |
65 | function thenScheduleUrlsAre(...expectedUrls) {
66 | expect(schedule.list.map(o => o.url)).toEqual(expectedUrls);
67 | }
68 |
69 | function thenScheduleOffsetIs(expectedOffset) {
70 | expect(schedule.initialOffset).toBe(expectedOffset);
71 | }
72 |
73 | describe("getScheduleForChannel", () => {
74 | beforeEach(() => {
75 | givenAPlaylist('playlist1', 'server1', 'dir1', [
76 | {name: 'p1_1', length: 30 * 60},
77 | {name: 'p1_2', length: 10 * 60},
78 | {name: 'p1_3', length: 20 * 60}
79 | ]);
80 | givenAPlaylist('playlist2', 'server2', 'dir2', [
81 | {name: 'p2_1', length: 5 * 60},
82 | {name: 'p2_2', length: 5 * 60},
83 | {name: 'p2_3', length: 5 * 60},
84 | {name: 'p2_4', length: 5 * 60}
85 | ]);
86 | givenAPlaylist('playlist3', 'server3', 'dir3', [
87 | {name: 'p3_1', length: 60 * 60},
88 | {name: 'p3_2', length: 1 * 60},
89 | {name: 'p3_3', length: 60 * 60},
90 | {name: 'p3_4', length: 1 * 60}
91 | ]);
92 | givenAPlaylist('playlist4', 'server4', 'dir4', [
93 | {name: 'p4_1', length: 1 * 60},
94 | {name: 'p4_2', length: 2 * 60},
95 | {name: 'p4_3', length: 3 * 60},
96 | {name: 'p4_4', length: 4 * 60},
97 | {name: 'p4_5', length: 5 * 60},
98 | {name: 'p4_6', length: 6 * 60}
99 | ]);
100 | givenAPlaylist('playlist5', 'server5', 'dir5', [
101 | {name: 'p5_1', length: 30 * 60},
102 | {name: 'p5_2', length: 30 * 60},
103 | {name: 'p5_3', length: 30 * 60}
104 | ]);
105 | givenAPlaylist('playlist6', 'server6', 'dir6', [
106 | {name: 'p6_1', length: 20 * 60},
107 | {name: 'p6_2', length: 25 * 60},
108 | {name: 'p6_3', length: 35 * 60}
109 | ]);
110 | });
111 |
112 | describe("Single Show", () => {
113 | it("Zero offset, duration exactly matches playlist length", async () => {
114 | givenAShow('show1', ['channel1'], 0, ['playlist1']);
115 | givenTimeOffsetIs(0);
116 |
117 | schedule = await scheduler.getScheduleForChannel('channel1', 60 * 60);
118 |
119 | thenScheduleUrlsAre('https://server1/dir1/p1_1', 'https://server1/dir1/p1_2', 'https://server1/dir1/p1_3');
120 | thenScheduleOffsetIs(0);
121 | });
122 |
123 | it("Zero offset, duration shorter than playlist length", async () => {
124 | givenAShow('show1', ['channel1'], 0, ['playlist1']);
125 | givenTimeOffsetIs(0);
126 |
127 | schedule = await scheduler.getScheduleForChannel('channel1', 50 * 60);
128 |
129 | thenScheduleUrlsAre('https://server1/dir1/p1_1', 'https://server1/dir1/p1_2', 'https://server1/dir1/p1_3');
130 | thenScheduleOffsetIs(0);
131 | });
132 |
133 | it("Zero offset, duration longer than playlist length", async () => {
134 | givenAShow('show1', ['channel1'], 0, ['playlist1']);
135 | givenTimeOffsetIs(0);
136 |
137 | schedule = await scheduler.getScheduleForChannel('channel1', 70 * 60);
138 |
139 | thenScheduleUrlsAre('https://server1/dir1/p1_1', 'https://server1/dir1/p1_2', 'https://server1/dir1/p1_3', 'https://server1/dir1/p1_1');
140 | thenScheduleOffsetIs(0);
141 | });
142 |
143 | it("Offset within first show", async () => {
144 | givenAShow('show1', ['channel1'], 0, ['playlist1']);
145 | givenTimeOffsetIs(20 * 60);
146 |
147 | schedule = await scheduler.getScheduleForChannel('channel1', 60 * 60);
148 |
149 | thenScheduleUrlsAre('https://server1/dir1/p1_1', 'https://server1/dir1/p1_2', 'https://server1/dir1/p1_3', 'https://server1/dir1/p1_1');
150 | thenScheduleOffsetIs(20 * 60);
151 | });
152 |
153 | it("Offset within later show", async () => {
154 | givenAShow('show1', ['channel1'], 0, ['playlist1']);
155 | givenTimeOffsetIs(50 * 60);
156 |
157 | schedule = await scheduler.getScheduleForChannel('channel1', 60 * 60);
158 |
159 | thenScheduleUrlsAre('https://server1/dir1/p1_3', 'https://server1/dir1/p1_1', 'https://server1/dir1/p1_2', 'https://server1/dir1/p1_3');
160 | thenScheduleOffsetIs(10 * 60);
161 | });
162 |
163 | it("Offset wraps whole playlist", async () => {
164 | givenAShow('show1', ['channel1'], 0, ['playlist1']);
165 | givenTimeOffsetIs(60 * 60 + 20 * 60);
166 |
167 | schedule = await scheduler.getScheduleForChannel('channel1', 60 * 60);
168 |
169 | thenScheduleUrlsAre('https://server1/dir1/p1_1', 'https://server1/dir1/p1_2', 'https://server1/dir1/p1_3', 'https://server1/dir1/p1_1');
170 | thenScheduleOffsetIs(20 * 60);
171 | });
172 | });
173 |
174 | describe("Multiple Shows", () => {
175 | it("interleaved correctly", async () => {
176 | givenAShow('show1', ['channel1'], 0, ['playlist1', 'playlist2']); // 7 items, 80 mins
177 | givenAShow('show2', ['channel1'], 1, ['playlist3']); // 4 items, 122 mins
178 |
179 | schedule = await scheduler.getScheduleForChannel('channel1', (80 + 122) * 60);
180 |
181 | thenScheduleUrlsAre(
182 | 'https://server1/dir1/p1_1',
183 | 'https://server3/dir3/p3_1',
184 | 'https://server1/dir1/p1_2',
185 | 'https://server1/dir1/p1_3',
186 | 'https://server3/dir3/p3_2',
187 | 'https://server2/dir2/p2_1',
188 | 'https://server2/dir2/p2_2',
189 | 'https://server3/dir3/p3_3',
190 | 'https://server2/dir2/p2_3',
191 | 'https://server2/dir2/p2_4',
192 | 'https://server3/dir3/p3_4');
193 | thenScheduleOffsetIs(0);
194 | });
195 |
196 | it("commercials added correctly", async () => {
197 | givenAShow('show1', ['channel1'], 0, ['playlist1', 'playlist2']);
198 | givenAShow('show2', ['channel1'], 1, ['playlist3']);
199 | givenAShow('show3', ['channel1'], 2, ['playlist4'], true);
200 |
201 | schedule = await scheduler.getScheduleForChannel('channel1', (80 + 122) * 60);
202 |
203 | thenScheduleUrlsAre(
204 | 'https://server1/dir1/p1_1',
205 | 'https://server4/dir4/p4_1',
206 | 'https://server3/dir3/p3_1',
207 | 'https://server4/dir4/p4_2',
208 | 'https://server1/dir1/p1_2',
209 | 'https://server4/dir4/p4_3',
210 | 'https://server1/dir1/p1_3',
211 | 'https://server4/dir4/p4_4',
212 | 'https://server3/dir3/p3_2',
213 | 'https://server4/dir4/p4_5',
214 | 'https://server2/dir2/p2_1',
215 | 'https://server4/dir4/p4_6',
216 | 'https://server2/dir2/p2_2',
217 | 'https://server4/dir4/p4_1',
218 | 'https://server3/dir3/p3_3')
219 | thenScheduleOffsetIs(0);
220 | });
221 |
222 | fit("show balanced correctly", async () => {//1-3 2-4 3-4 4-6 5-3 6-3
223 | givenAShow('show1', ['channel1'], 0, ['playlist1', 'playlist2', 'playlist3', 'playlist4']); // 17 items
224 | givenAShow('show2', ['channel1'], 1, ['playlist5']); // 3 items
225 |
226 | schedule = await scheduler.getScheduleForChannel('channel1', (223 + 180) * 60);
227 | console.log(schedule.list.map(s => s.url))
228 | thenScheduleUrlsAre(
229 | 'https://server1/dir1/p1_1',
230 | 'https://server1/dir1/p1_2',
231 | 'https://server5/dir5/p5_1',
232 | 'https://server1/dir1/p1_3',
233 | 'https://server2/dir2/p2_1',
234 | 'https://server2/dir2/p2_2',
235 | 'https://server5/dir5/p5_2',
236 | 'https://server2/dir2/p2_3',
237 | 'https://server2/dir2/p2_4',
238 | 'https://server3/dir3/p3_1',
239 | 'https://server5/dir5/p5_3',
240 | 'https://server3/dir3/p3_2',
241 | 'https://server3/dir3/p3_3',
242 | 'https://server3/dir3/p3_4',
243 | 'https://server5/dir5/p5_1', // playlist 5 is repeated
244 | 'https://server4/dir4/p4_1',
245 | 'https://server4/dir4/p4_2',
246 | 'https://server4/dir4/p4_3',
247 | 'https://server5/dir5/p5_2',
248 | 'https://server4/dir4/p4_4',
249 | 'https://server4/dir4/p4_5',
250 | 'https://server4/dir4/p4_6',
251 | 'https://server5/dir5/p5_3'
252 | );
253 | thenScheduleOffsetIs(0);
254 | });
255 |
256 | });
257 |
258 | });
259 |
260 | });
261 |
--------------------------------------------------------------------------------
/test/support/jasmine.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec_dir": "test",
3 | "spec_files": [
4 | "**/*-test.js"
5 | ],
6 | "stopSpecOnExpectationFailure": false,
7 | "random": false
8 | }
9 |
--------------------------------------------------------------------------------