-
Notifications
You must be signed in to change notification settings - Fork 3
/
MUSICPLAYER.html
951 lines (903 loc) · 47 KB
/
MUSICPLAYER.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MUSICPLAYER</title>
<style>
:root { color-scheme: dark; }
button { font-size: 300%; padding: 2px 2px 2px 2px; background-color: rgba(0,0,0,0); border: none; }
button:hover { background-color: gray; }
#clear_timestamps { font-size: 100%; }
select { font-size: 24pt; width:90%; max-width:80%; }
option { font-size: 18pt; }
audio { background-color: rgba(0,0,0,0.00); width: 90%; }
audio::-webkit-media-controls-play-button { transform-origin: center center; transform: scale(2,2.5); }
#log_table:has(#log_table_body:empty) { display: none; } /* The :has modifier does not work in firefox just yet. */
#log_table { font-size:10pt; word-wrap:break-word; font-family: Consolas, monaco, monospace; }
#log_table tr { transition: 1s opacity ease-in, 0.5s height ease; }
</style>
<style id="CSS_screen_wake_lock">
/* Screen wake lock css, for the fancy togglabe slider */
.slider-toggle-switch { --slider-ts-width: 65px;--slider-ts-height: 30px; --slider-ts-switch-width: 30px; --slider-ts-color: #3498db; --slider-ts-color-unchecked: #ccc; --slider-ts-shadow-color: rgba(0, 0, 0, 0.5); --slider-ts-input-font-size: 0.8rem; --slider-ts-input-font-color: #fff; --slider-ts-input-checked-content: "ON"; --slider-ts-input-unchecked-content: "OFF"; --slider-ts-switch-background-color: #fff; --slider-ts-label-color: #000; color: var(--slider-ts-input-font-color); display: inline-flex; align-items: center; user-select: none; position: relative; vertical-align: middle; margin-bottom: 0; }
.slider-toggle-switch:hover { cursor: pointer; }
.slider-toggle-switch>input[type="checkbox"] { position: absolute; opacity: 0; }
.slider-toggle-switch>input[type="checkbox"]+.slider_toggle { border-radius: 4px; align-items: center; position: relative; overflow: hidden; flex-shrink: 0; width: var(--slider-ts-width); height: var(--slider-ts-height); margin: 0; cursor: pointer; transition: background 200ms linear, box-shadow 200ms linear; }
.slider-toggle-switch>input[type="checkbox"]+.slider_toggle:before { content: var(--slider-ts-input-checked-content); opacity: 0; }
.slider-toggle-switch>input[type="checkbox"]+.slider_toggle:after { content: var(--slider-ts-input-unchecked-content); left: var(--slider-ts-switch-width); }
.slider-toggle-switch>input[type="checkbox"]+.slider_toggle:before,
.slider-toggle-switch>input[type="checkbox"]+.slider_toggle:after { display: flex; align-items: center; position: absolute; z-index: 2; height: 100%; justify-content: center; width: calc(100% - var(--slider-ts-switch-width)); font-size: var(--slider-ts-input-font-size); transition: all 200ms linear; }
.slider-toggle-switch>input[type="checkbox"]+.slider_toggle>.slider_switch { background-color: var(--slider-ts-switch-background-color); border-radius: 6px; display: block; height: 100%; width: var(--slider-ts-switch-width); position: absolute; right: 0; z-index: 3; box-sizing: border-box; transition: right 200ms linear, border-color 200ms linear; }
.slider-toggle-switch>input[type="checkbox"]:checked+.slider_toggle { background-color: var(--slider-ts-color); }
.slider-toggle-switch>input[type="checkbox"]:checked+.slider_toggle:before { opacity: 1; }
.slider-toggle-switch>input[type="checkbox"]:checked+.slider_toggle:after { opacity: 0; }
.slider-toggle-switch>input[type="checkbox"]:checked+.slider_toggle>.slider_switch { border-width: 3px; border-style: solid; border-color: var(--slider-ts-color); }
.slider-toggle-switch>input[type="checkbox"]:not(:checked)+.slider_toggle { background-color: var(--slider-ts-color-unchecked); }
.slider-toggle-switch>input[type="checkbox"]:not(:checked)+.slider_toggle>.slider_switch { border-width: 3px; border-style: solid; border-color: var(--slider-ts-color-unchecked); right: calc(100% - var(--slider-ts-switch-width)); }
</style>
<script src="custom.js"> /* load CUSTOM_PLAYLISTS_TO_LOAD and INDIVIDUAL_TRACKS_TO_LOAD */ </script>
<script>
var PLAYER_HISTORY_STACK = [];
var PLAYLIST_SONGS = [];
/**
* @params - You can give it a list of strings that include the
* - "name" of the song.
* - "key" of the song -> the `/artist/song/` part of the mixcloud.com/artist/song/ url.
* - "src" of the song. -> the full url of the audio to be used as the source when loaded.
* OR you can give it a dict with "name", "key", and/or "src" key value pairs.
* valid usage:
* add_song_record_to_playlist("RONS: Podcast #420", "/therealrons/rons-podcast-420/")
* add_song_record_to_playlist("RONS: Podcast #420", "/therealrons/rons-podcast-420/", "https://stream5.mixcloud.com/secure/c/m4a/64/2/7/3/3/c31f-6b26-42c6-814d-2051f42337fc.m4a?sig=woS4_8jGlXDNMX-TmIuEnQ")
* add_song_record_to_playlist({name:"RONS: Podcast #420", key:"/therealrons/rons-podcast-420/", src:"https://stream5.mixcloud.com/secure/c/m4a/64/2/7/3/3/c31f-6b26-42c6-814d-2051f42337fc.m4a?sig=woS4_8jGlXDNMX-TmIuEnQ"})
* add_song_record_to_playlist({name:"RONS: Podcast #420", key:"/therealrons/rons-podcast-420/"})
*/
function add_song_record_to_playlist() {
function validate_song_record() {
var temp = {};
for (var x of arguments) {
if (x == null) {
continue;
}
if (typeof(x) == 'object' && (x.src || (x.name && x.key))) {
return validate_song_record(x?.name, x?.key, x?.src);
}
if (x?.toString().match(/^\/.*?\/.*?\/$/gmi)) {
// then its a key
temp['key'] = x;
} else if (x?.toString().match(/https?:\/\/.*\..*/gmi)){
temp['src'] = x;
} else {
temp['name'] = x;
}
}
return (((temp['src'] || (temp['key'] && temp['name'])) || false) && true ? temp : null);
}
function does_song_record_already_exist(record) {
for (var row of PLAYLIST_SONGS) {
if ((record.key && row.key && record.key == row.key) || (record.src && row.src && record.src == row.src))
return true
}
return false;
}
var song = validate_song_record(...arguments)
if (song && song != {} && !does_song_record_already_exist(song)) {
PLAYLIST_SONGS.push(song);
}
}
/**
* Loads custom list of songs from mixcloud into the global {@linkcode PLAYLIST_SONGS} variable.
* This function is also responsible for formatting the new records for {@linkcode PLAYLIST_SONGS} name of the song.
* @param {HTMLElement=} debug - If given, it may use this HTMLElement (or subclass of HTMLElement) as a debugging print line.
*/
async function load_custom_songs(debug=null, mixcloud_search_query, mixcloud_match_regex, name_to_replace, name_replace_with) {
if (typeof(mixcloud_match_regex) == "string") {
mixcloud_match_regex = new RegExp(mixcloud_match_regex, "gmi")
}
var search_queue = [`https://api.mixcloud.com/search/?limit=100&q=${mixcloud_search_query}&type=cloudcast`];
if (debug) { debug.innerText = `${PLAYLIST_SONGS.length} songs loaded`;}
while (search_queue.length > 0) {
if (debug) { debug.innerText = debug.innerText.replace(new RegExp('(\\d+) songs loaded(\.*)', 'gm'), `${PLAYLIST_SONGS.length} songs loaded $2.`); }
var search_url = search_queue.pop();
var search_results = await get_JSON(search_url);
if ('data' in search_results) {
for (var i in search_results['data']) {
if (mixcloud_match_regex.exec(search_results['data'][i]['name']) !== null || mixcloud_match_regex.exec(search_results['data'][i]['user']['username']) !== null) {
add_song_record_to_playlist(name=search_results['data'][i]['name'].replace(name_to_replace, name_replace_with), key=search_results['data'][i]['key'])
if (debug) { debug.innerText = debug.innerText.replace(new RegExp('(\\d+) songs loaded(\.*)', 'gm'), `${PLAYLIST_SONGS.length} songs loaded $2`); }
document.title = `${PLAYLIST_SONGS.length} songs loaded...`;
}
}
}
if ('paging' in search_results) {
if ('next' in search_results['paging']) {
search_queue.push(search_results['paging']['next'])
}
}
}
}
/**
* README | EDIT HERE | NOTE | README | EDIT HERE | NOTE | README | EDIT HERE | NOTE | README | EDIT HERE | NOTE
* Custom func for filling PLAYLIST_SONGS with whatever you want
* See load_custom_songs for an example for how to load songs from mixcloud searches.
* See add_song_record_to_playlist for how to add songs to the master playlist.
* README | EDIT HERE | NOTE | README | EDIT HERE | NOTE | README | EDIT HERE | NOTE | README | EDIT HERE | NOTE
*/
async function update_playlist_songs() {
var prog = document.querySelector("#songlist_selector").querySelector("option");
prog.innerText = `${PLAYLIST_SONGS.length} songs loaded`;
if (Date.now() - load_persistent('PERSISTENT_PLAYLIST_GEN_DATE') <= 86400000 && PLAYLIST_SONGS.length > 10) {
// if last load time was in the last 24hrs and it seems we actually have a bunch of songs, then dont regenerate the playlist
console.log('done updating my song playlist. If this is wrong, reload the page with the Enable persistent storage of playlist checkbox disabled!');
return true;
}
if (CUSTOM_PLAYLISTS_TO_LOAD == undefined) {
write2log(`Missing CUSTOM_PLAYLISTS_TO_LOAD from custom.js!`);
} else {
for (var opt of CUSTOM_PLAYLISTS_TO_LOAD ?? []) {
await load_custom_songs(debug=prog, mixcloud_search_key=opt.mixcloud_search_key, mixcloud_match_regex=opt.mixcloud_match_regex, name_to_replace=opt.name_to_replace, name_replace_with=opt.name_replace_with);
}
}
if (INDIVIDUAL_TRACKS_TO_LOAD == undefined) {
write2log(`Missing INDIVIDUAL_TRACKS_TO_LOAD from custom.js!`);
} else {
for (var track of INDIVIDUAL_TRACKS_TO_LOAD) {
try {
add_song_record_to_playlist(name=track.name, key=track.key);
} catch {
write2log(`Bad INDIVIDUAL_TRACKS_TO_LOAD: ${JSON.stringify(track)}`);
}
}
}
document.title = "Cleaning songs...";
prog.innerText = `Cleaning ${PLAYLIST_SONGS.length} songs...`;
// REMOVE BAD SONGS
PLAYLIST_SONGS = PLAYLIST_SONGS.filter(song => !BAN_LIST.includes(song.key) && !BAN_LIST.includes(song.name));
prog.innerText = `${PLAYLIST_SONGS.length} songs loaded...`;
console.log('done updating my song playlist');
update_persistent('PERSISTENT_PLAYLIST_GEN_DATE', Date.now(), 0);
return true;
}
/**
* Fetches the response from the URL. If offline, it will attempt to wait for up to `max_attempts=10` tries, 1 minutes each, for the connection to go back online.
* @param url <String> the url to get.
* @throws Exceptions that are raised while fetching the url (not including NetworkErrors), or an offline timeout error.
*/
async function get_successful_fetch(url) {
function delay_until_connection(timeout_sec) {
// Sleep until onLine or timeout.
let end_at = Date.now() + (timeout_sec * 1000);
while (!window.navigator.onLine) {
if (Date.now() >= end_at) {
throw("Timeout exception, offline for too long!")
}
}
}
var max_attempts = 10;
var attempt = 0;
let result = null;
while (result == null && attempt++ < max_attempts) {
try {
return await fetch(url);
} catch (err) {
if (! err.message.startswith("NetworkError")) {
throw err;
}
// else it is a network error, so keep waiting until we timeout in 60 seconds
write2log(`get_successful_fetch(${url}); //Offline... waiting to reconnect...`,59);
await delay_until_connection(60);
}
}
throw(`Timed out trying to get ${url}`)
}
/**
* @param url <String> The url to extract JSON from.
* @returns result of JSON.parse.
*/
async function get_JSON(url) { write2log(`get_JSON(${url})`,2); let response = await get_successful_fetch(url); let data = await response.json(); return data; }
/**
* @param key <String> the mixcloud key/url endpoint of the song to be retrieved.
* @returns <String> the url for the audio source of the given mixcloud key.
*/
async function retreive_audio_src(key) {
// write2log(`retreive_audio_src(${key})`,2);
var logLines = [];
logLines.push(write2log(`retreive_audio_src(${key})`));
// Attempts to get the url for the src of the given mixcloud key
var url = `https://justcors.com/l_n1ehx3zipc/https://www.dlmixcloud.com/ajax.php?url=https://www.mixcloud.com${key}`;
var response = await get_successful_fetch(url)
var result_text = await response.text()
var resulting_json = JSON.parse(result_text)
var value = resulting_json['url'];
// new format:
if (!value) {
logLines.push(write2log(`Attempted to get audio src link for ${key}, no 'url' key at level 0 in response`))
for (var fmt of resulting_json['formats']) {
if (fmt['format_id'] == 'http') {
logLines.push(write2log(`Attempted to get audio src link for ${key}, found formats...format_id=='http' with url of '${fmt['url']}`))
value = fmt['url'];
break;
}
}
}
logLines.push(write2log(`returning: ${value}`))
logLines.forEach(e=> removeFadeOutAfter(e, 5))
return value;
}
/**
* Loads a random song index from the playlist queue, tries to not replay the last DONT_REPLAY_FROM_THE_LAST_X_SONGS songs.
* @CONFIG DONT_REPLAY_FROM_THE_LAST_X_SONGS - the number of songs from the history to not be repeated.
* @returns <int> An integer index for PLAYLIST_SONGS where the index is a randomly selcted song that hasnt been recently played.
*/
function get_random_song_idx(){
const DONT_REPLAY_FROM_THE_LAST_X_SONGS = 5;
var r = Math.floor(Math.random() * PLAYLIST_SONGS.length);
if (PLAYER_HISTORY_STACK.length < DONT_REPLAY_FROM_THE_LAST_X_SONGS) {
// no option to play a diff song.
return r;
}
while (r in PLAYER_HISTORY_STACK.slice(-DONT_REPLAY_FROM_THE_LAST_X_SONGS)) {
r = Math.floor(Math.random() * PLAYLIST_SONGS.length);
}
return r;
}
/**
* Loads up the next song from the playlist queue in a sequential order.
* @return <int> the integer index for PLAYLIST_SONGS of the next song to be played.
*/
function get_next_song_idx(){
//
if (PLAYER_HISTORY_STACK.length == 0) {
// no option to play a diff song.
return 0;
}
return (PLAYER_HISTORY_STACK[0] + 1) % PLAYLIST_SONGS.length;
}
/**
* Stores timestamps of specific songs so that we can keep track of favorite songs etc.
* Will update the #timestamps_list element.
*/
function mark_favorited_timestamp(){
var audio = document.querySelector('audio');
var curr_timestamp = audio.currentTime;
if (PLAYER_HISTORY_STACK.length == 0) {
return;
}
var curr_song = PLAYLIST_SONGS[PLAYER_HISTORY_STACK[0]];
var timestamp_list = document.querySelector('#timestamps_list');
var li = document.createElement("li");
li.innerText = `${curr_song.name} @ ${sec_to_human_readable_timestamp(curr_timestamp)}`;
timestamp_list.appendChild(li);
}
/**
* @param sec <number> seconds.
* @returns <String> simplified timestamp.
*/
function sec_to_human_readable_timestamp(sec) {
return `${String(parseInt(sec/3600)).padStart(2, '0')}:${String(parseInt((sec % 3600)/60)).padStart(2, '0')}:${parseInt(sec) % 60}`;
}
/**
* Event fired by selecting a song from the playlist dropdown.
* Loads the song from the index that matches the selected value (a key).
*/
function load_selected_song(){
var selected_val = document.querySelector('select').value;
var i = 0;
while (i < PLAYLIST_SONGS.length) {
if (PLAYLIST_SONGS[i].key == selected_val) {
load_song_from_index(i);
return;
}
++i;
}
}
/**
* Actition event for the Fast forward feature. Will fast forward by the greater of the following values: 5% of the song length, or 1 minute.
*/
async function fastforward() {
const audio = document.querySelector('#player');
audio.currentTime += Math.min(audio.duration * 0.05, 60);
}
/**
* PREREQ: update_playlist_songs has been run
* If not given explicitly, gets the proper playlist index number, attempts to play that song, and updates the UI.
* @param next_song, <int> the index from the PLAYLIST_SONGS to use, <null>=0, <str> the song key from the PLAYLIST_SONGS to use.
*/
async function load_song_from_index(next_song=null){
if (PLAYLIST_SONGS.length == 0) {
return;
}
// is run when we want to start loading a new song into the player
if (typeof(next_song) == 'number') {
next_song = (parseInt(next_song) || 0) % PLAYLIST_SONGS.length;
} else if (typeof(next_song) == 'string') {
var s = next_song;
next_song = 0;
while (next_song < PLAYLIST_SONGS.length) {
if (PLAYLIST_SONGS[next_song].name == s || PLAYLIST_SONGS[next_song].key == s) {
break
}
++next_song;
}
if (next_song == PLAYLIST_SONGS.length) {
next_song = get_random_song_idx();
}
} else {
next_song = document.querySelector('#shuffle_button').innerText == "🔀" ? get_random_song_idx() : get_next_song_idx();
}
document.title = "Loading next song...";
// now next_song is an int.
if (PLAYLIST_SONGS[next_song].src == undefined) {
do {
try {
PLAYLIST_SONGS[next_song].src = await retreive_audio_src(PLAYLIST_SONGS[next_song].key);
} catch (err) {
// begin temp patch
// while the retreive_audio_src is unreliable display the alternative as a link on the page
const brokenButtonId = 'brokenRedirect';
if (! document.querySelector(`#${brokenButtonId}`)) {
var a = document.createElement('button');
a.onclick = () => {window.open(window.location.pathname.replace('/main/','/quickpatch-till-music-is-live/'), '_self');};
a.innerText = `Errors while attempting to retreive the audio src key. Click here to use the other version of the app.`;
a.id = 'brokenRedirect';
document.body.insertBefore(a, document.body.firstChild);
}
// end temp patch
console.log(`Error while attempting to get src from song index #${next_song}, key = '${PLAYLIST_SONGS[next_song].key}', name = '${PLAYLIST_SONGS[next_song].name}', skipping to next index!`);
next_song++;
}
} while (PLAYLIST_SONGS[next_song].src == undefined)
} else {
}
// now we have the src for this song... its ready to be loaded
await update_player_src(PLAYLIST_SONGS[next_song].src);
// set currently playing in player history
PLAYER_HISTORY_STACK.splice(0, 0, next_song);
var dropdown_selections = document.getElementById('songlist_selector');
dropdown_selections.value = PLAYLIST_SONGS[next_song].key;
// update played history tab
update_played_history_ui();
// update title of webpage
document.title = PLAYLIST_SONGS[next_song].name;
update_mediasession_info(PLAYLIST_SONGS[next_song]);
}
/**
* Updates the navagator.mediaSession object to reflect the given song.
*/
function update_mediasession_info(song) {
if ("mediaSession" in navigator) {
try {
var [artist, title] = song.name.split(": ",2);
navigator.mediaSession.metadata = new MediaMetadata({
title: title,
artist: artist,
//album: "Podcast Name",
//artwork: [{ src: "podcast.jpg" }],
});
var audio_element = document.querySelector("audio");
navigator.mediaSession.setPositionState({duration: audio_element.duration, playbackRate: audio_element.playbackRate, position: audio_element.currentTime});
} catch (err) {}
}
}
function setup_media_session_action_handlers() {
if (!("mediaSession" in navigator)) {
write2log(`No mediaSession in navigator.`,2);
}
const audio_element = document.querySelector('audio');
const default_skip_time = 60; /* Time to skip in seconds by default */
const action_handlers = [
['play', () => { audio_element.play(); }],
['pause', () => { audio_element.pause(); }],
['previoustrack', () => { audio_element.currentTime = 0; }],
['nexttrack', () => { load_song_from_index(); }],
['stop', () => {audio_element.pause(); }],
['seekbackward', (details) => { const skip_time = details.seekOffset || default_skip_time; audio_element.currentTime = Math.max(audio_element.currentTime - skip_time, 0); navigator.mediaSession.setPositionState({duration: audio_element.duration, playbackRate: audio_element.playbackRate, position: audio_element.currentTime});}],
['seekforward', (details) => { const skip_time = details.seekOffset || default_skip_time; audio_element.currentTime = Math.min(audio_element.currentTime + skip_time, 0); navigator.mediaSession.setPositionState({duration: audio_element.duration, playbackRate: audio_element.playbackRate, position: audio_element.currentTime});}],
['seekto', (details) => { if (details.fastSeek && 'fastSeek' in audio_element) { audio_element.fastSeek(details.seekTime); } else { audio_element.currentTime = details.seekTime; } navigator.mediaSession.setPositionState({duration: audio_element.duration, playbackRate: audio_element.playbackRate, position: audio_element.currentTime});}],
// Not implemented: ['togglemicrophone', 'togglecamera', 'hangup', 'previousslide', and 'nextslide'];
/* Video conferencing actions */
//['togglemicrophone', () => { await audio_element.pause(); /* On any microphone usage (on or off), just set the audio to pause. */ }],
//['togglecamera', () => { await audio_element.pause(); /* On any camera usage (on or off), just set the audio to pause. */ }],
//['hangup', () => { if (audio_element.currentTime > 0) { await audio_element.play(); } /* On hangup, simplified (not bulletproof) version of unmute if we were playing music before */}],
/* Presenting slides actions */ // Not implemented at this time, placeholder for future? idk.
//['previousslide', () => { /* ... */ }],
//['nextslide', () => { /* ... */ }],
];
for (const [action, handler] of action_handlers) {
try {
write2log(`Adding mediaSession action: ${action}`,5);
navigator.mediaSession.setActionHandler(action, handler);
} catch (error) {
write2log(`The media session action "${action}" is not supported yet.`,2);
}
}
try {
// Set playback event listeners
audio_element.addEventListener('play', () => { navigator.mediaSession.playbackState = 'playing'; });
audio_element.addEventListener('pause', () => { navigator.mediaSession.playbackState = 'paused'; });
}
catch (err) {
write2log(`Failed to set navigator.mediaSession.playbackState play/pause handlers`,2);
}
}
/**
* Enables a whole batch of events to be setup for gestures
* @params args {name:string, step:string}[] array of objects with keys: 'name', and 'step'
*/
function enable_gesture_handler_group(args) {
var start = {};
var end = {};
var tracking = false;
var thresholdTime = 500;
var relativeScreenDistThreshold = 0.3 * (mobileCheck() ? 0.5 : 1);
// 15-30% of the visible diagonal of the screen. should work seamlessly for any device/viewport, where mobile device swipes are more sensitive.
var thresholdDistance = ((window.visualViewport?.height ?? 1)**2 + (window.visualViewport?.width ?? 1)**2)**0.5 * relativeScreenDistThreshold;
gestureStart = function(e) {
tracking = true;
/* Hack - would normally use e.timeStamp but it's whack in Fx/Android */
start.t = new Date().getTime();
start.x = e.screenX;
start.y = e.screenY;
};
gestureMove = function(e) {
if (tracking) {
e.preventDefault();
end.x = e.screenX;
end.y = e.screenY;
}
}
gestureEnd = function(e) {
if (tracking) {
tracking = false;
var now = new Date().getTime();
var deltaTime = now - start.t;
var deltaX = end.x - start.x;
var deltaY = end.y - start.y;
/* work out what the movement was */
if (deltaTime > thresholdTime) {
/* gesture too slow */
return;
} else {
if ((deltaX > thresholdDistance)&&(Math.abs(deltaY) < thresholdDistance)) {
handle_gesture_right();
} else if ((-deltaX > thresholdDistance)&&(Math.abs(deltaY) < thresholdDistance)) {
handle_gesture_left();
} else if ((deltaY > thresholdDistance)&&(Math.abs(deltaX) < thresholdDistance)) {
handle_gesture_down();
} else if ((-deltaY > thresholdDistance)&&(Math.abs(deltaX) < thresholdDistance)) {
handle_gesture_up();
} else {
// do nothing, undefined behavior.
}
}
}
}
handle_gesture_left = function() { write2log(`Gesture fired: LEFT`, 1); document.querySelector('audio').currentTime = 0; }
handle_gesture_right = function() { write2log(`Gesture fired: RIGHT`, 1); load_song_from_index(); }
handle_gesture_up = function() { write2log(`Gesture fired: UP`, 1); document.querySelector('#shuffle_button').click(); }
handle_gesture_down = function() { write2log(`Gesture fired: DOWN`, 1); load_song_from_index(); }
args.forEach(item=>{
item.step = item.step.toLowerCase();
if (item.step.endsWith("start")) {
item.step = gestureStart;
} else if (item.step.endsWith("end")) {
item.step = gestureEnd;
} else if (item.step.endsWith("move")) {
item.step = gestureMove;
} else {
write2log(`bad formatting of args to enable_gesture_handler_group!`)
write2log(item);
return;
}
try {
document.addEventListener(item.name, item.step, false)
} catch {
write2log(`Failed to setup event: ${item.name}`);
}
});
}
/**
* Setup the touch events where possible.
*/
function setup_gesture_handlers() {
if ("PointerEvent" in window) {
enable_gesture_handler_group([
{ name: 'pointerdown', step: 'start' },
{ name: 'pointermove', step: 'move' },
{ name: 'pointerup', step: 'end' },
{ name: 'pointerleave', step: 'end' },
{ name: 'pointercancel', step: 'end' },
]);
} else {
write2log(`No PointerEvent in window, gesture controls via PointerEvents will not be enabled!`, 2);
}
if ('TouchEvent' in window || 'ontouchstart' in window || 'ontouchend' in document) {
enable_gesture_handler_group([
{ name: 'touchstart', step: 'start' },
{ name: 'touchmove', step: 'move' },
{ name: 'touchend', step: 'end' },
{ name: 'touchcancel', step: 'end' },
]);
} else {
write2log(`No TouchEvent in window, gesture controls via TouchEvents will not be enabled!`, 2);
}
}
/**
* Updates the played history tab UI with the current song info.
*/
function update_played_history_ui() {
var history_list = document.querySelector('#played_history_list');
var ul = document.createElement("ul");
ul.setAttribute('id','played_history_list');
PLAYER_HISTORY_STACK.forEach(e=>{
var li = document.createElement("li");
li.innerText = `${PLAYLIST_SONGS[e].name}`;
ul.appendChild(li);
});
history_list.innerHTML = ul.innerHTML;
}
/**
* Updates the audio element with the given src url.
* If successfully played, will update the persistent LAST_SONG_SRC value so that you can quick start playing next time the musicplayer is open.
* @param src <str> the audio source string (url) to be played
*/
async function update_player_src(src) {
if (src == null) {return;}
var audio = document.querySelector('audio');
var source = document.querySelector('source');
source.setAttribute('src', src);
try{
audio.load();
try{
audio.play();
audio.muted = false;
}catch(err){}
update_persistent('LAST_SONG_SRC', src, 0); // save the src for the quickstart
navigator.mediaSession?.setPositionState({duration: audio.duration, playbackRate: audio.playbackRate, position: audio.currentTime});
}catch(err){}
}
/**
* Fired when a src fails to load in the audio player.
* Checks the playlist song records that use that src value and attepts to get the new updated SRC for them.
* When successful it will update the src of the live PLAYLIST_SONG record.
* This is a background thread!
* @param src <str> the audio source string (url) to be checked for updates.
*/
function check_for_updated_src(src) {
if (!src) {
return null;
}
try {
for (let e of PLAYLIST_SONGS) {
if (e.src == src) {
let ns = retreive_audio_src(e.key)
.then(newSrc => {
if (newSrc && e.src != newSrc) {
e.src = newSrc;
write2log(`Updated ${e.key} src to ${newSrc}!`,2);
}
});
}
}
} catch(err) {
write2log(`some ${err} error while attempting to get updated src of ${song.key}`,2);
}
}
/**
* Updates the dropdown UI to display all of the available songs in PLAYLIST_SONGS.
* Will sort the PLAYLIST_SONGS.
* NOTE: This should be run after `update_playlist_songs`
*/
function update_dropdown_with_current_songs(){
document.querySelector("#songlist_selector").querySelector("option").innerText = "Updating song selections...";
var i = 0;
var dropdown_selections = document.getElementById('songlist_selector').options;
while (dropdown_selections.length > 0) {
dropdown_selections[0].remove();
}
// Sort by name, then by key
PLAYLIST_SONGS.sort((a,b) => (a.name.toLowerCase() > b.name.toLowerCase()) ? 1 : ((b.name.toLowerCase() > a.name.toLowerCase()) ? -1 : (a.key?.toLowerCase() > b.key?.toLowerCase()) ? 1 : ((b.key?.toLowerCase() > a.key?.toLowerCase()) ? -1 : 0)));
while (i < PLAYLIST_SONGS.length) {
dropdown_selections.add(new Option(PLAYLIST_SONGS[i].name,PLAYLIST_SONGS[i].key));
++i;
}
}
/**
* Attempts to load a src value from the localStorage so that we can get music instantly instead of having to wait for the full dynamic load of the song list
*/
function start_quick_start_song(){
try {
var src = load_persistent('LAST_SONG_SRC');
if (src) {update_player_src(src);}
} catch (err) {
console.log("Unable to load the last song src, will have to wait for full song list to load.");
}
}
/**
* Updates the given data as stringified JSON for the given key in the `localStorage`.
* @param key <str> the localStorage key to update
* @param data <?> the data.
* @param mode <int>, -1=delete, 0=overwrite, 1=append (if [existing type is array, and mode == 1])
*/
function update_persistent(key, data=null, mode=0) {
if (mode in [-1, 0]) {
localStorage.removeItem(key);
}
if (mode in [0, 1] && data != null) {
var temp = JSON.parse(localStorage.getItem(key));
if (Array.isArray(temp)) {
temp = temp.concat(data);
} else {
temp = data;
}
localStorage.setItem(key, JSON.stringify(temp));
}
}
/**
* @returns JSON.parse of the value stored at the `localStorage.key`.
* null if it does not exist.
*/
function load_persistent(key){
return JSON.parse(localStorage.getItem(key));
}
/**
* load_ functions get the persistent data and updates their respective live values.
* save_ functions stores their respective live values to localStorage to be persistent.
*/
function load_playlist_data() {
let enabled = (load_persistent('ALLOW_PERSISTENT_PLAYLIST') == null? false : load_persistent('ALLOW_PERSISTENT_PLAYLIST'));
if (enabled) {
document.getElementById("allow_persistent_playlist").checked = true;
let plylst = load_persistent('PERSISTENT_PLAYLIST');
plylst.forEach(e=>{/*if(e.src) {*/ add_song_record_to_playlist(e)}/*}*/)
}
}
function save_playlist() {
update_persistent('ALLOW_PERSISTENT_PLAYLIST', document.getElementById("allow_persistent_playlist").checked);
if (document.getElementById("allow_persistent_playlist").checked) {
update_persistent('PERSISTENT_PLAYLIST',PLAYLIST_SONGS/*.filter(e=>e.src)*/, 0);
} else {
// delete this data as its potential to be large* data footprint
update_persistent('PERSISTENT_PLAYLIST',mode=-1);
update_persistent('PERSISTENT_PLAYLIST_GEN_DATE',mode=-1);
}
}
function load_volume() {
document.getElementById("player").volume = (load_persistent('VOLUME') == null? 1 : load_persistent('VOLUME'));
}
function save_volume() {
update_persistent('VOLUME',document.getElementById("player").volume, 0);
}
function load_timestamps() {
var timestamps = load_persistent('TIMESTAMPS');
if (timestamps == null) {
return;
}
var timestamp_list = document.querySelector('#timestamps_list');
timestamps.forEach(ts => {
var li = document.createElement("li");
li.innerText = ts;
timestamp_list.appendChild(li);
});
}
function save_timestamps() {
var arr = Array.from(new Set(Array.from(document.getElementById('timestamps_list').childNodes).map(e=>e.innerText)));
update_persistent('TIMESTAMPS', arr, arr.length == 0? 0 : 1)
}
/**
* Fired by the clear timestamps button 🆑.
* Updates the UI and deletes its respective persistent data.
*/
function clear_timestamps() {
var timestamps = document.getElementById('timestamps_list').childNodes;
if (timestamps.length == 0) {return;}
if (confirm("Are you sure you want to clear and reset all of the timestamps?")) {
var removed = JSON.stringify(Array.from(timestamps).map(e=>e.innerText));
while (timestamps.length) {
timestamps[0].remove();
}
update_persistent('TIMESTAMPS', null, -1);
//localStorage.removeItem('TIMESTAMPS');
console.log(removed);
navigator.clipboard.writeText(removed);
alert("Console and current clipboard contents should contain JSON of removed data!");
}
}
/**
* Sets up the wake lock action handlers for the slider.
*/
function setup_wake_lock_action_handlers() {
if (!('wakeLock' in navigator)) {
// remove useless elements from screen
document.querySelector("#screen_wake_lock_toggle").remove();
document.querySelector("#CSS_screen_wake_lock").remove();
write2log(`Screen wait lock feature disabled, no 'wakeLock' in navigator`, mobileCheck() ? null : 10);
return;
}
const wakeLockToggle = document.querySelector("#screen_wake_lock_switch");
wakeLockToggle._wakeLocks = [];
wakeLockToggle.onchange = async (e) => {
if (e.target.checked) {
await request_screen_wake_lock();
} else {
// toggle off
await release_screen_wake_lock({isUserDisabled:true});
}
}
}
/**
* Attempts to get the screen wake lock to prevent screen from turning off.
*/
async function request_screen_wake_lock() {
try {
let wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener("release", async (event) => {
await release_screen_wake_lock(event);
});
document.querySelector("#screen_wake_lock_switch")._wakeLocks.push(wakeLock);
//window.screen_wake_lock.release = release_screen_wake_lock;
write2log(`Screen wake lock aquired, will not go to sleep!`, 10);
} catch (err) {
// if wake lock request fails - usually system related, such as battery
write2log(`Failed to aquire screen wake lock!`, 60);
write2log(err, 60);
}
}
/**
* Called when we are trying to aquire the lock because it was released due to some
* circumstance that was not human pressing the toggle off button
*/
async function auto_retry_aquire_lock() {
if (document.visibilityState === "visible") {
try {
document.querySelector("#screen_wake_lock_switch").checked = true;
} catch {}
} else {
document.addEventListener("visibilitychange", auto_retry_aquire_lock, { once: true });
}
}
/**
* Releases the screen wake lock. If not human toggled,
* then it will try to reaquire the lock when the screen is next visible.
*/
async function release_screen_wake_lock(event) {
document.querySelector("#screen_wake_lock_switch").checked = false;
while (document.querySelector("#screen_wake_lock_switch")._wakeLocks.length > 0) {
try {
let wakeLock = document.querySelector("#screen_wake_lock_switch")._wakeLocks.pop();
if (wakeLock?.released == false) {
try {
wakeLock.release();
} catch {}
}
} catch {}
}
write2log(`Screen wake lock has been released`, 10);
if (!event?.isUserDisabled) {
// non user fired event
write2log(`Will try to reaquire screen wake lock when possible.`, 10);
document.addEventListener("visibilitychange", auto_retry_aquire_lock, { once: true });
}
}
/** logs data to console and to visible element on page.
* #param val (any) the value to log
* @param timeout (number) if greater than 0, will remove the line from the log after timeout seconds.
* @returns the row element <tr> that was logged
*/
function write2log(val, timeout = 0) {
console.log(val);
var ts = document.createTextNode(
new Date().toLocaleTimeString("en-us", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
})
);
var is_secondary_val_type = false;
if (String(val) == "[object Object]") {
val = JSON.stringify(val);
is_secondary_val_type = true;
}
val = document.createTextNode(val);
var newRow = document.createElement("tr");
newRow.appendChild(document.createElement("td")).appendChild(ts);
newRow.appendChild(document.createElement("td")).appendChild(val);
if (is_secondary_val_type) {
newRow.childNodes[1].style.color = "red";
}
var tbl = document.querySelector('#log_table_body');
tbl.appendChild(newRow);
tbl.hidden = false;
if (timeout > 0) {
removeFadeOutAfter(newRow, Math.max(timeout, 1));
}
return newRow;
}
/** Fades an element after a given time, and deletes the object from the DOM/memory.
* @param {HTMLElement} el the element to be dissapered
* @param {*} seconds the seconds to dissapear after
*/
function removeFadeOutAfter(el, seconds) {
setTimeout(() => {
el.style.transition = "opacity 1s ease";
el.style.opacity = 0;
setTimeout(function () {
el.parentNode.removeChild(el);
}, seconds * 1000);
}, Math.max(seconds - 1, 1) * 1000);
}
/**
* @returns true if on mobile device, false otherwise. Not 100% accurate.
*/
function mobileCheck() {
const checkByUserAgent = (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(navigator.userAgent||navigator.vendor||window.opera)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test((navigator.userAgent||navigator.vendor||window.opera).substr(0,4)));
const checkByTouchScreen = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0);
return checkByTouchScreen || checkByUserAgent;
}
/**
* onload event:
* 1. Loads any persistent data.
* 2. Adds events to UI objects when they should become available.
* 3. Retreives the playlists.
* 4. Starts playing music.
*/
window.onload = async function() {
load_volume();
load_playlist_data();
document.querySelector('#shuffle_button').addEventListener('click', function(){document.querySelector('#shuffle_button').innerText = document.querySelector('#shuffle_button').innerText == "🔀" ? "🔃" : "🔀"}, false);
document.querySelector('#player_source').addEventListener('error', async function(err){ console.log(`Failed to load source value of ${this.src}`); check_for_updated_src(this.src); }, false);
start_quick_start_song();
load_timestamps();
setup_wake_lock_action_handlers();
await update_playlist_songs();
update_dropdown_with_current_songs();
load_song_from_index(get_random_song_idx());
document.querySelector('audio').addEventListener('ended', function(){ if (! document.querySelector('audio').seeking) load_song_from_index()}, false);
document.querySelector('#songlist_selector').addEventListener('change', load_selected_song, false);
document.querySelector('#skip').addEventListener('click', load_song_from_index, false);
document.querySelector('#fastforward').addEventListener('click', fastforward, false);
document.querySelector('#mark_timestamp').addEventListener('click', mark_favorited_timestamp, false);
document.querySelector('#clear_timestamps').addEventListener('click', clear_timestamps, false);
document.querySelector('#player_source').addEventListener('error', function(err){ if (! document.querySelector('audio').seeking) load_song_from_index()}, false);
setup_media_session_action_handlers();
setup_gesture_handlers();
}
/**
* onunload: Before closing down, save any persistent data that needs to be saved!
*/
window.onunload = function() {
save_volume();
save_timestamps();
save_playlist();
}
</script>
</head>
<body>
<button id="PLAY" onclick="let x=document.querySelector('audio');document.querySelectorAll('[hidden]').forEach(e=>e.hidden=false);x.muted=true;x.muted=false;x.volume=mobileCheck()?1:(x.volume?x.volume : 1);x.play();document.querySelector('#PLAY').remove()">▶️</button>
<div hidden>
<select id="songlist_selector"><option>Loading...</option></select>
</div>
<div>
<button id="shuffle_button" hidden>🔀</button>
<button id="skip" hidden>⏭</button>
<button id="fastforward" hidden>⏩</button>
<button id="mark_timestamp" hidden>⭐</button>
<label id="screen_wake_lock_toggle" class="slider-toggle-switch" for="screen_wake_lock_switch" data-size="" style="--slider-ts-input-checked-content: 'Screen wake locked';--slider-ts-input-unchecked-content: 'Screen sleep allowed';--slider-ts-width: 150px;" hidden>
<input id="screen_wake_lock_switch" type="checkbox" hidden/>
<span class="slider_toggle" hidden><span class="slider_switch" hidden></span></span>
</label>
</div>
<div><audio controls autoplay id="player" hidden><source src="data:audio/ogg;base64,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GEMppJRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkgppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkqppJRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoplVJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSCgCQinAAkHowoQwUGrISAEgFAACMUUopxpyDEDHmGGPQSSgpYsw5xhyUklLlHIQQUmktt8o5CCGk1FJtmXNSWosx5hgz56SkFFvNOYdSUoux5ppr7qS0VmuuNedaWqs115xzzbm0FmuuOdecc8sx15xzzjnnGHPOOeecc84FAOA0OACAHtiwOsJJ0VhgoSErAYBUAAACGaUYc8456BBSjDnnHIQQIoUYc845CCFUjDnnHHQQQqgYc8w5CCGEkDnnHIQQQgghcw466CCEEEIHHYQQQgihlM5BCCGEEEooIYQQQgghhBA6CCGEEEIIIYQQQgghhFJKCCGEEEIJoZRQAABggQMAQIANqyOcFI0FFhqyEgAAAgCAHJagUs6EQY5Bjw1BylEzDUJMOdGZYk5qMxVTkDkQnXQSGWpB2V4yCwAAgCAAIMAEEBggKPhCCIgxAABBiMwQCYVVsMCgDBoc5gHAA0SERACQmKBIu7iALgNc0MVdB0IIQhCCWBxAAQk4OOGGJ97whBucoFNU6iAAAAAAAAwA4AEA4KAAIiKaq7C4wMjQ2ODo8AgAAAAAABYA+AAAOD6AiIjmKiwuMDI0Njg6PAIAAAAAAAAAAICAgAAAAAAAQAAAAICAT2dnUwAEAAAAAAAAAADwOoUqAgAAAJxxaEsBAQA=" type="audio/mp4" id="player_source"></audio></div>
<div>
<h4 hidden>Played history:</h4>
<ul id="played_history_list" hidden></ul>
<p hidden>TIMESTAMPS: <button id="clear_timestamps">🆑</button></p>
<ul id="timestamps_list" hidden></ul>
</div>
<label hidden><input type="checkbox" id="allow_persistent_playlist" > Enable persistent storage of playlist</label>
<table id="log_table" >
<thead id="log_table_header"><tr><th>timestamp</th><th>message</th></tr></thead>
<tbody id="log_table_body"></tbody>
</table>
</body>
</html>