Advent of SvelteKit 2022

Christmas VGM Radio

00:00 - 00:01
050

Challenge

Source code

+page.svelte

<script lang="ts">
	import Playlist from './Playlist.svelte';
	import Controls from './Controls.svelte';
	import type { PageData } from './$types';
	import { makeSongStore, paused } from './songs';

	export let data: PageData;

	const selectedSong = makeSongStore(data.current);
	$paused = true; // reset when first loading the page

	$: selectedSong.set(data.current);

	$: if (data.change < 0) {
		selectedSong.prev();
	} else if (data.change > 0) {
		selectedSong.next();
	}
</script>

<svelte:head>
	<link rel="preconnect" href="https://fi.zophar.net/" />
</svelte:head>

<h1>
	Christmas <a href="https://en.wikipedia.org/wiki/Video_game_music">VGM</a> Radio
</h1>
<Playlist {selectedSong} />
<Controls {selectedSong} />

<style>
	a {
		color: var(--red-7);
	}
</style>

+page.ts

import type { PageLoad } from './$types';

export const load: PageLoad = ({ url }) => {
	const params = url.searchParams;
	const change = Number(params.get('change'));
	const current = Number(params.get('current'));

	return {
		change,
		current
	};
};

Controls.svelte

<script lang="ts">
	import { scale } from 'svelte/transition';
	import Backward from './icons/Backward.svelte';
	import ChevronLeft from './icons/ChevronLeft.svelte';
	import ChevronRight from './icons/ChevronRight.svelte';
	import Forward from './icons/Forward.svelte';
	import Pause from './icons/Pause.svelte';
	import Play from './icons/Play.svelte';
	import { type SongStore, paused } from './songs';

	// had to wrap audio tag in browser or duration wasn't populated properly
	import { browser } from '$app/environment';
	import { onMount, tick } from 'svelte';
	import { submitReplaceState } from '$lib/util';

	export let selectedSong: SongStore;
	$: playing = !$paused;

	$: progress = currentTime / duration;
	let currentTime = 0;
	let duration = 1;

	// note: this won't work on iOS since you can't adjust volume via JS
	let volume = 0.5;
	let audio: HTMLAudioElement;

	let loading = true;

	function togglePlay() {
		$paused = !$paused;
	}

	$: if (currentTime >= duration) {
		selectedSong.next();
	}

	let timer: number;
	onMount(() => {
		let once = true;

		if (duration === 1 && !isNaN(audio.duration)) {
			// firefox doesn't populate this sometimes for some reason
			duration = audio.duration;
		}

		if (audio.readyState >= 2) {
			// sometimes audio is already ready and canplay event not fired in Firefox
			loading = false;
		}

		// was having issues with regular store autosub for some reason
		// e.g. if ($selectedSong) { offAndOnAgain(); }
		// The statement was not running
		const unsub = selectedSong.subscribe((val) => {
			if (once) {
				// don't run first time to prevent FF error
				// "The play method is not allowed by the user agent or the platform in the current context, possibly because the user denied permission."
				once = false;
				return;
			}
			// reset current time to prevent race condition where next song starts in the middle
			currentTime = 0;

			// wait to set loading state so we don't get a flash of "loading" in the UI
			clearTimeout(timer);
			timer = setTimeout(() => (loading = true), 250);

			// paused doesn't properly update when audio source changes - https://github.com/sveltejs/svelte/issues/5914
			// so toggle it off and then back on on the next tick
			$paused = true;
			tick().then(() => ($paused = false));
		});

		return () => {
			unsub();
		};
	});

	function rw() {
		// need to access element directly due to https://github.com/sveltejs/svelte/issues/6955
		audio.currentTime -= 10;
	}

	function ff() {
		audio.currentTime += 10;
	}

	function handleRangeInput({ target }: InputEvent) {
		if (!target) return;
		volume = (target as HTMLInputElement).valueAsNumber / 100;
	}

	function prettifyTime(time: number) {
		const seconds = Math.floor(time);
		const minutes = Math.floor(seconds / 60);
		const remainder = seconds - minutes * 60;
		return `${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}`;
	}
</script>

