StudioClock.svelte 12.5 KB
Newer Older
1
<svelte:options tag="aura-clock"/>
2
<main bind:this={rootElement}></main>
David Trattnig's avatar
David Trattnig committed
3
<script>
4
	import { onMount, afterUpdate } from 'svelte';
David Trattnig's avatar
David Trattnig committed
5
6
7
8
9
10
11
12
13
14

	export let css = globalConfig.CSS;
	export let api =  globalConfig.API_URL;
	export let name = globalConfig.NAME;
	export let logo = globalConfig.LOGO;
	export let logosize = globalConfig.LOGO_SIZE;
	export let nocurrentschedule = globalConfig.NO_CURRENT_SCHEDULE;
	export let nonextschedule = globalConfig.NO_NEXT_SCHEDULE;
	export let unknowntitle = globalConfig.UNKNOWN_TITLE;
	export let playoffset = globalConfig.PLAY_OFFSET;
15

David Trattnig's avatar
David Trattnig committed
16
	let version = "APP_VERSION";
17
	let time = new Date(); 
18
	let queryCurrent = "clock";
19
	let rootElement;
20
21
22
23
	let promise;
	let prevClockData = null;
	let clockData = null;
	let currentTrackElement = null;
24
	let timeLeft;
25
26
27
	let scheduleTimeLeft = 0;
	let reloadTime = 10;
	let reloadWait = 0;
David Trattnig's avatar
David Trattnig committed
28
29
30
31
32
33
34

	// these automatically update when `time`
	// changes, because of the `$:` prefix
	$: hours = time.getHours();
	$: minutes = time.getMinutes();
	$: seconds = time.getSeconds();

35
36
	promise = fetchApi(queryCurrent);

37

38
	/* When component is mounted to the DOM */
David Trattnig's avatar
David Trattnig committed
39
40
41
	onMount(() => {
		const interval = setInterval(() => {
			time = new Date();
42
			timeLeft -= 1;
43
44
45
46
47
48
49
50
51
52
			scheduleTimeLeft -= 1;
		
			/* End of track or end of schedule - load new data */
			if (timeLeft <= 0 || scheduleTimeLeft <= 0) {
				/* For some seconds refresh every second, to work around API timing delays */
				if (timeLeft <= 0 && timeLeft >= -3 || scheduleTimeLeft <= 0 && scheduleTimeLeft >= -3 || reloadWait == 0) {
					promise = fetchApi(queryCurrent);
					reloadWait = reloadTime;
				}
				reloadWait -= 1;				
53
			}
54
						
David Trattnig's avatar
David Trattnig committed
55
56
57
58
		}, 1000);

		return () => {
			clearInterval(interval);
59
		};		
David Trattnig's avatar
David Trattnig committed
60
	});
61

62
63
64
65
66
67
68
69

	/* Called after the component has been updated */
	afterUpdate(async () => {
		scrollToActiveTrack();
	});


	/* Load clock data from the API */
70
	async function fetchApi(query) {
71
		let response;
72
73
		let data;

74
		try {
75
			response = await fetch(api+query);
76
		} catch {
77
			throw new Error(`Cannot connect to Engine API at "${api}"!`);
78
79
		}

80
81
82
83
84
85
		try {
			data = await response.json();
		} catch(e) {
			console.log("Error while converting response to JSON!", e);
			throw new Error(response.statusText);
		}
86
87
88
89

		if (response.ok) {
			return data;
		} else {
90
91
92
93
94
			console.log("Error:", data);
			throw new Error(data.message);
		}
	}

95

96
	/* Initialize the component */
97
	function initComponent(value) {
98
99
100
101
102
103

		/* Load external CSS */
		if (css != null)
			loadExternalCss(rootElement, css);

		/* Set currently loaded data */
104
105
106
107
108
109
110
111
112
		if (value != null) {
			clockData = value;
			console.log("Current Data", value);

			if (value.current_track != null) {
				let t = time - Date.parse(value.current_track.track_start);
				t = parseInt(t/1000);
				timeLeft = value.current_track.track_duration - t - playoffset;
			}
113
			
114
115
116
117
118
119
120
121
			if (value.current_schedule != null) {
				let schedule_end = Date.parse(value.current_schedule.schedule_end);
				schedule_end = parseInt(schedule_end/1000);
				scheduleTimeLeft = schedule_end - time;
			} else {
				/* Decrease time left in any case to avoid reloading too often */
				scheduleTimeLeft -= 1;
			}
122
123
124
125
		}
		return "";
	}

