Headless Improvements (#41)

This commit is contained in:
Ollie Taylor
2023-07-10 16:35:58 +01:00
committed by GitHub
parent 980588930a
commit 16366ec7f3
54 changed files with 1406 additions and 1034 deletions
+3
View File
@@ -0,0 +1,3 @@
export { default as HeadlessTimeline } from './headless-timeline.svelte';
export { default as PlayerStack } from './player-stack.svelte';
export { default as PlayerWidget } from './player-widget.svelte';
@@ -0,0 +1,426 @@
<script lang="ts">
import { Pause, Play, SpeakerWave } from '@inqling/svelte-icons/heroicon-20-solid';
import { clsx } from 'clsx';
import { AudioPlayer, episode_progress, user_preferences } from 'svelte-podcast';
import type { EpisodeDetails } from 'svelte-podcast/audio/stores';
import { A11yIcon, Skip, Spinner, Timestamp } from './utility';
export let src: string | undefined;
export let metadata: EpisodeDetails = {};
export let hide_timestamps: boolean = false;
export let hide_playback_rate: boolean = false;
export let skip_back: number = 30;
export let skip_forward: number = 10;
export let playback_rate_values: number[] = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
const { class: ClassName, ...rest } = $$restProps;
</script>
<AudioPlayer {src} {metadata} let:Player let:action let:episode>
<div
class={clsx('svpod--container svpod--reset', ClassName)}
data-loaded={episode.is_loaded ? 'true' : 'false'}
{...rest}
>
<div class="svpod--container--row svpod--row--controls">
<button
on:click={() => action.skip_back(10)}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={skip_back} type="backward" />
</button>
<!-- toggle play -->
{#if episode.is_loaded}
<button
on:click={() => action.toggle()}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<A11yIcon
icon={episode.is_playing ? Pause : Play}
label={episode.is_playing ? 'Pause' : 'Play'}
/>
</button>
{:else}
<div class="svpod--reset svpod--toggle-pause">
<A11yIcon icon={Spinner} label="Waiting for audio..." />
</div>
{/if}
<button
on:click={() => action.skip_forward(10)}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={skip_forward} type="forward" />
</button>
</div>
<div class="svpod--artwork svpod--row--artwork">
<span class="svpod--aspect--square" />
<slot>
<div class="svpod--artwork--placeholder">
<span><SpeakerWave style="width:1em; height: 1em;" /></span>
</div>
</slot>
</div>
<div class="svpod--timeline svpod--row--timeline">
<Player.AudioProgress />
</div>
{#if !hide_timestamps || !hide_playback_rate}
<div class="svpod--container--row svpod--row--timestamps">
{#if !hide_timestamps}
<div class="svpod--timestamp">
<Timestamp
value={$episode_progress?.current_time || 0}
force_hours={episode.timestamp_hours}
/>
</div>
<div style="flex-grow: 1" />
<div class="svpod--timestamp">
<Timestamp value={episode.duration || 0} />
</div>
{/if}
{#if !hide_playback_rate}
<select
class="svpod--playback-rate"
value={$user_preferences.playback_rate}
on:change={(e) => {
const value = parseFloat(e.currentTarget.value);
user_preferences.set_playback_rate(value);
}}
>
{#each playback_rate_values as value}
<option {value}>
{#if Number.isInteger(value)}
{value}.0
{:else}
{value}
{/if}
</option>
{/each}
</select>
{/if}
</div>
{/if}
</div>
</AudioPlayer>
<style lang="postcss">
.svpod--container {
--fg: var(--svpod--content--base);
--bg: var(--svpod--surface--darker);
--padding: 2px;
--radius: 0.75em;
--inner-radius: calc(var(--radius) - var(--padding));
--svpod--timeline-track--shape--height: 3em;
--svpod--timeline-track--shape--radius: var(--inner-radius);
--svpod--timeline-track--shape--border: 2px;
--svpod--timeline-thumb--shape--height: var(--svpod--timeline-track--shape--height);
--svpod--timeline-thumb--shape--width: 3px;
--svpod--timeline-thumb--shape--radius: 1px;
--svpod--timeline-thumb--shape--border: 0px;
background-color: var(--bg);
color: var(--fg);
display: inline-flex;
flex-direction: column;
align-items: stretch;
font-size: 16px;
line-height: 1em;
width: auto;
max-width: 100%;
border-radius: var(--radius);
font-weight: 600;
padding: var(--padding);
gap: 0;
}
.svpod--container--row {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: flex-end;
width: 100%;
min-width: max-content;
border-radius: var(--inner-radius);
gap: var(--padding);
}
.svpod--container--row:last-child {
border-top: 1px solid var(--svpod--surface--base);
border-top-right-radius: 0;
border-top-left-radius: 0;
}
.svpod--row--artwork {
order: 0;
}
.svpod--row--timeline {
order: 1;
}
.svpod--row--controls {
order: 2;
justify-content: center;
}
.svpod--row--timestamps {
order: 3;
min-height: 32px;
gap: 0.5em;
}
div.svpod--timeline {
display: flex;
align-items: center;
justify-content: stretch;
flex-grow: 1;
flex-shrink: 1;
font-size: 1em;
background: none;
}
.svpod--toggle-pause {
align-items: center;
border: none;
display: flex;
font-size: 1em;
height: 3em;
justify-content: center;
width: 3em;
border-radius: var(--inner-radius);
}
button.svpod--toggle-pause {
cursor: pointer;
}
.svpod--toggle-pause :global(svg) {
width: 1.5em;
height: 1.5em;
}
div.svpod--timestamp {
background-color: var(--bg);
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 1em;
line-height: 1em;
pointer-events: none;
border-radius: var(--inner-radius);
width: max-content;
flex-grow: 0;
text-align: center;
padding: 0 0.1em;
}
select.svpod--playback-rate {
background-color: var(--bg);
appearance: none;
padding: 0 0.75em;
color: var(--fg);
overflow: hidden;
letter-spacing: 0.5px;
font-size: 0.8em;
line-height: 1em;
text-transform: uppercase;
font-weight: 600;
text-align: center;
min-width: 48px;
outline-color: var(--svpod--accent--lighter);
cursor: pointer;
margin: 0;
border-radius: var(--inner-radius);
}
select.svpod--playback-rate,
select.svpod--playback-rate:hover,
select.svpod--playback-rate:focus {
border: none;
shadow: none;
}
select.svpod--playback-rate:focus {
--fg: var(--svpod--accent--lighter);
}
button.svpod--toggle-pause,
select.svpod--playback-rate {
background-color: var(--bg);
color: var(--fg);
}
button.svpod--toggle-pause:hover,
select.svpod--playback-rate:hover {
--bg: var(--svpod--surface--base);
--fg: var(--svpod--content--lighter);
background-color: var(--bg);
color: var(--fg);
}
div.svpod--artwork {
position: relative;
border-radius: var(--inner-radius);
overflow: hidden;
}
div.svpod--artwork > :global(div),
div.svpod--artwork > :global(img) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.svpod--artwork--placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--svpod--accent--lighter);
font-size: 2.5em;
}
.svpod--artwork--placeholder :global(svg) {
position: relative;
z-index: 1;
}
.svpod--artwork--placeholder::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
opacity: 0.5;
background: linear-gradient(45deg, var(--svpod--accent--base), transparent);
}
span.svpod--aspect--square {
display: block;
position: relative;
line-height: 0;
padding-bottom: 100%;
}
.svpod--timeline {
:global(input[type='range']) {
--track--shape--height: calc(var(--svpod--timeline-track--shape--height));
--thumb-border-offset: calc(var(--svpod--timeline-thumb--shape--border) * 2);
--thumb--shape--height: calc(
var(--svpod--timeline-thumb--shape--height) - var(--thumb-border-offset)
);
--svpod--timeline-track--bg: var(--svpod--surface--base);
--svpod--timeline-track--border: var(--bg);
--svpod--timeline-thumb--bg: var(--svpod--content--base);
--svpod--timeline-thumb--border: var(--bg);
font-size: 1em;
--borders: calc(var(--svpod--timeline-thumb--shape--border) * 2);
--no-shadow: 0px 0px 0px transparent;
height: calc(var(--thumb--shape--height) + var(--borders));
appearance: none;
margin: 0;
width: 100%;
background: transparent;
}
:global(input[type='range']:hover) {
--svpod--timeline-track--bg: var(--svpod--accent--darker);
--svpod--timeline-track--border: var(--svpod--accent--base);
--svpod--timeline-thumb--bg: var(--svpod--accent--lighter);
}
:global(input[type='range']:focus) {
outline: none;
}
:global(input[type='range']::-webkit-slider-runnable-track) {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
box-shadow: var(--no-shadow);
background: var(--svpod--timeline-track--bg);
border-radius: var(--svpod--timeline-track--shape--radius);
border: var(--svpod--timeline-track--shape--border) solid
var(--svpod--timeline-track--border);
}
:global(input[type='range']::-webkit-slider-thumb) {
--offset: calc(var(--svpod--timeline-track--shape--border) * -1);
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid
var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
-webkit-appearance: none;
margin-top: var(--offset);
}
:global(input[type='range']:focus::-webkit-slider-runnable-track) {
background: var(--svpod--timeline-track--bg);
}
:global(input[type='range']::-moz-range-track) {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
box-shadow: var(--no-shadow);
background: var(--svpod--timeline-track--bg);
border-radius: var(--svpod--timeline-track--shape--radius);
border: var(--svpod--timeline-track--shape--border) solid
var(--svpod--timeline-track--border);
}
:global(input[type='range']::-moz-range-thumb) {
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid
var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
}
:global(input[type='range']::-ms-track) {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
background: transparent;
border-color: transparent;
color: transparent;
}
:global(input[type='range']::-ms-fill-lower),
:global(input[type='range']::-ms-fill-upper) {
background: var(--svpod--timeline-track--bg);
border: var(--svpod--timeline-track--shape--border) solid
var(--svpod--timeline-track--border);
border-radius: calc(var(--svpod--timeline-track--shape--radius) * 2);
box-shadow: var(--no-shadow);
}
:global(input[type='range']::-ms-thumb) {
margin-top: 1px;
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid
var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
}
:global(input[type='range']:focus::-ms-fill-lower),
:global(input[type='range']:focus::-ms-fill-upper) {
background: var(--svpod--timeline-track--bg);
}
}
</style>
@@ -0,0 +1,341 @@
<script lang="ts">
import { Pause, Play } from '@inqling/svelte-icons/heroicon-20-solid';
import { clsx } from 'clsx';
import { AudioPlayer, user_preferences } from 'svelte-podcast';
import type { EpisodeDetails } from 'svelte-podcast/audio/stores';
import { A11yIcon, Skip, Spinner, Timestamp } from './utility';
export let src: string | undefined;
export let metadata: EpisodeDetails = {};
export let hide_duration: boolean = false;
export let hide_current_time: boolean = false;
export let hide_playback_rate: boolean = false;
export let hide_skip_back: boolean = false;
export let hide_skip_forward: boolean = false;
export let skip_back: number = 30;
export let skip_forward: number = 10;
export let playback_rate_values: number[] = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
const { class: ClassName, ...rest } = $$restProps;
</script>
<AudioPlayer {src} {metadata} let:Player let:action let:episode>
<div
class={clsx('svpod--container svpod--reset', ClassName)}
data-loaded={episode.is_loaded ? 'true' : 'false'}
{...rest}
>
{#if !hide_skip_back}
<button
on:click={() => action.skip_back(skip_back)}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={skip_back} type="backward" />
</button>
{/if}
<!-- toggle play -->
{#if episode.is_loaded}
<button
on:click={() => action.toggle()}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<A11yIcon
icon={episode.is_playing ? Pause : Play}
label={episode.is_playing ? 'Pause' : 'Play'}
/>
</button>
{:else}
<div class="svpod--reset svpod--toggle-pause">
<A11yIcon icon={Spinner} label="Waiting for audio..." />
</div>
{/if}
{#if !hide_skip_forward}
<button
on:click={() => action.skip_forward(skip_forward)}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={skip_forward} type="forward" />
</button>
{/if}
{#if !hide_current_time}
<div class="svpod--timestamp">
<Timestamp value={episode.current_time} force_hours={episode.timestamp_hours} />
</div>
{/if}
<div class="svpod--timeline">
<Player.AudioProgress />
</div>
{#if !hide_duration}
<div class="svpod--timestamp">
<Timestamp value={episode.duration} />
</div>
{/if}
{#if !hide_playback_rate}
<select
class="svpod--playback-rate"
value={$user_preferences.playback_rate}
on:change={(e) => {
const value = parseFloat(e.currentTarget.value);
action.set_playback_rate(value);
}}
>
{#each playback_rate_values as value}
<option {value}>
{#if Number.isInteger(value)}
{value}.0
{:else}
{value}
{/if}
</option>
{/each}
</select>
{/if}
</div>
</AudioPlayer>
<style lang="postcss">
.svpod--container {
--fg: var(--svpod--content--base);
--bg: var(--svpod--surface--darker);
--padding: 2px;
--radius: 0.5em;
--inner-radius: calc(var(--radius) - var(--padding));
--svpod--timeline-track--shape--height: 3em;
--svpod--timeline-track--shape--radius: var(--inner-radius);
--svpod--timeline-track--shape--border: 2px;
--svpod--timeline-thumb--shape--height: var(--svpod--timeline-track--shape--height);
--svpod--timeline-thumb--shape--width: 3px;
--svpod--timeline-thumb--shape--radius: 1px;
--svpod--timeline-thumb--shape--border: 0px;
background-color: var(--bg);
color: var(--fg);
display: inline-flex;
flex-direction: row;
align-items: stretch;
font-size: 16px;
line-height: 1em;
min-width: 320px;
max-width: 100%;
border-radius: var(--radius);
font-weight: 600;
padding: var(--padding);
gap: var(--padding);
}
div.svpod--timeline {
display: flex;
align-items: center;
justify-content: stretch;
flex-grow: 1;
flex-shrink: 1;
font-size: 1em;
background: none;
}
.svpod--toggle-pause {
align-items: center;
border: none;
display: flex;
font-size: 1em;
height: 3em;
justify-content: center;
width: 3em;
border-radius: var(--inner-radius);
}
button.svpod--toggle-pause {
cursor: pointer;
}
.svpod--toggle-pause :global(svg) {
width: 1.5em;
height: 1.5em;
}
div.svpod--timestamp {
background-color: var(--bg);
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 1em;
line-height: 1em;
pointer-events: none;
border-radius: var(--inner-radius);
width: max-content;
flex-grow: 0;
text-align: center;
padding: 0 0.1em;
}
select.svpod--playback-rate {
background-color: var(--bg);
appearance: none;
padding: 0 0.75em;
color: var(--fg);
overflow: hidden;
letter-spacing: 0.5px;
font-size: 0.8em;
line-height: 1em;
text-transform: uppercase;
font-weight: 600;
text-align: center;
min-width: 48px;
outline-color: var(--svpod--accent--lighter);
cursor: pointer;
margin: 0;
border-radius: var(--inner-radius);
}
select.svpod--playback-rate,
select.svpod--playback-rate:hover,
select.svpod--playback-rate:focus {
border: none;
shadow: none;
}
select.svpod--playback-rate:focus {
--fg: var(--svpod--accent--lighter);
}
button.svpod--toggle-pause,
select.svpod--playback-rate {
background-color: var(--bg);
color: var(--fg);
}
button.svpod--toggle-pause:hover,
select.svpod--playback-rate:hover {
--bg: var(--svpod--surface--base);
--fg: var(--svpod--content--lighter);
background-color: var(--bg);
color: var(--fg);
}
.svpod--timeline {
:global(input[type='range']) {
--track--shape--height: calc(var(--svpod--timeline-track--shape--height));
--thumb-border-offset: calc(var(--svpod--timeline-thumb--shape--border) * 2);
--thumb--shape--height: calc(
var(--svpod--timeline-thumb--shape--height) - var(--thumb-border-offset)
);
--svpod--timeline-track--bg: var(--svpod--surface--base);
--svpod--timeline-track--border: var(--bg);
--svpod--timeline-thumb--bg: var(--svpod--content--base);
--svpod--timeline-thumb--border: var(--bg);
font-size: 1em;
--borders: calc(var(--svpod--timeline-thumb--shape--border) * 2);
--no-shadow: 0px 0px 0px transparent;
height: calc(var(--thumb--shape--height) + var(--borders));
appearance: none;
margin: 0;
width: 100%;
background: transparent;
}
:global(input[type='range']:hover) {
--svpod--timeline-track--bg: var(--svpod--accent--darker);
--svpod--timeline-track--border: var(--svpod--accent--base);
--svpod--timeline-thumb--bg: var(--svpod--accent--lighter);
}
:global(input[type='range']:focus) {
outline: none;
}
:global(input[type='range']::-webkit-slider-runnable-track) {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
box-shadow: var(--no-shadow);
background: var(--svpod--timeline-track--bg);
border-radius: var(--svpod--timeline-track--shape--radius);
border: var(--svpod--timeline-track--shape--border) solid
var(--svpod--timeline-track--border);
}
:global(input[type='range']::-webkit-slider-thumb) {
--offset: calc(var(--svpod--timeline-track--shape--border) * -1);
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid
var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
-webkit-appearance: none;
margin-top: var(--offset);
}
:global(input[type='range']:focus::-webkit-slider-runnable-track) {
background: var(--svpod--timeline-track--bg);
}
:global(input[type='range']::-moz-range-track) {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
box-shadow: var(--no-shadow);
background: var(--svpod--timeline-track--bg);
border-radius: var(--svpod--timeline-track--shape--radius);
border: var(--svpod--timeline-track--shape--border) solid
var(--svpod--timeline-track--border);
}
:global(input[type='range']::-moz-range-thumb) {
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid
var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
}
:global(input[type='range']::-ms-track) {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
background: transparent;
border-color: transparent;
color: transparent;
}
:global(input[type='range']::-ms-fill-lower),
:global(input[type='range']::-ms-fill-upper) {
background: var(--svpod--timeline-track--bg);
border: var(--svpod--timeline-track--shape--border) solid
var(--svpod--timeline-track--border);
border-radius: calc(var(--svpod--timeline-track--shape--radius) * 2);
box-shadow: var(--no-shadow);
}
:global(input[type='range']::-ms-thumb) {
margin-top: 1px;
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid
var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
}
:global(input[type='range']:focus::-ms-fill-lower),
:global(input[type='range']:focus::-ms-fill-upper) {
background: var(--svpod--timeline-track--bg);
}
}
</style>
@@ -1,10 +1,10 @@
<script lang="ts">
import { secondsToTimestamp } from '../../utility';
import { seconds_to_timestamp } from 'svelte-podcast';
export let value: number;
export let force_hours = false;
$: timestamp = secondsToTimestamp(value, force_hours);
$: timestamp = seconds_to_timestamp(value, force_hours);
</script>
<span style="width:{timestamp.length}ch">{timestamp}</span>
+2 -2
View File
@@ -1,8 +1,8 @@
<script>
import { AudioLoader } from 'svelte-podcast';
import { Audio } from 'svelte-podcast';
</script>
<AudioLoader />
<Audio />
<!-- your layout -->
@@ -2,8 +2,8 @@
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { user_preferences } from '../user';
import { info, warn } from '../utility/package/log';
import { audio_element } from './audio-element';
import { announce } from '../utility';
import { audio_element } from './stores/audio-element';
$: $audio_element;
/* onMount(() => {
@@ -15,14 +15,17 @@
onMount(() => {
return user_preferences.subscribe((prefs) => {
const el = get(audio_element);
if (!el) return warn('no audio element found');
info('setting preferences :: ', prefs);
if (!el) return announce.warn('no audio element found');
announce.info('setting preferences :: ', prefs);
el.volume = prefs.volume;
el.playbackRate = prefs.playback_rate;
});
});
</script>
<!-- TODO: move styles to headless -->
<svelte:head>
<style>
:root {
-138
View File
@@ -1,138 +0,0 @@
import clamp from 'just-clamp';
import { derived, get, type Readable } from 'svelte/store';
import type { EpisodeAttributes, EpisodeDetails } from '../types';
import { user_preferences, user_progress } from '../user';
import { warn } from '../utility/package/log';
import { audio_element } from './audio-element';
import { episode_details } from './episode-details';
const default_episode_attributes = {
will_autoplay: false,
is_paused: true,
duration: 0,
start_at: 0,
details: null,
} satisfies Omit<EpisodeAttributes, 'src'>;
type EpisodeAttributesStore = Readable<EpisodeAttributes | null>;
const episode_attributes = derived([audio_element, episode_details], ([$audio, $details], set) => {
if (!$audio) return set(null);
function set_value() {
if (!$audio?.src) return null;
set({
...default_episode_attributes,
src: $audio.src,
duration: $audio.duration,
details: $details,
will_autoplay: $audio.autoplay,
is_paused: $audio.paused,
start_at: user_progress.get($audio.src) ?? 0,
});
}
$audio.addEventListener('loadeddata', set_value);
$audio.addEventListener('pause', set_value);
$audio.addEventListener('playing', set_value);
return () => {
$audio.removeEventListener('loadeddata', set_value);
$audio.removeEventListener('pause', set_value);
$audio.removeEventListener('playing', set_value);
};
}) satisfies EpisodeAttributesStore;
type HandleType = 'toggle' | 'set';
const no_element = (action: string) => warn(`could not ${action} :: no audio element exists yet`);
export const episode_audio = {
subscribe: episode_attributes.subscribe,
load: (src: string, details: EpisodeDetails) => {
user_progress.save();
const el = get(audio_element);
if (!el) return no_element('load');
el.src = src;
el.muted = false;
// using user_progress
const start_at = user_progress.get(src) || 0;
el.currentTime = start_at;
// using user_preferences
const preferences = get(user_preferences);
el.playbackRate = preferences.playback_rate;
el.volume = preferences.volume;
episode_details.set(details);
},
unload: () => {
user_progress.save();
const el = get(audio_element);
if (!el) return no_element('unload');
el.src = '';
episode_details.set(null);
},
play: (t: HandleType = 'set') => {
const el = get(audio_element);
if (!el) return no_element('play');
if (t === 'toggle') {
el.paused ? el.play() : el.pause();
} else {
el.play();
}
},
pause: (t: HandleType = 'set') => {
user_progress.save();
const el = get(audio_element);
if (!el) return no_element('pause');
if (t === 'toggle') {
el.paused ? el.play() : el.pause();
} else {
el.pause();
}
},
mute: (t: HandleType = 'set') => {
const el = get(audio_element);
if (!el) return no_element('mute');
if (t === 'toggle') {
el.muted = !el.muted;
} else {
el.muted = true;
}
},
unmute: (t: HandleType = 'set') => {
const el = get(audio_element);
if (!el) return no_element('unmute');
if (t === 'toggle') {
el.muted = !el.muted;
} else {
el.muted = false;
}
},
seek(seconds: number, from: 'from-start' | 'from-end' = 'from-start') {
const el = get(audio_element);
if (!el) return no_element('seek');
if (from === 'from-end') {
el.currentTime = clamp(0, el.duration, el.duration - seconds);
} else {
el.currentTime = clamp(0, el.duration, seconds);
}
},
skip(seconds: number, type: 'forward' | 'backward' = 'forward') {
const el = get(audio_element);
if (!el) return no_element('skip');
if (type === 'backward') {
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
} else {
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
}
},
};
-4
View File
@@ -1,4 +0,0 @@
import { writable } from 'svelte/store';
import type { EpisodeDetails } from '../types';
export const episode_details = writable<EpisodeDetails | null>(null);
+2 -4
View File
@@ -1,4 +1,2 @@
export { default as AudioLoader } from './audio-loader.svelte';
export * from './episode-data';
export * from './episode-details';
export * from './episode-progress';
export { default as Audio } from './component.svelte';
export { episode_audio, episode_details, episode_progress } from './stores';
@@ -1,7 +1,7 @@
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { get, readable } from 'svelte/store';
import { user_preferences } from '../user';
import { user_preferences } from '../../user';
export const audio_element = readable<HTMLAudioElement | null>(null, (set) => {
if (!browser) return;
+207
View File
@@ -0,0 +1,207 @@
import clamp from 'just-clamp';
import { derived, get, type Readable } from 'svelte/store';
import { user_preferences, user_progress } from '../../user';
import { announce } from '../../utility';
import { audio_element } from './audio-element';
import { episode_details, type EpisodeDetails } from './episode-details';
export type EpisodeState = {
will_autoplay: boolean;
is_paused: boolean;
duration: number;
src: string;
start_at: number;
details: EpisodeDetails | null;
};
const default_episode_state = {
will_autoplay: false,
is_paused: true,
duration: 0,
start_at: 0,
details: null,
} satisfies Omit<EpisodeState, 'src'>;
/**
* Episode state store
*/
const episode_state = derived([audio_element, episode_details], ([$audio, $details], set) => {
if (!$audio) return set(null);
function set_value() {
if (!$audio?.src) return null;
set({
...default_episode_state,
src: $audio.src,
duration: $audio.duration,
details: $details,
will_autoplay: $audio.autoplay,
is_paused: $audio.paused,
start_at: user_progress.get($audio.src) ?? 0,
});
}
$audio.addEventListener('loadeddata', set_value);
$audio.addEventListener('pause', set_value);
$audio.addEventListener('playing', set_value);
return () => {
$audio.removeEventListener('loadeddata', set_value);
$audio.removeEventListener('pause', set_value);
$audio.removeEventListener('playing', set_value);
};
}) satisfies Readable<EpisodeState | null>;
type HANDLE_TYPE = 'toggle' | 'set';
/**
* Use audio element
* @param {string} action - Action to perform
* @returns {HTMLAudioElement} - Audio element
* @throws {string} - Error message if no audio element exists
*/
const use_element = (action: string): HTMLAudioElement => {
const el = get(audio_element);
if (!el) throw announce.warn(`could not ${action} :: no audio element exists yet`);
return el;
};
/**
* Load audio
* @param {string} src - Audio source
* @param {EpisodeDetails} details - Episode details
*/
const load_audio = (src: string, details: EpisodeDetails): void => {
user_progress.save();
const el = use_element('load');
el.src = src;
el.muted = false;
// using user_progress
const start_at = user_progress.get(src) || 0;
el.currentTime = start_at;
// using user_preferences
const preferences = get(user_preferences);
el.playbackRate = preferences.playback_rate;
el.volume = preferences.volume;
episode_details.set(details);
};
/**
* Unload audio
*/
const unload_audio = (): void => {
user_progress.save();
const el = use_element('unload');
el.src = '';
episode_details.set(null);
};
/**
* Play audio
* @param {HANDLE_TYPE} t - Handle type
*/
const play_audio = (t: HANDLE_TYPE = 'set'): void => {
const el = use_element('play');
if (t === 'toggle') {
el.paused ? el.play() : el.pause();
} else {
el.play();
}
};
/**
* Pause audio
* @param {HANDLE_TYPE} t - Handle type
*/
const pause_audio = (t: HANDLE_TYPE = 'set'): void => {
user_progress.save();
const el = use_element('pause');
if (t === 'toggle') {
el.paused ? el.play() : el.pause();
} else {
el.pause();
}
};
/**
* Mute audio
* @param {HANDLE_TYPE} t - Handle type
*/
const mute_audio = (t: HANDLE_TYPE = 'set'): void => {
const el = use_element('mute');
if (t === 'toggle') {
el.muted = !el.muted;
} else {
el.muted = true;
}
};
/**
* Unmute audio
* @param {HANDLE_TYPE} t - Handle type
*/
const unmute_audio = (t: HANDLE_TYPE = 'set'): void => {
const el = use_element('unmute');
if (t === 'toggle') {
el.muted = !el.muted;
} else {
el.muted = false;
}
};
/**
* Seek to time audio
* @param {number} seconds - Seconds to seek
* @param {'from-start' | 'from-end'} from - Seek from start or end
*/
const seek_audio = (seconds: number, from: 'from-start' | 'from-end' = 'from-start'): void => {
const el = use_element('seek');
if (from === 'from-end') {
el.currentTime = clamp(0, el.duration, el.duration - seconds);
} else {
el.currentTime = clamp(0, el.duration, seconds);
}
};
/**
* Skip by about audio
* @param {number} seconds - Seconds to skip
* @param {'forward' | 'backward'} type - Skip forward or backward
*/
const skip_audio = (seconds: number, type: 'forward' | 'backward' = 'forward'): void => {
const el = use_element('skip');
if (type === 'backward') {
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
} else {
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
}
};
/**
* Episode audio object
*/
export const episode_audio = {
/**
* Subscribes to episode audio store
*/
subscribe: episode_state.subscribe,
load: load_audio,
unload: unload_audio,
play: play_audio,
pause: pause_audio,
mute: mute_audio,
unmute: unmute_audio,
seek: seek_audio,
skip: skip_audio,
};
+12
View File
@@ -0,0 +1,12 @@
import { writable } from 'svelte/store';
export interface EpisodeDetails {
[key: string]: string | number | boolean;
}
/**
* Episode details store.
*
* @returns {Writable<EpisodeDetails>} A Svelte writable store that contains metadata for the current episode.
*/
export const episode_details = writable<EpisodeDetails | null>(null);
@@ -1,28 +1,43 @@
import { derived, type Readable } from 'svelte/store';
import type { EpisodeProgress } from '../types';
import { secondsToTimestamp } from '../utility';
import { seconds_to_timestamp } from '../../utility';
import { audio_element } from './audio-element';
export type EpisodeProgress = {
current_time: number;
timestamp: string;
has_ended: boolean;
};
const default_episode_progress = {
current_time: 0,
timestamp: '00:00',
has_ended: false,
} satisfies EpisodeProgress;
type ProgressStore = Readable<EpisodeProgress>;
/**
* Episode progress store.
*
* @returns {Readable<EpisodeProgress>} A Svelte readable store that contains the current progress of the audio episode.
*/
export const episode_progress = derived(audio_element, ($audio, set) => {
if (!$audio) return set(default_episode_progress);
/**
* Set episode progress value.
*/
function set_value() {
if (!$audio) return;
set({
current_time: $audio.currentTime,
timestamp: secondsToTimestamp($audio.currentTime),
timestamp: seconds_to_timestamp($audio.currentTime),
has_ended: $audio.ended,
});
}
$audio.addEventListener('timeupdate', () => set_value());
/**
* Remove event listener.
*/
return () => $audio.removeEventListener('timeupdate', () => set_value());
}) satisfies ProgressStore;
}) satisfies Readable<EpisodeProgress>;
+4
View File
@@ -0,0 +1,4 @@
export * from './audio-element';
export * from './episode-data';
export * from './episode-details';
export * from './episode-progress';
-145
View File
@@ -1,145 +0,0 @@
<script lang="ts">
import { episode_audio, episode_progress } from '../audio';
import { info } from '../utility/package/log';
export let step = 10;
let was_paused = true;
function handle_drag_start(t: string) {
info('drag_start :: ', t);
was_paused = $episode_audio?.is_paused || true;
episode_audio.pause();
}
function handle_drag_end(t: string) {
info('drag_end :: ', t);
if (was_paused) return;
else episode_audio.play();
}
</script>
<input
class={$$props.class}
style="display:block; width:100%;"
type="range"
data-paused={$episode_audio?.is_paused ? 'true' : 'false'}
min={0}
{step}
max={$episode_audio?.duration || step}
value={$episode_progress.current_time}
on:change={(e) => episode_audio.seek(e.currentTarget.valueAsNumber)}
on:touchstart={() => handle_drag_start('touchstart')}
on:mousedown={() => handle_drag_start('mousedown')}
on:touchend={() => handle_drag_end('touchend')}
on:mouseup={() => handle_drag_end('mouseup')}
/>
<style>
input[type='range'] {
--track--shape--height: calc(var(--svpod--timeline-track--shape--height));
--thumb-border-offset: calc(var(--svpod--timeline-thumb--shape--border) * 2);
--thumb--shape--height: calc(
var(--svpod--timeline-thumb--shape--height) - var(--thumb-border-offset)
);
--svpod--timeline-track--bg: var(--svpod--surface--base);
--svpod--timeline-track--border: var(--bg);
--svpod--timeline-thumb--bg: var(--svpod--content--base);
--svpod--timeline-thumb--border: var(--bg);
font-size: 1em;
--borders: calc(var(--svpod--timeline-thumb--shape--border) * 2);
--no-shadow: 0px 0px 0px transparent;
height: calc(var(--thumb--shape--height) + var(--borders));
appearance: none;
margin: 0;
width: 100%;
background: transparent;
}
input[type='range']:hover {
--svpod--timeline-track--bg: var(--svpod--accent--darker);
--svpod--timeline-track--border: var(--svpod--accent--base);
--svpod--timeline-thumb--bg: var(--svpod--accent--lighter);
}
input[type='range']:focus {
outline: none;
}
input[type='range']::-webkit-slider-runnable-track {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
box-shadow: var(--no-shadow);
background: var(--svpod--timeline-track--bg);
border-radius: var(--svpod--timeline-track--shape--radius);
border: var(--svpod--timeline-track--shape--border) solid var(--svpod--timeline-track--border);
}
input[type='range']::-webkit-slider-thumb {
--offset: calc(var(--svpod--timeline-track--shape--border) * -1);
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
-webkit-appearance: none;
margin-top: var(--offset);
}
input[type='range']:focus::-webkit-slider-runnable-track {
background: var(--svpod--timeline-track--bg);
}
input[type='range']::-moz-range-track {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
box-shadow: var(--no-shadow);
background: var(--svpod--timeline-track--bg);
border-radius: var(--svpod--timeline-track--shape--radius);
border: var(--svpod--timeline-track--shape--border) solid var(--svpod--timeline-track--border);
}
input[type='range']::-moz-range-thumb {
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
}
input[type='range']::-ms-track {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
background: transparent;
border-color: transparent;
color: transparent;
}
input[type='range']::-ms-fill-lower,
input[type='range']::-ms-fill-upper {
background: var(--svpod--timeline-track--bg);
border: var(--svpod--timeline-track--shape--border) solid var(--svpod--timeline-track--border);
border-radius: calc(var(--svpod--timeline-track--shape--radius) * 2);
box-shadow: var(--no-shadow);
}
input[type='range']::-ms-thumb {
margin-top: 1px;
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
}
input[type='range']:focus::-ms-fill-lower,
input[type='range']:focus::-ms-fill-upper {
background: var(--svpod--timeline-track--bg);
}
</style>
+2 -3
View File
@@ -1,3 +1,2 @@
export { default as HeadlessTimeline } from './headless-timeline.svelte';
export { default as PlayerStack } from './player-stack.svelte';
export { default as PlayerWidget } from './player-widget.svelte';
export { default as AudioPlayer } from './player.svelte';
export { default as AudioProgress } from './progress.svelte';
-322
View File
@@ -1,322 +0,0 @@
<script lang="ts">
import { Pause, Play, SpeakerWave } from '@inqling/svelte-icons/heroicon-20-solid';
import { clsx } from 'clsx';
import { HeadlessTimeline } from '.';
import { episode_audio, episode_progress } from '../audio';
import { user_preferences } from '../user';
import { A11yIcon, Skip, Spinner, Timestamp } from './utility';
type WithElement = {
timestamps: boolean;
playback_rate: boolean;
skip_back: number;
skip_forward: number;
};
const default_options = {
timestamps: false,
playback_rate: false,
skip_back: 10,
skip_forward: 10,
} satisfies WithElement;
export let include: Partial<WithElement> = {};
$: options = { ...default_options, ...include };
$: is_playing = $episode_audio?.is_paused === false;
$: timestamp_hours = Boolean($episode_audio?.duration && $episode_audio.duration >= 3600);
export let playback_rate_values: number[] = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
$: is_loaded = Boolean($episode_audio?.src);
const { class: ClassName, ...rest } = $$restProps;
</script>
<div
class={clsx('svpod--container svpod--reset', ClassName)}
data-loaded={is_loaded ? 'true' : 'false'}
{...rest}
>
<div class="svpod--container--row svpod--row--controls">
<button
on:click={() => options.skip_back && episode_audio.skip(options.skip_back, 'backward')}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={options.skip_back} type="backward" />
</button>
<!-- toggle play -->
{#if is_loaded}
<button
on:click={() => is_loaded && episode_audio.play('toggle')}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<A11yIcon icon={is_playing ? Pause : Play} label={is_playing ? 'Pause' : 'Play'} />
</button>
{:else}
<div class="svpod--reset svpod--toggle-pause">
<A11yIcon icon={Spinner} label="Waiting for audio..." />
</div>
{/if}
<button
on:click={() =>
options.skip_forward && episode_audio.skip(options.skip_forward, 'forward')}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={options.skip_forward} type="forward" />
</button>
</div>
<div class="svpod--artwork svpod--row--artwork">
<span class="svpod--aspect--square" />
<slot>
<div class="svpod--artwork--placeholder">
<span><SpeakerWave style="width:1em; height: 1em;" /></span>
</div>
</slot>
</div>
<div class="svpod--timeline svpod--row--timeline">
<HeadlessTimeline />
</div>
{#if options.timestamps || options.playback_rate}
<div class="svpod--container--row svpod--row--timestamps">
{#if options.timestamps}
<div class="svpod--timestamp">
<Timestamp
value={$episode_progress?.current_time || 0}
force_hours={timestamp_hours}
/>
</div>
<div style="flex-grow: 1" />
<div class="svpod--timestamp">
<Timestamp value={$episode_audio?.duration || 0} />
</div>
{/if}
{#if options.playback_rate}
<select
class="svpod--playback-rate"
value={$user_preferences.playback_rate}
on:change={(e) => {
const value = parseFloat(e.currentTarget.value);
user_preferences.set.playback_rate(value);
}}
>
{#each playback_rate_values as value}
<option {value}>
{#if Number.isInteger(value)}
{value}.0
{:else}
{value}
{/if}
</option>
{/each}
</select>
{/if}
</div>
{/if}
</div>
<style>
.svpod--container {
--fg: var(--svpod--content--base);
--bg: var(--svpod--surface--darker);
--padding: 2px;
--radius: 0.75em;
--inner-radius: calc(var(--radius) - var(--padding));
--svpod--timeline-track--shape--height: 3em;
--svpod--timeline-track--shape--radius: var(--inner-radius);
--svpod--timeline-track--shape--border: 2px;
--svpod--timeline-thumb--shape--height: var(--svpod--timeline-track--shape--height);
--svpod--timeline-thumb--shape--width: 3px;
--svpod--timeline-thumb--shape--radius: 1px;
--svpod--timeline-thumb--shape--border: 0px;
background-color: var(--bg);
color: var(--fg);
display: inline-flex;
flex-direction: column;
align-items: stretch;
font-size: 16px;
line-height: 1em;
width: auto;
max-width: 100%;
border-radius: var(--radius);
font-weight: 600;
padding: var(--padding);
gap: 0;
}
.svpod--container--row {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: flex-end;
width: 100%;
min-width: max-content;
border-radius: var(--inner-radius);
gap: var(--padding);
}
.svpod--container--row:last-child {
border-top: 1px solid var(--svpod--surface--base);
border-top-right-radius: 0;
border-top-left-radius: 0;
}
.svpod--row--artwork {
order: 0;
}
.svpod--row--timeline {
order: 1;
}
.svpod--row--controls {
order: 2;
justify-content: center;
}
.svpod--row--timestamps {
order: 3;
min-height: 32px;
gap: 0.5em;
}
div.svpod--timeline {
display: flex;
align-items: center;
justify-content: stretch;
flex-grow: 1;
flex-shrink: 1;
font-size: 1em;
background: none;
}
.svpod--toggle-pause {
align-items: center;
border: none;
display: flex;
font-size: 1em;
height: 3em;
justify-content: center;
width: 3em;
border-radius: var(--inner-radius);
}
button.svpod--toggle-pause {
cursor: pointer;
}
.svpod--toggle-pause :global(svg) {
width: 1.5em;
height: 1.5em;
}
div.svpod--timestamp {
background-color: var(--bg);
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 1em;
line-height: 1em;
pointer-events: none;
border-radius: var(--inner-radius);
width: max-content;
flex-grow: 0;
text-align: center;
padding: 0 0.1em;
}
select.svpod--playback-rate {
background-color: var(--bg);
appearance: none;
padding: 0 0.75em;
color: var(--fg);
overflow: hidden;
letter-spacing: 0.5px;
font-size: 0.8em;
line-height: 1em;
text-transform: uppercase;
font-weight: 600;
text-align: center;
min-width: 48px;
outline-color: var(--svpod--accent--lighter);
cursor: pointer;
margin: 0;
border-radius: var(--inner-radius);
}
select.svpod--playback-rate,
select.svpod--playback-rate:hover,
select.svpod--playback-rate:focus {
border: none;
shadow: none;
}
select.svpod--playback-rate:focus {
--fg: var(--svpod--accent--lighter);
}
button.svpod--toggle-pause,
select.svpod--playback-rate {
background-color: var(--bg);
color: var(--fg);
}
button.svpod--toggle-pause:hover,
select.svpod--playback-rate:hover {
--bg: var(--svpod--surface--base);
--fg: var(--svpod--content--lighter);
background-color: var(--bg);
color: var(--fg);
}
div.svpod--artwork {
position: relative;
border-radius: var(--inner-radius);
overflow: hidden;
}
div.svpod--artwork > :global(div),
div.svpod--artwork > :global(img) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.svpod--artwork--placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--svpod--accent--lighter);
font-size: 2.5em;
}
.svpod--artwork--placeholder :global(svg) {
position: relative;
z-index: 1;
}
.svpod--artwork--placeholder::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
opacity: 0.5;
background: linear-gradient(45deg, var(--svpod--accent--base), transparent);
}
span.svpod--aspect--square {
display: block;
position: relative;
line-height: 0;
padding-bottom: 100%;
}
</style>
-235
View File
@@ -1,235 +0,0 @@
<script lang="ts">
import { Pause, Play } from '@inqling/svelte-icons/heroicon-20-solid';
import { clsx } from 'clsx';
import { HeadlessTimeline } from '.';
import { episode_audio, episode_progress } from '../audio';
import { user_preferences } from '../user';
import { A11yIcon, Skip, Spinner, Timestamp } from './utility';
type WithElement = {
duration: boolean;
current_time: boolean;
playback_rate: boolean;
skip_back: number | null;
skip_forward: number | null;
};
const default_options = {
duration: false,
current_time: false,
playback_rate: false,
skip_back: null,
skip_forward: null,
} satisfies WithElement;
export let include: Partial<WithElement> = {};
$: options = { ...default_options, ...include };
$: is_playing = $episode_audio?.is_paused === false;
$: current_time = options.current_time && ($episode_progress?.current_time || 0);
$: duration = options.duration && ($episode_audio?.duration || 0);
$: timestamp_hours = Boolean($episode_audio?.duration && $episode_audio.duration >= 3600);
export let playback_rate_values: number[] = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
$: is_loaded = Boolean($episode_audio?.src);
const { class: ClassName, ...rest } = $$restProps;
</script>
<div
class={clsx('svpod--container svpod--reset', ClassName)}
data-loaded={is_loaded ? 'true' : 'false'}
{...rest}
>
{#if options.skip_back}
<button
on:click={() => options.skip_back && episode_audio.skip(options.skip_back, 'backward')}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={options.skip_back} type="backward" />
</button>
{/if}
<!-- toggle play -->
{#if is_loaded}
<button
on:click={() => is_loaded && episode_audio.play('toggle')}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<A11yIcon icon={is_playing ? Pause : Play} label={is_playing ? 'Pause' : 'Play'} />
</button>
{:else}
<div class="svpod--reset svpod--toggle-pause">
<A11yIcon icon={Spinner} label="Waiting for audio..." />
</div>
{/if}
{#if options.skip_forward}
<button
on:click={() =>
options.skip_forward && episode_audio.skip(options.skip_forward, 'forward')}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={options.skip_forward} type="forward" />
</button>
{/if}
{#if current_time !== false}
<div class="svpod--timestamp">
<Timestamp value={current_time} force_hours={timestamp_hours} />
</div>
{/if}
<div class="svpod--timeline">
<HeadlessTimeline />
</div>
{#if duration !== false}
<div class="svpod--timestamp">
<Timestamp value={duration} />
</div>
{/if}
{#if options.playback_rate}
<select
class="svpod--playback-rate"
value={$user_preferences.playback_rate}
on:change={(e) => {
const value = parseFloat(e.currentTarget.value);
user_preferences.set.playback_rate(value);
}}
>
{#each playback_rate_values as value}
<option {value}>
{#if Number.isInteger(value)}
{value}.0
{:else}
{value}
{/if}
</option>
{/each}
</select>
{/if}
</div>
<style>
.svpod--container {
--fg: var(--svpod--content--base);
--bg: var(--svpod--surface--darker);
--padding: 2px;
--radius: 0.5em;
--inner-radius: calc(var(--radius) - var(--padding));
--svpod--timeline-track--shape--height: 3em;
--svpod--timeline-track--shape--radius: var(--inner-radius);
--svpod--timeline-track--shape--border: 2px;
--svpod--timeline-thumb--shape--height: var(--svpod--timeline-track--shape--height);
--svpod--timeline-thumb--shape--width: 3px;
--svpod--timeline-thumb--shape--radius: 1px;
--svpod--timeline-thumb--shape--border: 0px;
background-color: var(--bg);
color: var(--fg);
display: inline-flex;
flex-direction: row;
align-items: stretch;
font-size: 16px;
line-height: 1em;
min-width: 320px;
max-width: 100%;
border-radius: var(--radius);
font-weight: 600;
padding: var(--padding);
gap: var(--padding);
}
div.svpod--timeline {
display: flex;
align-items: center;
justify-content: stretch;
flex-grow: 1;
flex-shrink: 1;
font-size: 1em;
background: none;
}
.svpod--toggle-pause {
align-items: center;
border: none;
display: flex;
font-size: 1em;
height: 3em;
justify-content: center;
width: 3em;
border-radius: var(--inner-radius);
}
button.svpod--toggle-pause {
cursor: pointer;
}
.svpod--toggle-pause :global(svg) {
width: 1.5em;
height: 1.5em;
}
div.svpod--timestamp {
background-color: var(--bg);
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 1em;
line-height: 1em;
pointer-events: none;
border-radius: var(--inner-radius);
width: max-content;
flex-grow: 0;
text-align: center;
padding: 0 0.1em;
}
select.svpod--playback-rate {
background-color: var(--bg);
appearance: none;
padding: 0 0.75em;
color: var(--fg);
overflow: hidden;
letter-spacing: 0.5px;
font-size: 0.8em;
line-height: 1em;
text-transform: uppercase;
font-weight: 600;
text-align: center;
min-width: 48px;
outline-color: var(--svpod--accent--lighter);
cursor: pointer;
margin: 0;
border-radius: var(--inner-radius);
}
select.svpod--playback-rate,
select.svpod--playback-rate:hover,
select.svpod--playback-rate:focus {
border: none;
shadow: none;
}
select.svpod--playback-rate:focus {
--fg: var(--svpod--accent--lighter);
}
button.svpod--toggle-pause,
select.svpod--playback-rate {
background-color: var(--bg);
color: var(--fg);
}
button.svpod--toggle-pause:hover,
select.svpod--playback-rate:hover {
--bg: var(--svpod--surface--base);
--fg: var(--svpod--content--lighter);
background-color: var(--bg);
color: var(--fg);
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { onMount } from 'svelte';
import { AudioProgress } from '.';
import { episode_audio, episode_progress } from '../audio';
import type { EpisodeDetails } from '../audio/stores';
import { user_preferences } from '../user';
import { seconds_to_timestamp } from '../utility';
export let src: string | undefined;
export let metadata: EpisodeDetails = {};
let mounted = false;
$: is_loaded = Boolean($episode_audio?.src);
$: is_playing = $episode_audio?.is_paused === false;
$: current_time = $episode_progress?.current_time || 0;
$: duration = $episode_audio?.duration || 0;
$: timestamp_hours = Boolean($episode_audio?.duration && $episode_audio.duration >= 3600);
$: timestamp = duration && seconds_to_timestamp(duration);
onMount(() => {
if (!src) {
mounted = true;
return;
}
episode_audio.load(src, metadata || {});
mounted = true;
});
$: mounted && src && episode_audio.load(src, metadata || {});
/**
* Skips the audio forward by a specified number of seconds.
* @param {number} seconds - The number of seconds to skip forward.
*/
const skip_forward = (seconds: number) => episode_audio.skip(seconds, 'forward');
/**
* Skips the audio backward by a specified number of seconds.
* @param {number} seconds - The number of seconds to skip backward.
*/
const skip_back = (seconds: number) => episode_audio.skip(seconds, 'backward');
/**
* Seeks to a specific time in the audio.
* @param {number} value - The time to seek to, in seconds.
*/
const seek_to = (value: number) => episode_audio.seek(value);
/**
* Toggles the audio playback between play and pause.
*/
const toggle = () => episode_audio.play('toggle');
/**
* Starts playing the audio.
*/
const play = () => episode_audio.play('set');
/**
* Pauses the audio.
*/
const pause = () => episode_audio.pause('set');
</script>
<slot
Player={{ AudioProgress }}
action={{
set_playback_rate: user_preferences.set_playback_rate,
set_volume: user_preferences.set_volume,
skip_forward,
skip_back,
seek_to,
toggle,
play,
pause,
}}
episode={{
is_loaded,
is_playing,
current_time,
duration,
timestamp_hours,
timestamp,
}}
/>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import { episode_audio, episode_progress } from '../audio';
import { announce } from '../utility';
export let step = 10;
let was_paused = true;
function handle_drag_start(t: string) {
announce.info('drag_start :: ', t);
was_paused = $episode_audio?.is_paused || true;
episode_audio.pause();
}
function handle_drag_end(t: string) {
announce.info('drag_end :: ', t);
if (was_paused) return;
else episode_audio.play();
}
</script>
{#if $episode_audio && $episode_progress}
<input
class={$$restProps.class}
style="display:block; width:100%;"
type="range"
data-paused={$episode_audio.is_paused ? 'true' : 'false'}
min={0}
{step}
max={$episode_audio.duration || step}
value={$episode_progress.current_time}
on:change={(e) => episode_audio.seek(e.currentTarget.valueAsNumber)}
on:touchstart={() => handle_drag_start('touchstart')}
on:mousedown={() => handle_drag_start('mousedown')}
on:touchend={() => handle_drag_end('touchend')}
on:mouseup={() => handle_drag_end('mouseup')}
/>
{:else}
<input
class={$$restProps.class}
style="display:block; width:100%;"
type="range"
data-paused={true}
min={0}
step={1}
max={1}
value={0}
disabled
readonly
/>
{/if}
+1
View File
@@ -1,4 +1,5 @@
export * from './audio';
export * from './components';
export { AudioPlayer as default } from './components';
export * from './user';
export * from './utility';
-28
View File
@@ -1,28 +0,0 @@
export interface EpisodeDetails {
[key: string]: string | number | boolean;
}
export type EpisodeProgress = { current_time: number; timestamp: string; has_ended: boolean };
export type EpisodeAttributes = {
will_autoplay: boolean;
is_paused: boolean;
duration: number;
src: string;
start_at: number;
details: EpisodeDetails | null;
};
export type AudioLoadOptions = {
autoplay: boolean;
start_at?: number;
};
export interface UserPreferences {
playback_rate: number;
volume: number;
}
export type UserProgress = {
[key: string]: number;
};
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './preferences';
export * from './progress';
export * from './user-preferences';
export * from './user-progress';
-21
View File
@@ -1,21 +0,0 @@
import clamp from 'just-clamp';
import { persisted } from 'svelte-local-storage-store';
import type { UserPreferences } from '../types';
const _default_user_preferences = { playback_rate: 1, volume: 1 } satisfies UserPreferences;
const _user_preferences = persisted<UserPreferences>('USER_PREFERENCE', _default_user_preferences);
export const user_preferences = {
subscribe: _user_preferences.subscribe,
set: {
playback_rate: (value: number) => {
const playback_rate = clamp(value, 0.5, 5);
return _user_preferences.update((prefs) => ({ ...prefs, playback_rate }));
},
volume: (value: number) => {
const volume = clamp(value, 0, 1);
return _user_preferences.update((prefs) => ({ ...prefs, volume }));
},
},
clear: () => _user_preferences.set(_default_user_preferences),
};
-27
View File
@@ -1,27 +0,0 @@
import { persisted } from 'svelte-local-storage-store';
import { get } from 'svelte/store';
import { episode_audio, episode_progress } from '../audio';
import type { UserProgress } from '../types';
import { get_pathname_from_url } from '../utility';
import { info } from '../utility/package/log';
const _default_user_progress = {} satisfies UserProgress;
const _user_progress = persisted<UserProgress>('USER_PROGRESS', _default_user_progress);
const save = () => {
const audio = get(episode_audio);
if (!audio?.src) return;
info('saving progress: ', audio.src);
const pathname = get_pathname_from_url(audio.src);
const current_time = get(episode_progress).current_time;
_user_progress.update((prev) => ({ ...prev, [pathname]: current_time }));
};
export const user_progress = {
subscribe: _user_progress.subscribe,
get: (src: string) => get(_user_progress)[get_pathname_from_url(src)],
save,
clear: () => _user_progress.set(_default_user_progress),
};
+49
View File
@@ -0,0 +1,49 @@
import clamp from 'just-clamp';
import { persisted } from 'svelte-local-storage-store';
export interface UserPreferences {
playback_rate: number;
volume: number;
}
const default_user_preferences = { playback_rate: 1, volume: 1 };
const user_preferences_store = persisted<UserPreferences>(
'USER_PREFERENCE',
default_user_preferences,
);
/**
* Sets the playback rate for the user
* @param value - The playback rate value
* @returns A Promise that resolves with the updated user preferences
*/
const set_playback_rate = (value: number) => {
const playback_rate = clamp(value, 0.5, 5);
return user_preferences_store.update((prefs) => ({ ...prefs, playback_rate }));
};
/**
* Sets the volume for the user
* @param value - The volume value
* @returns A Promise that resolves with the updated user preferences
*/
const set_volume = (value: number) => {
const volume = clamp(value, 0, 1);
return user_preferences_store.update((prefs) => ({ ...prefs, volume }));
};
/**
* Clears user preferences store
*/
const clear_store = () => user_preferences_store.set(default_user_preferences);
export const user_preferences = {
/**
* Subscribes to user preferences store
*/
subscribe: user_preferences_store.subscribe,
set_playback_rate,
set_volume,
clear: clear_store,
};
+53
View File
@@ -0,0 +1,53 @@
import { persisted } from 'svelte-local-storage-store';
import { get } from 'svelte/store';
import { episode_audio, episode_progress } from '../audio';
import { announce, use_url } from '../utility';
export type UserProgress = {
[key: string]: number;
};
const default_user_progress = {};
const user_progress_store = persisted<UserProgress>('USER_PROGRESS', default_user_progress);
/**
* Saves user progress
*/
const save = () => {
const audio = get(episode_audio);
if (!audio?.src) return;
announce.info('saving progress: ', audio.src);
const pathname = use_url(audio.src).pathname;
const current_time = get(episode_progress).current_time;
user_progress_store.update((prev) => ({ ...prev, [pathname]: current_time }));
};
/**
* Gets user progress for a given audio source
* @param {string} src - Audio source
* @returns {number} - User progress for the given audio source
*/
const get_user_progress = (src: string) => {
const pathname = use_url(src).pathname;
const store = get(user_progress_store) as UserProgress;
return store[pathname];
};
/**
* Clears user progress store
*/
const clear_user_progress = () => user_progress_store.set(default_user_progress);
export const user_progress = {
/**
* Subscribes to user preferences store
*/
subscribe: user_progress_store.subscribe,
get: get_user_progress,
clear: clear_user_progress,
save,
};
+49
View File
@@ -0,0 +1,49 @@
import { dev } from '$app/environment';
const use_logger = {
info: console.info,
error: console.error,
warn: console.warn,
};
type Logger = keyof typeof use_logger;
/**
* Log function.
* @param type - Type of logger.
* @param content - Content to log.
*/
function log(type: Logger, ...content: unknown[]) {
const logger = use_logger[type];
// If type is info and not in dev mode, return
if (type === 'info' && !dev) return;
// Log message
logger(
'%c🔊 svelte-podcast:',
'color: #FF3E00; background-color: rgba(255, 62, 0, 0.15); padding: 4px 8px; border-radius:4px;',
...content,
);
}
/**
* Object containing logger functions.
*/
export const announce = {
/**
* Log info message.
* @param content - Content to log.
*/
info: (...content: unknown[]) => log('info', ...content),
/**
* Log warning message.
* @param content - Content to log.
*/
warn: (...content: unknown[]) => log('warn', ...content),
/**
* Log error message.
* @param content - Content to log.
*/
error: (...content: unknown[]) => log('error', ...content),
};
-4
View File
@@ -1,4 +0,0 @@
export const get_pathname_from_url = (src: string) => {
if (src.startsWith('http')) return new URL(src).pathname;
else return new URL(src, 'https://svelte.dev').pathname;
};
+2 -1
View File
@@ -1,2 +1,3 @@
export * from './get-pathname-from-url';
export * from './announce';
export * from './seconds-to-timestamp';
export * from './use-url';
-25
View File
@@ -1,25 +0,0 @@
import { dev } from '$app/environment';
const useLogger = {
info: console.info,
error: console.error,
warn: console.warn,
};
type Logger = keyof typeof useLogger;
export function log(type: Logger, ...content: unknown[]) {
const logger = useLogger[type];
if (type === 'info' && !dev) return;
logger(
'%c🔊 svelte-podcast:',
'color: #FF3E00; background-color: rgba(255, 62, 0, 0.15); padding: 4px 8px; border-radius:4px;',
...content,
);
}
export const info = (...content: unknown[]) => log('info', ...content);
export const warn = (...content: unknown[]) => log('warn', ...content);
export const error = (...content: unknown[]) => log('error', ...content);
-7
View File
@@ -1,7 +0,0 @@
export function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number' && !isNaN(value);
}
+28 -15
View File
@@ -1,18 +1,31 @@
function section(t: number) {
if (t < 10) return `0${t}`;
return t;
/**
* Returns a string timestamp in the format of "hh:mm:ss" from a given number of seconds.
* @param seconds - The number of seconds to convert to a timestamp.
* @param force_hours - Whether to always include hours in the timestamp, even if it's 0.
* @returns A string timestamp in the format of "hh:mm:ss".
*/
export function seconds_to_timestamp(seconds: number, force_hours = false) {
const hours = Math.floor(seconds / 3600);
const hours_remainder = seconds % 3600;
const minutes = Math.floor(hours_remainder / 60);
const minutes_remainder = hours_remainder % 60;
const secs = Math.floor(minutes_remainder);
const hours_str = force_hours || hours > 0 ? `${section(hours)}:` : '';
const minutes_str = minutes ? `${section(minutes)}:` : '00:';
const seconds_str = secs ? `${section(secs)}` : '00';
return `${hours_str}${minutes_str}${seconds_str}`;
}
export function secondsToTimestamp(seconds: number, force_hours = false) {
const hh = Math.floor(seconds / 3600);
const hh_remainder = seconds % 3600;
const mm = Math.floor(hh_remainder / 60);
const mm_remainder = hh_remainder % 60;
const ss = Math.floor(mm_remainder);
const hrs = force_hours || hh > 0 ? `${section(hh)}:` : '';
const mins = mm ? `${section(mm)}:` : '00:';
const secs = ss ? `${section(ss)}` : '00';
return `${hrs}${mins}${secs}`;
/**
* Returns a string representation of a number with a leading 0 if it's less than 10.
* @param num - The number to format.
* @returns A string representation of the number with a leading 0 if it's less than 10.
*/
function section(num: number) {
if (num < 10) return `0${num}`;
return num.toString();
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Converts a string to a URL object. If the string is not a valid URL, it will be appended to the Svelte website URL.
* @param href - The string to convert to a URL object.
* @returns A URL object.
*/
const string_to_url = (href: string) => {
try {
return new URL(href);
} catch (e) {
return new URL(href, 'https://svelte.dev');
}
};
/**
* Parses a URL string and returns an object containing its pathname, search, hash, and searchParams.
* @param href - The URL string to parse.
* @returns An object containing the pathname, search, hash, and searchParams of the URL.
*/
export const use_url = (href: string) => {
const { pathname, search, hash, searchParams } = string_to_url(href);
return { pathname, search, hash, searchParams } as const;
};
+1
View File
@@ -0,0 +1 @@
export * from './use-markdown';
+2 -2
View File
@@ -4,7 +4,7 @@
import { Github } from '@inqling/svelte-icons/simple-icons';
import clsx from 'clsx';
import 'highlight.js/styles/github-dark.css';
import { AudioLoader } from 'svelte-podcast';
import { Audio } from 'svelte-podcast';
import '../app.postcss';
const page_links = [
@@ -62,6 +62,6 @@
</div>
</header>
<AudioLoader />
<Audio />
<slot />
+1 -1
View File
@@ -1,6 +1,6 @@
import content from '$src/content/docs.md?raw';
import { use_markdown } from '$src/markdown';
import type { PageServerLoad } from './$types';
import { use_markdown } from './use-markdown';
export const load = (async () => {
const post = await use_markdown(content);
+7 -9
View File
@@ -2,21 +2,18 @@
import { base } from '$app/paths';
import { Section } from '$src/content/components';
// import Docs from '$src/content/docs.md';
//import { PodcastPlayer } from '$src/components/example-player/_player';
import PlayerWidget from '$src/components/example-player/player-widget.svelte';
import { episodes } from '$src/content/episodes';
import { onMount } from 'svelte';
import { episode_audio, PlayerWidget } from 'svelte-podcast';
import type { PageServerData } from './$types';
export let data: PageServerData;
onMount(() => {
// load the episode on mount without any metadata
episode_audio.load(episodes.knomii.src, {
/* optional metadata */
});
});
let audio_src: string | undefined = episodes.knomii.src;
</script>
<!-- <PodcastPlayer title="Example title" /> -->
<div class="relative isolate bg-white">
<svg
class="absolute inset-0 -z-10 h-full w-full stroke-mono-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
@@ -86,8 +83,9 @@
class="-m-2 rounded-xl border border-mono-100 bg-white p-1 shadow-2xl shadow-mono-200 sm:p-2 lg:-m-4 lg:rounded-2xl lg:p-4"
>
<PlayerWidget
src={audio_src}
class="w-full border border-mono-100"
include={{
options={{
duration: true,
current_time: true,
playback_rate: true,
+8 -8
View File
@@ -1,16 +1,16 @@
<script lang="ts">
import PlayerStack from '$src/components/example-player/player-stack.svelte';
import PlayerWidget from '$src/components/example-player/player-widget.svelte';
import PreviewComponent from '$src/components/preview-component.svelte';
import PreviewDataCode from '$src/components/preview-data-code.svelte';
import PreviewData from '$src/components/preview-data.svelte';
import { Section } from '$src/content/components';
import {
episode_audio,
episode_progress,
PlayerStack,
PlayerWidget,
user_preferences,
user_progress,
} from 'svelte-podcast';
import PreviewComponent from './preview-component.svelte';
import PreviewDataCode from './preview-data-code.svelte';
import PreviewData from './preview-data.svelte';
const sources = {
syntax: {
@@ -180,11 +180,11 @@
description="clears all user preferences and resets them to default values"
/>
<PreviewDataCode
code={`user_preferences.set.playback_rate(value: number)`}
code={`user_preferences.set_playback_rate(value: number)`}
description="sets the users prefered playback_rate (speed)"
/>
<PreviewDataCode
code={`user_preferences.set.volume(value: number)`}
code={`user_preferences.set_volume(value: number)`}
description="sets the users prefered volume"
/>
</PreviewData>
@@ -259,7 +259,7 @@
<h6>Playback Rate</h6>
{#each [0.5, 1, 2, 3] as rate}
<button type="button" on:click={() => user_preferences.set.playback_rate(rate)}>
<button type="button" on:click={() => user_preferences.set_playback_rate(rate)}>
{rate}x
</button>
{/each}