{#if browser}
	<audio
		src={$selectedSong.file}
		bind:paused={$paused}
		bind:volume
		bind:currentTime
		bind:duration
		bind:this={audio}
		on:canplay={() => {
			loading = false;
			clearTimeout(timer);
		}}
	/>
{:else}
	<audio autoplay src={$selectedSong.file} controls class="no-js" />
{/if}

<form class="controls" on:submit={submitReplaceState}>
	<input type="hidden" name="current" value={$selectedSong.id} />
	<div class="progress js-only" style:width="{progress * 100}%" />
	<div class="time js-only">
		<!-- only show when playing because iOS safari won't fire the canplay event if we're not playing -->
		{#if loading && !$paused}
			Loading...
		{:else}
			{prettifyTime(currentTime)} - {prettifyTime(duration)}
		{/if}
	</div>
	<div class="buttons">
		<button aria-label="Previous song" name="change" value="-1"><ChevronLeft /></button>
		<button aria-label="Rewind" on:click={rw} class="js-only" type="button">
			<Backward />
		</button>
		<button
			aria-label={playing ? 'Pause' : 'Play'}
			on:click={togglePlay}
			class="play js-only"
			type="button"
		>
			{#key playing}
				<div transition:scale>
					{#if playing}
						<Pause />
					{:else}
						<Play />
					{/if}
				</div>
			{/key}
		</button>
		<button aria-label="Fast forward" on:click={ff} class="js-only" type="button">
			<Forward />
		</button>
		<button aria-label="Next song" name="change" value="1"><ChevronRight /></button>
	</div>
	<div class="volume js-only">
		<input
			type="range"
			aria-label="Volume"
			style:background="linear-gradient(90deg, rgba(66, 184, 131, 1) 0%, rgba(66, 184, 131, 1) {volume *
				100}%, transparent {volume * 100}%)"
			value={volume * 100}
			on:input={handleRangeInput}
			disabled={!browser}
		/>
		<span
			>{Math.floor(volume * 100)
				.toString()
				.padStart(3, '0')}</span
		>
	</div>
</form>

<style>
	.controls {
		width: 100%;
		position: relative;
		border-radius: var(--radius-2);
		background: var(--gray-8);
		color: white;
		display: grid;
		align-items: center;
		grid-template-columns: 1fr auto 1fr;
		grid-template-areas:
			'p p p'
			'time buttons volume';
		column-gap: 0.5rem;
	}

	@media screen and (max-width: 480px) {
		.controls {
			grid-template-columns: 1fr 1fr;
			grid-template-areas:
				'p p'
				'buttons buttons'
				'time volume';
		}
	}

	.progress {
		background: linear-gradient(90deg, rgba(66, 184, 131, 1) 0%, rgba(0, 48, 255, 1) 100%);
		border-radius: var(--radius-2);
		height: 0.25rem;
		background: var(--gray-4);
		position: relative;
		grid-column: 1 / -1;
		grid-area: p;
	}

	.buttons {
		display: flex;
		align-items: center;
		justify-content: center;
		grid-area: buttons;
	}

	.volume {
		grid-area: volume;
		padding-right: 1rem;
		justify-self: end;
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}

	.volume span {
		/* Prevent changing numbers from changing width */
		font-variant-numeric: tabular-nums;
	}

	input[type='range'] {
		-webkit-appearance: none; /* Hides the slider so that custom slider can be made */
		border: rgba(66, 184, 131, 1) 1px solid;
		height: 4px;
		border-radius: 3px;
		width: 6rem;
	}
	input[type='range']::-webkit-slider-thumb {
		-webkit-appearance: none;
		height: 15px;
		width: 15px;
		background: white;
		border-radius: 10px;
	}
	div :global(svg) {
		transition: 0.3s ease all;
		width: 1.5rem;
		height: 1.5rem;
	}
	div :global(svg:hover) {
		opacity: 0.7;
	}

	button {
		cursor: pointer;
		font-family: inherit;
		font-size: 100%;
		font-weight: inherit;
		line-height: inherit;
		color: inherit;
		-webkit-appearance: button;
		background-color: transparent;
		background-image: none;
		text-transform: none;
		border: none;
	}

	.play {
		background: var(--green-6);
		border-radius: var(--radius-round);
		width: 2rem;
		height: 2rem;
		transform: scale(1.5);
		margin: 0 1rem;
	}

	.play :global(svg) {
		position: absolute;
		top: 50%;
		left: 50%;
		transform: translate(-50%, -50%);
	}

	.time {
		width: 6rem;
		white-space: nowrap;
		grid-area: time;
		padding-left: 1rem;
	}
</style>

Playlist.svelte

<script lang="ts">
	import { goto } from '$app/navigation';
	import Play from './icons/Play.svelte';
	import Playing from './icons/Playing.svelte';
	import { type SongStore, songs, paused } from './songs';

	export let selectedSong: SongStore;
</script>

<ul>
	{#each songs as song}
		{@const isSelected = song.title === $selectedSong.title}
		{@const isPlaying = !$paused && isSelected}
		<li class:selected={isSelected}>
			<div class="album">
				{#if isSelected}
					<div class="overlay" />
				{/if}
				<div class="icon">
					{#if isPlaying}
						<Playing />
					{:else}
						<Play />
					{/if}
				</div>
				<img src={song.cover} alt="{song.title} cover" />
			</div>
			<div class="title">
				{song.title}
				<a
					href="?current={song.id}"
					data-sveltekit-noscroll
					on:click|preventDefault={() =>
						/* we don't want each song selection to push to history state, so override default */
						goto(`?current=${song.id}`, { keepFocus: true, replaceState: true, noScroll: true })}
					><span class="visually-hidden">Play {song.title}</span></a
				>
			</div>
		</li>
	{/each}
</ul>

<style>
	ul {
		width: 100%;
		padding: 0;
	}

	ul :global(svg) {
		width: 1.5rem;
		height: 1.5rem;
	}

	li {
		color: var(--gray-7);
		cursor: pointer;
		display: flex;
		align-items: center;
		padding-right: 1rem;
		margin-bottom: 2rem;
		gap: 1rem;
		transition: 0.3s ease background;
		position: relative;
	}

	li.selected,
	li:hover {
		background: var(--gray-2);
	}

	.album {
		position: relative;
		border-radius: var(--radius-3);
		overflow: hidden;
	}

	.overlay {
		transition: opacity ease-in 0.3s;
		position: absolute;
		top: 0;
		right: 0;
		left: 0;
		bottom: 0;
		background: var(--gray-8);
		opacity: 0.5;
	}

	.overlay:hover {
		opacity: 0;
	}

	.icon {
		pointer-events: none;
		position: absolute;
		color: white;
		top: 50%;
		left: 50%;
		transform: translate(-50%, -50%);
	}

	img {
		width: 5rem;
		height: 5rem;
		object-fit: cover;
	}

	a {
		position: absolute;
		top: 0;
		right: 0;
		left: 0;
		bottom: 0;
	}
</style>

icons/Backward.svelte

<svg
	xmlns="http://www.w3.org/2000/svg"
	fill="none"
	viewBox="0 0 24 24"
	stroke-width="1.5"
	stroke="currentColor"
	class="w-6 h-6"
>
	<path
		stroke-linecap="round"
		stroke-linejoin="round"
		d="M21 16.811c0 .864-.933 1.405-1.683.977l-7.108-4.062a1.125 1.125 0 010-1.953l7.108-4.062A1.125 1.125 0 0121 8.688v8.123zM11.25 16.811c0 .864-.933 1.405-1.683.977l-7.108-4.062a1.125 1.125 0 010-1.953L9.567 7.71a1.125 1.125 0 011.683.977v8.123z"
	/>
</svg>

icons/ChevronLeft.svelte

<svg
	xmlns="http://www.w3.org/2000/svg"
	fill="none"
	viewBox="0 0 24 24"
	stroke-width="1.5"
	stroke="currentColor"
	class="w-6 h-6"
>
	<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>

icons/ChevronRight.svelte

<svg
	xmlns="http://www.w3.org/2000/svg"
	fill="none"
	viewBox="0 0 24 24"
	stroke-width="1.5"
	stroke="currentColor"
	class="w-6 h-6"
>
	<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>

icons/Forward.svelte

<svg
	xmlns="http://www.w3.org/2000/svg"
	fill="none"
	viewBox="0 0 24 24"
	stroke-width="1.5"
	stroke="currentColor"
	class="w-6 h-6"
>
	<path
		stroke-linecap="round"
		stroke-linejoin="round"
		d="M3 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062A1.125 1.125 0 013 16.81V8.688zM12.75 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062a1.125 1.125 0 01-1.683-.977V8.688z"
	/>
</svg>

icons/Pause.svelte

<svg
	xmlns="http://www.w3.org/2000/svg"
	fill="none"
	viewBox="0 0 24 24"
	stroke-width="1.5"
	stroke="currentColor"
	class="w-6 h-6"
>
	<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>

icons/Play.svelte

<svg
	xmlns="http://www.w3.org/2000/svg"
	fill="none"
	viewBox="0 0 24 24"
	stroke-width="1.5"
	stroke="currentColor"
	class="w-6 h-6"
>
	<path
		stroke-linecap="round"
		stroke-linejoin="round"
		d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
	/>
</svg>

icons/Playing.svelte

<div class="playing">
	<span class="playing__bar playing__bar1" />
	<span class="playing__bar playing__bar2" />
	<span class="playing__bar playing__bar3" />
</div>

<style>
	/* https://codepen.io/MoritzGiessmann/pen/XWWovQP */
	.playing {
		background: rgba(255, 255, 255, 0.1);
		width: 2rem;
		height: 2rem;
		border-radius: 0.3rem;
		display: flex;
		justify-content: space-between;
		align-items: flex-end;
		padding: 0.5rem;
		box-sizing: border-box;
	}

	.playing__bar {
		display: inline-block;
		background: white;
		width: 30%;
		height: 100%;
		animation: up-and-down 1.3s ease infinite alternate;
	}

	.playing__bar1 {
		height: 60%;
	}

	.playing__bar2 {
		height: 30%;
		animation-delay: -2.2s;
	}

	.playing__bar3 {
		height: 75%;
		animation-delay: -3.7s;
	}

	@keyframes up-and-down {
		10% {
			height: 30%;
		}

		30% {
			height: 100%;
		}

		60% {
			height: 50%;
		}

		80% {
			height: 75%;
		}

		100% {
			height: 60%;
		}
	}
</style>

icons/Stop.svelte

<svg
	xmlns="http://www.w3.org/2000/svg"
	fill="none"
	viewBox="0 0 24 24"
	stroke-width="1.5"
	stroke="currentColor"
	class="w-6 h-6"
>
	<path
		stroke-linecap="round"
		stroke-linejoin="round"
		d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
	/>
</svg>

songs.ts

import superMario64 from './covers/n64_supermario64.jpg';
import pokemonDiamond from './covers/ds_pokemondiamondversion.jpg';
import majorasMask from './covers/n64_thelegendofzeldamajorasmask.jpg';
import dkc2 from './covers/snes_dkc2final.jpg';
import earthbound from './covers/snes_earthbound.jpg';
import { writable, derived } from 'svelte/store';

export const songs = [
	{
		title: 'Snow Mountain',
		file: 'https://fi.zophar.net/soundfiles/nintendo-64-usf/super-mario-64/11%20Snow%20Mountain.mp3',
		cover: superMario64
	},
	{
		title: 'In a Snow-Bound Land',
		file: 'https://fi.zophar.net/soundfiles/nintendo-snes-spc/donkey-kong-country-2-diddys-kong-quest/25%20In%20a%20Snow-Bound%20Land%20.mp3',
		cover: dkc2
	},
	{
		title: 'Snowman',
		file: 'https://fi.zophar.net/soundfiles/nintendo-snes-spc/earthbound/063%20Snowman.mp3',
		cover: earthbound
	},
	{
		title: 'Snowpoint City (Daytime)',
		file: 'https://fi.zophar.net/soundfiles/nintendo-ds-2sf/pokemon-diamond/163%20Snowpoint%20City%20%28Daytime%29.mp3',
		cover: pokemonDiamond
	},
	{
		title: 'Snowhead Temple',
		file: 'https://fi.zophar.net/soundfiles/nintendo-64-usf/legend-of-zelda-the-majoras-mask/206%20Snowhead%20Temple.mp3',
		cover: majorasMask
	}
].map((song, idx) => ({ ...song, id: idx }));

export const paused = writable(true);

export function makeSongStore(initialIndex = 0) {
	const selectedIdx = writable(initialIndex);
	const selectedSongStore = derived(selectedIdx, ($selectedIdx) => songs[$selectedIdx]);

	return {
		subscribe: selectedSongStore.subscribe,
		next: () => {
			selectedIdx.update((curr) => (curr + 1) % songs.length);
		},
		prev: () => {
			selectedIdx.update((curr) => (curr - 1 + songs.length) % songs.length);
		},
		set: selectedIdx.set
	};
}

export type SongStore = ReturnType<typeof makeSongStore>;