126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149

	/* Checks if there's an existing, valid schedule */
	function hasValidSchedule(value) {
		if (value.current_schedule != null) {
			if (value.current_schedule.schedule_end != null) {
				let schedule_end = Date.parse(value.current_schedule.schedule_end);
				let diff = schedule_end - time;
				if (diff >= 0)
					return true;
			}
		}
		return false;
	}


	/* Checks if there is an existing valid playlist */
	function hasValidPlaylist(value) {
		if (hasValidSchedule(value))
			if (value.current_playlist != null)
				return true;
		return false;
	}


150
	/* Display the title of a track */
151
152
	function displayTitle(track) {
		if (track != null) {
153
			let artist = "";
154
155
			let title = unknowntitle;
			if (track.track_artist != null && track.track_artist != "")
David Trattnig's avatar
David Trattnig committed
156
				artist = track.track_artist + " - ";
157
158
159
			if (track.track_title != null && track.track_title != "")
				title = track.track_title;
			return artist + title;
160
161
162
163
		}
		return "";
	}

164

165
	/* Format the time */
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
	function formatTime(seconds) {
		if (seconds != null && Number.isInteger(seconds)) {
			let d = new Date(null);
			d.setSeconds(seconds);

			let s;
			if (seconds > 3600)
				s = d.toISOString().substr(11, 8);
			else
				s = d.toISOString().substr(14, 5);
			return s;
		}
		return "";
	}

181

182
	/* Display the name of a show */
David Trattnig's avatar
David Trattnig committed
183
	function displayShowName(schedule) {
184
		let name = ""
David Trattnig's avatar
David Trattnig committed
185
		if (schedule == null || schedule.show_name == null) {
186
			name = '<span class="error">'+nonextschedule+'</span>';
187
		} else {
David Trattnig's avatar
David Trattnig committed
188
			name = schedule.show_name;
189
		}
190
191
192
		return name;
	}

193

194
	/* Display the schedule of a show */
195
196
197
198
199
200
201
202
203
204
205
206
207
	function displayShowSchedule(schedule) {
		let str = "";

		if (schedule != null && schedule.schedule_start != null) {
			let scheduleStart = ""
			let scheduleEnd = "";

			if (schedule.schedule_start != null) {
				let scheduleStart = new Date(Date.parse(schedule.schedule_start));
				scheduleStart = scheduleStart.toLocaleTimeString(navigator.language, {
					hour: '2-digit',
					minute:'2-digit'
				});
208
				str = "" + scheduleStart;
209
210
211
212
213
214
215
			}
			if (schedule.schedule_end != null) {
				scheduleEnd = new Date(Date.parse(schedule.schedule_end));
				scheduleEnd = scheduleEnd.toLocaleTimeString(navigator.language, {
					hour: '2-digit',
					minute:'2-digit'
				});
216
				str = str + " - " + scheduleEnd + "";
217
			} else {
218
				str += "";
219
220
221
222
			}

		}
		return str;
223
	}
224
	
225
	/* Check if the given track is currently playing */
226
	function isActive(entry, currentTrack) {
David Trattnig's avatar
David Trattnig committed
227
		if (currentTrack != null && entry.track_num == currentTrack.track_num) {
228
229
230
231
232
			return true;
		}
		return false;
	}

233

234
	/* Hack to load external CSS into the Web Component */
235
236
237
238
239
240
241
	function loadExternalCss(root, file) {
		let element = document.createElement("link");
		element.setAttribute("rel", "stylesheet");
		element.setAttribute("type", "text/css");
		element.setAttribute("href", file);
		root.appendChild(element);
	}
242
	
243

244
245
246
247
	/* Scrolls to the track currently playing */
	function scrollToActiveTrack() {
		if (currentTrackElement != null)
			currentTrackElement.scrollIntoView();
248
	}
249

David Trattnig's avatar
David Trattnig committed
250
251
252
</script>

<style>
253

David Trattnig's avatar
David Trattnig committed
254
255
	* {
		font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
256
257
	}

258
259
	#studio-clock {
		width: calc(100% - 200px);
260
		height: calc(100% - 500px);
David Trattnig's avatar
David Trattnig committed
261
		margin: 0 50px 50px 50px;
262
263
264
265
266
267
268
269
270
271
272
273
274
		display: -webkit-flex;
		display: -ms-flexbox;
		display: flex;
		flex-direction: row;
	}

	#left-column {
		width: 30%;
		padding: 25px;
	}

	#right-column {
		width: 70%;
David Trattnig's avatar
David Trattnig committed
275
		margin-top: 220px;
276
		padding: 25px 25px 25px 50px;
