├── .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 | Website Screenshot 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 |

Old Time Radio

20 |
21 | 29 |
30 | 31 | 32 | 33 | 34 |
35 |
36 | 41 |
42 | 164 | 165 | 166 |
167 | 168 |
169 | 170 |
171 | 172 |
173 |
174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------