277
278
	}

David Trattnig's avatar
David Trattnig committed
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
	#station-header {
		width: 100%;
		height: 220px;
		padding: 0 0 130px 0;
	}

	#station-wrapper {
		text-align: center;
	}

	#station-name {
		margin: 0;
    	font-size: 3em;
		line-height: 60px;
		color: #CCC;	
		font-variant: all-small-caps;
	}

	#station-logo {
		align-content: left;
		text-align: right;
		margin: 0 0 0 0;
		opacity: 0.88;
		filter: invert(100%);
	}

305
306
307
308
309
310
311
	#current-schedule {
		border: 2px solid #333;
		margin: 20px 20px 40px 20px;
		background-color: #111;		
		height: 100%;
	}

312
313
314
315
316
	#current-schedule,
	#next-schedule {
		margin: 0 0 40px 20px;
	}

317
318
319
320
321
322
323
	#next-schedule {
		background-color:rgb(24, 24, 24);
		margin-right: 20px;
		padding: 12px;
	}

	#current-schedule .schedule-title {
324
		text-align: center;
325
326
327
328
329
330
331
332
333
		height: 100px;		
	}

	#current-schedule .schedule-title h1 {
		color: #ccc;
		font-size: 2.8em;		
		position: relative;
		top: 30%;
		transform: translateY(-50%);		
334
	}
335

336
337
338
339
340
	#next-schedule .schedule-title {
		color: gray !important;
		font-size: 2em;
	}

341
	#playlist {
342
		height: calc(100% - 155px);
343
344
		overflow-y: auto;
		scroll-behavior: smooth; 
345
		padding: 10px;
346
		display: flex;
347
348
	   	align-items: center;
		border-top: 1px solid #333;;
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
	}

	#playlist::-webkit-scrollbar-track
	{
		border-radius: 10px;
		-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
		background-color: rgb(77, 73, 73);
	}

	#playlist::-webkit-scrollbar
	{
		width: 12px;
		background-color: rgb(0, 0, 0);
	}

	#playlist::-webkit-scrollbar-thumb
	{
		border-radius: 10px;
		-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
		background-color: rgb(34, 32, 32);
369
370
	}

371
372
	#playlist ol {
		margin-left: 33px;
373
		height: 95%;
374
375
	}

376
	.playlist-entry {
David Trattnig's avatar
David Trattnig committed
377
		font-size: 1.9em;
378
		padding-left: 53px;
379
		padding-bottom: 13px;
380
		color: #AAA;
381
382
	}

383
	#current-track * {
David Trattnig's avatar
David Trattnig committed
384
		font-size: 1.5em;
385
386
	}

387
	.track-time-left {
388
		margin: 25px 50px;
389
390
	}

391
	.is-active {
392
393
		color: rgb(43, 241, 36);
		font-weight: bold;
394
395
396
		padding-left: 0;
	}

397
398
	.is-active .track-title::before {
		content: "\00a0\00a0▶\00a0\00a0\00a0";
399
		font-size: larger; 
400
		color: rgb(43, 241, 36);
401
402
	}

403
404
405
406
407
408
409
	.is-active .track-time-left {
		color: rgb(43, 241, 36);
		background-color: #222;
		padding: 5px 15px;
	}

	.error {
David Trattnig's avatar
David Trattnig committed
410
		font-size: 0.8em;
411
412
413
414
415
416
417
		color:red;
		height:100%;
		display : flex;
		align-items : center;
		justify-content: center;
	}

David Trattnig's avatar
David Trattnig committed
418
419
420
421
422
	svg {
		width: 100%;
	}

	.clock-face {
423
424
		stroke: rgb(66, 66, 66);
		fill: black;
David Trattnig's avatar
David Trattnig committed
425
426
427
	}

	.minor {
428
		stroke: rgb(132, 132, 132);
David Trattnig's avatar
David Trattnig committed
429
430
431
432
		stroke-width: 0.5;
	}

	.major {
433
		stroke: rgb(162, 162, 162);
David Trattnig's avatar
David Trattnig committed
434
435
436
437
		stroke-width: 1;
	}

	.hour {
438
		stroke: rgba(255, 255, 255, 0.705);
David Trattnig's avatar
David Trattnig committed
439
440
441
	}

	.minute {
442
		stroke: rgba(255, 255, 255, 0.705);
David Trattnig's avatar
David Trattnig committed
443
444
445
446
447
448
	}

	.second, .second-counterweight {
		stroke: rgb(180,0,0);
	}

David Trattnig's avatar
David Trattnig committed
449
450
451
452
453
	footer {
		width: 100%;
		text-align: center;
		font-size: 0.8em;
		color: gray;
454
		opacity: 0.5;
David Trattnig's avatar
David Trattnig committed
455
	}
456
457
458
459
460
461
462

	footer a {
		color: gray;
		text-decoration: underline;
	}


David Trattnig's avatar
David Trattnig committed
463
464
</style>

465
466


467
468
<div id="studio-clock">
	<div id="left-column" class="column">
David Trattnig's avatar
David Trattnig committed
469
470
471
472
473
474
475
476

		<div id="station-header">
			<div id="station-wrapper">
				<img id="station-logo" src="{logo}" style="width:{logosize}" alt="Radio Station" align="center" />
				<h1 id="station-name">{name}</h1>
			</div>		
		</div>

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
		<svg viewBox='-50 -50 100 100'>
			<circle class='clock-face' r='48'/>

			<!-- markers -->
			{#each [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] as minute}
				<line
					class='major'
					y1='35'
					y2='45'
					transform='rotate({30 * minute})'
				/>

				{#each [1, 2, 3, 4] as offset}
					<line
						class='minor'
						y1='42'
						y2='45'
						transform='rotate({6 * (minute + offset)})'
					/>
				{/each}
			{/each}

			<!-- hour hand -->
			<line
				class='hour'
				y1='2'
				y2='-20'
				transform='rotate({30 * hours + minutes / 2})'
			/>
David Trattnig's avatar
David Trattnig committed
506

507
			<!-- minute hand -->
David Trattnig's avatar
David Trattnig committed
508
			<line
509
510
511
512
				class='minute'
				y1='4'
				y2='-30'
				transform='rotate({6 * minutes + seconds / 10})'
David Trattnig's avatar
David Trattnig committed
513
			/>
514
515
516
517
518
519
520
521
522
523
524

			<!-- second hand -->
			<g transform='rotate({6 * seconds})'>
				<line class='second' y1='10' y2='-38'/>
				<line class='second-counterweight' y1='10' y2='2'/>
			</g>
		</svg>
	</div>

	<div id="right-column" class="column">

525
		{#await promise}
526
527
528
529
			<div class="spinner-border mt-5" role="status">
				<span class="sr-only">Loading...</span>
			</div>
		{:then value}
530
			{initComponent(value)}
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
				<div id="current-schedule">
					<div class="schedule-title">
						<h1>
						{#if hasValidSchedule(value)}						
								{@html displayShowName(value.current_schedule)} {displayShowSchedule(value.current_schedule)}
							{:else}
								<span class="error">{nocurrentschedule}</span>
							{/if}						
						</h1>
					</div>
					<div id="playlist">

						{#if hasValidPlaylist(value)}
							<ol>
							{#each value.current_playlist.entries as entry, index}
								{#if isActive(entry, value.current_track)}
								
								<li id="current-playlist-entry" class="playlist-entry is-active" bind:this={currentTrackElement}>
									<!-- <span class="play-icon">&#9654;</span> -->
									<span class="track-title">{displayTitle(entry)}</span>
									<span class="track-time-left">({formatTime(timeLeft)})</span>
								</li>

								{:else}

								<li class="playlist-entry">
									<span class="track-title">{displayTitle(entry)}</span>
									<span class="track-duration">({formatTime(entry.track_duration)})</span>
								</li>	

								{/if}

							{/each}
							</ol>
567
						{:else}
568
569
570
571
572
573
574
575
							{#if value.current_track}
								<div id="current-track" class="is-active">
									<h2>
										<span class="track-title">{displayTitle(value.track)}</span>
										<span class="track-time-left">{formatTime(timeLeft)}</span>
									</h2>
								</div>
							{/if}								
576
						{/if}
577
578
579
					</div>

				</div> 
580
581


582

583
584
585
586
587
			{#if value.current_schedule}
				<div id="next-schedule">
					<h3 class="schedule-title">{@html displayShowName(value.next_schedule)} {displayShowSchedule(value)}</h3>
				</div>		
			{/if}				
588
		{:catch error}
589
			<div class="error"><p>{error}</p></div>
590
		{/await}
591

592
593

		<footer>
David Trattnig's avatar
David Trattnig committed
594
			<a href="https://gitlab.servus.at/aura/engine-clock">Engine Clock v{version}</a> is fuelled by <a href="https://gitlab.servus.at/aura/engine">AURA Engine</a>
595
		</footer>
596
597
	</div>

David Trattnig's avatar
David Trattnig committed
598
599
</div>