Simplify setup (#46)

This commit is contained in:
Ollie Taylor
2023-07-13 00:27:14 +01:00
committed by GitHub
parent 29ef65733e
commit d18b0c78d0
68 changed files with 1236 additions and 1470 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'svelte-podcast': minor
---
refactor audio element logic
+5
View File
@@ -0,0 +1,5 @@
---
'svelte-podcast': minor
---
rewrite docs with code samples
+9 -1
View File
@@ -10,6 +10,11 @@
"source.organizeImports": true "source.organizeImports": true
} }
}, },
"[javascript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
},
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
@@ -33,5 +38,8 @@
"**/node_modules": true, "**/node_modules": true,
"**/.svelte-kit": true "**/.svelte-kit": true
}, },
"problems.sortOrder": "severity" "problems.sortOrder": "severity",
"javascript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.importModuleSpecifier": "relative",
"todo-tree.tree.scanMode": "workspace only"
} }
+7 -4
View File
@@ -22,7 +22,7 @@
.richtext h4 a, .richtext h4 a,
.richtext h5 a, .richtext h5 a,
.richtext h6 a { .richtext h6 a {
@apply relative underline decoration-transparent decoration-solid underline-offset-2; @apply relative break-all underline decoration-transparent decoration-solid underline-offset-2;
&::before { &::before {
@apply absolute -left-[1.25ch] inline-block font-light leading-normal text-mono-400; @apply absolute -left-[1.25ch] inline-block font-light leading-normal text-mono-400;
@@ -37,16 +37,19 @@
} }
.richtext code.hljs { .richtext code.hljs {
@apply p-0 text-sm; @apply overflow-x-auto p-0 text-sm;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.richtext .codeblock code.hljs { .richtext .codeblock code.hljs {
@apply overflow-hidden rounded-xl border border-mono-200 p-4 font-mono text-base leading-normal shadow-2xl shadow-mono-200; @apply overflow-auto rounded-xl border border-mono-200 p-4 font-mono text-base leading-normal shadow-2xl shadow-mono-200;
} }
.richtext { .richtext {
@apply prose prose-lg prose-slate max-w-none prose-h5:mb-1 prose-h5:mt-10 prose-h5:text-sm prose-h5:font-medium prose-h5:leading-normal prose-h5:text-primary-800 prose-code:before:content-none prose-code:after:content-none; @apply prose prose-slate max-w-none sm:prose-lg prose-headings:px-2 prose-h5:mb-1 prose-h5:mt-10 prose-h5:text-sm prose-h5:font-medium prose-h5:leading-normal prose-h5:text-primary-800 prose-p:px-2 prose-code:before:content-none prose-code:after:content-none;
}
.richtext code:not(.hljs) {
@apply inline whitespace-pre-wrap break-all rounded-md border border-mono-200 bg-white px-1.5 py-0.5 font-mono font-normal tracking-wide text-mono-950;
} }
.richtext td, .richtext td,
-1
View File
@@ -1,2 +1 @@
export { default as PlayerStack } from './player-stack.svelte';
export { default as PlayerWidget } from './player-widget.svelte'; export { default as PlayerWidget } from './player-widget.svelte';
@@ -1,444 +0,0 @@
<script>
import {
Pause,
Play,
SpeakerWave,
} from '@inqling/svelte-icons/heroicon-20-solid';
import {
AudioPlayer,
episode_progress,
user_preferences,
} from 'svelte-podcast';
import { A11yIcon, Skip, Spinner, Timestamp } from './utility';
/** @type {string | undefined} */
export let src;
/** @type {import('svelte-podcast/audio/stores').EpisodeDetails} */
export let metadata = {};
export let hide_timestamps = false;
export let hide_playback_rate = false;
export let skip_back = 30;
export let skip_forward = 10;
export let playback_rate_values = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
</script>
<AudioPlayer {src} {metadata} let:Player let:action let:episode>
<div
{...$$restProps}
class="svpod--container svpod--reset"
data-loaded={episode.is_loaded ? 'true' : 'false'}
>
<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>
@@ -6,7 +6,7 @@
/** @type {string | undefined} */ /** @type {string | undefined} */
export let src; export let src;
/** @type {import('svelte-podcast/audio/stores').EpisodeDetails} */ /** @type {import('svelte-podcast/audio').AudioMetadata} */
export let metadata = {}; export let metadata = {};
export let hide_duration = false; export let hide_duration = false;
@@ -21,11 +21,105 @@
export let playback_rate_values = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4]; export let playback_rate_values = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
</script> </script>
<AudioPlayer {src} {metadata} let:Player let:action let:episode> <svelte:head>
<style>
:root {
--svpod--font: ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--svpod--surface--darker: rgb(0, 0, 0);
--svpod--surface--base: rgb(40, 40, 40);
--svpod--surface--lighter: rgb(60, 60, 60);
--svpod--content--darker: rgb(150, 150, 150);
--svpod--content--base: rgb(200, 200, 200);
--svpod--content--lighter: rgb(255, 255, 255);
--svpod--accent--darker: rgb(75, 15, 0);
--svpod--accent--base: rgb(180, 40, 0);
--svpod--accent--lighter: rgb(255, 55, 25);
--bg: var(--svpod--surface--darker);
--fg: var(--svpod--content--darker);
--svpod--radius--rounded: 9999px;
/* element : timeline */
--svpod--timeline-track--shape--height: 20px;
--svpod--timeline-track--shape--radius: 0;
--svpod--timeline-track--shape--border: 0;
--svpod--timeline-thumb--shape--width: 8px;
--svpod--timeline-thumb--shape--height: 32px;
--svpod--timeline-thumb--shape--radius: 6px;
--svpod--timeline-thumb--shape--border: 2px;
}
.svpod--reset {
text-transform: none;
font-style: normal;
text-indent: 0;
text-shadow: none;
text-align: left;
margin: 0;
padding: 0;
}
.svpod--a11y-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.svpod--button,
.svpod--icon-button {
--fg: var(--svpod--content--base);
--bg: var(--svpod--surface--base);
position: relative;
line-height: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 16px;
border: 0px solid transparent;
border-radius: var(--svpod--radius--rounded);
background-color: var(--bg);
color: var(--fg);
cursor: pointer;
}
.svpod--button:hover,
.svpod--icon-button:hover {
--fg: var(--svpod--content--lighter);
--bg: var(--svpod--surface--lighter);
}
.svpod--button {
padding: 4px calc(1em);
}
.svpod--icon-button {
padding: 8px;
min-width: 48px;
min-height: 48px;
}
</style>
</svelte:head>
<AudioPlayer {src} {metadata} let:Player let:action let:attributes>
<div <div
{...$$restProps} {...$$restProps}
class="svpod--container svpod--reset" class="svpod--container svpod--reset"
data-loaded={episode.is_loaded ? 'true' : 'false'} data-loaded={attributes.is_loaded ? 'true' : 'false'}
> >
{#if !hide_skip_back} {#if !hide_skip_back}
<button <button
@@ -38,15 +132,15 @@
{/if} {/if}
<!-- toggle play --> <!-- toggle play -->
{#if episode.is_loaded} {#if attributes.is_loaded}
<button <button
on:click={() => action.toggle()} on:click={() => action.toggle()}
class="svpod--reset svpod--toggle-pause" class="svpod--reset svpod--toggle-pause"
type="button" type="button"
> >
<A11yIcon <A11yIcon
icon={episode.is_playing ? Pause : Play} icon={attributes.is_paused ? Play : Pause}
label={episode.is_playing ? 'Pause' : 'Play'} label={attributes.is_paused ? 'Play' : 'Pause'}
/> />
</button> </button>
{:else} {:else}
@@ -68,8 +162,8 @@
{#if !hide_current_time} {#if !hide_current_time}
<div class="svpod--timestamp"> <div class="svpod--timestamp">
<Timestamp <Timestamp
value={episode.current_time} value={attributes.current_time}
force_hours={episode.timestamp_hours} force_hours={attributes.duration >= 3600}
/> />
</div> </div>
{/if} {/if}
@@ -80,7 +174,7 @@
{#if !hide_duration} {#if !hide_duration}
<div class="svpod--timestamp"> <div class="svpod--timestamp">
<Timestamp value={episode.duration} /> <Timestamp value={attributes.duration} />
</div> </div>
{/if} {/if}
@@ -153,8 +153,4 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
svg {
/* animation: Spin 1s cubic-bezier(0.5, 0.1, 0.1, 0.8) infinite; */
}
</style> </style>
@@ -1,5 +1,5 @@
<script> <script>
import { seconds_to_timestamp } from 'svelte-podcast'; import { seconds_to_timestamp } from 'svelte-podcast/utility';
/** @type {number} */ /** @type {number} */
export let value; export let value;
+6
View File
@@ -16,4 +16,10 @@ export const episodes = Object.freeze({
title: `Empowerment starts with letting go of control`, title: `Empowerment starts with letting go of control`,
artwork: `https://ssl-static.libsyn.com/p/assets/f/a/8/d/fa8d56d5226884335f2e77a3093c12a1/ep-6.png`, artwork: `https://ssl-static.libsyn.com/p/assets/f/a/8/d/fa8d56d5226884335f2e77a3093c12a1/ep-6.png`,
}, },
svelte: {
src: 'https://media.transistor.fm/6d6c49e4/4cecfd0d.mp3?src=site',
title: 'SvelteKit-superforms with Andreas Söderlund',
artwork:
'https://images.transistor.fm/file/transistor/images/show/12899/medium_1597678946-artwork.jpg',
},
}); });
+18
View File
@@ -1,5 +1,23 @@
import { BROWSER } from 'esm-env';
export const slugify = (/** @type {string} */ str) => export const slugify = (/** @type {string} */ str) =>
encodeURI(str.toLowerCase().trim().replaceAll(' ', '_')); encodeURI(str.toLowerCase().trim().replaceAll(' ', '_'));
export const format_code = (/** @type {string} */ code) => export const format_code = (/** @type {string} */ code) =>
code.replaceAll('\t', ' '); code.replaceAll('\t', ' ');
export const on_this_page = () => {
if (!BROWSER) return [];
let anchorLinks = [];
let aTags = document.getElementsByTagName('a');
for (let i = 0; i < aTags.length; i++) {
let href = aTags[i]?.getAttribute('href');
if (href && href.startsWith('#')) {
anchorLinks.push(href);
}
}
return anchorLinks;
};
+1 -3
View File
@@ -1,5 +1,4 @@
<script> <script>
import { base } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { MetaTags } from 'svelte-meta-tags'; import { MetaTags } from 'svelte-meta-tags';
@@ -10,9 +9,8 @@
export let description = export let description =
'A suite of tools and components to build your own podcast players, and work with RSS podcast data in SvelteKit.'; 'A suite of tools and components to build your own podcast players, and work with RSS podcast data in SvelteKit.';
// TODO: update canonical link with static link
/** @type {string} */ /** @type {string} */
export let canonical = $page.url.href + base; export let canonical = 'https://svelte-podcast.com' + $page.url.pathname;
// TODO: add screenshots under images // TODO: add screenshots under images
/** @type {import('svelte-meta-tags').OpenGraph["images"]}*/ /** @type {import('svelte-meta-tags').OpenGraph["images"]}*/
+10 -4
View File
@@ -1,26 +1,32 @@
<script> <script>
import { slugify } from '$src/layout/helper'; import { on_this_page, slugify } from '$src/layout/helper';
import Metadata from '$src/layout/metadata.svelte'; import Metadata from '$src/layout/metadata.svelte';
import Container from '$src/layout/page/container.svelte'; import Container from '$src/layout/page/container.svelte';
import SectionArticle from '$src/layout/page/section-article.svelte'; import SectionArticle from '$src/layout/page/section-article.svelte';
import Section from '$src/layout/page/section.svelte'; import Section from '$src/layout/page/section.svelte';
import TableModule from '$src/layout/page/table-module.svelte';
import TableSchema from '$src/layout/page/table-schema.svelte'; import TableSchema from '$src/layout/page/table-schema.svelte';
import { onMount } from 'svelte';
/** @type {string}*/ /** @type {string}*/
export let title; export let title;
export let anchor = slugify(title); export let anchor = slugify(title);
const anchor_links = on_this_page();
onMount(() => {
console.log('on_this_page', JSON.stringify(anchor_links, null, 3));
});
</script> </script>
<Metadata {title} /> <Metadata {title} />
<slot name="header" {anchor} {title}> <slot name="header" {anchor} {title}>
<Container as="header" aria-labelledby={anchor}> <Container as="header" aria-labelledby={anchor}>
<div class="richtext pt-20"> <div class="richtext sm:pt-10 lg:pt-20">
<h1 id={anchor} class="my-0">{title}</h1> <h1 id={anchor} class="my-0">{title}</h1>
</div> </div>
</Container> </Container>
</slot> </slot>
<slot {Section} {SectionArticle} {TableModule} {TableSchema} /> <slot {Section} {SectionArticle} {TableSchema} />
+1 -1
View File
@@ -6,7 +6,7 @@
<svelte:element <svelte:element
this={as} this={as}
{...$$restProps} {...$$restProps}
class="relative mx-auto w-full max-w-6xl p-4 md:p-6" class="relative mx-auto w-full max-w-6xl px-2 py-4 md:px-6 md:py-6"
> >
<slot /> <slot />
</svelte:element> </svelte:element>
-33
View File
@@ -1,33 +0,0 @@
<script>
import { Highlight } from 'svelte-highlight';
import lang_ts from 'svelte-highlight/languages/typescript';
/**
* Table Row
* @typedef {Object} Row
* @property {string} method - Method on the module
* @property {string} description - Description of the method
*/
/** @type {Row[]} */
export let rows;
</script>
<div class="w-full overflow-x-auto">
<table class="">
<thead>
<th class="w-1/2">Method</th>
<th class="w-1/2">Description</th>
</thead>
<tbody>
{#each rows as { method, description }}
<tr>
<td class="not-prose">
<Highlight code={method} language={lang_ts} />
</td>
<td class=""> {description} </td>
</tr>
{/each}
</tbody>
</table>
</div>
+3 -3
View File
@@ -14,9 +14,9 @@
export let rows; export let rows;
</script> </script>
<div class="w-full overflow-x-auto"> <div class="-mx-6 min-w-full overflow-x-auto bg-white md:-mx-8">
<table class=""> <table class="my-2 w-max min-w-full">
<thead> <thead class="">
<th class="w-max">Property</th> <th class="w-max">Property</th>
<th class="w-max">Type</th> <th class="w-max">Type</th>
<th>Description</th> <th>Description</th>
+41 -5
View File
@@ -36,6 +36,16 @@
/** @type {Readonly<ResourceLink[]>} */ /** @type {Readonly<ResourceLink[]>} */
export let resources; export let resources;
/**
* @typedef {Object} PodcastLink
* @property {string} label - The label of the page link.
* @property {string} href - The URL of the page link.
* @property {string} src - The URL of the page link.
*/
/** @type {Readonly<PodcastLink[]>} */
export let podcasts;
$: use_is_current = (/** @type {string } */ href) => { $: use_is_current = (/** @type {string } */ href) => {
let href_pathname = href; let href_pathname = href;
@@ -57,11 +67,6 @@
) => { ) => {
if (!is_current_page) return false; if (!is_current_page) return false;
if (typeof document !== 'undefined') {
const el = document.querySelector(`[id="${anchor}"]`);
if (!el) throw new Error(`No element found with id "${anchor}"`);
}
return $page.url.hash.includes(anchor); return $page.url.hash.includes(anchor);
}; };
</script> </script>
@@ -174,6 +179,37 @@
{/each} {/each}
</ul> </ul>
</li> </li>
<li>
<div class="text-xs font-semibold leading-6 text-mono-400">
Svelte Podcasts
</div>
<ul role="list" class="-mx-2 mt-2 space-y-1">
{#each podcasts as link}
{@const is_current = use_is_current(link.href)}
<li>
<a
href={link.href}
target="_blank"
class={clsx(
'group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6',
is_current
? 'bg-mono-50 text-primary-600'
: 'text-mono-700 hover:bg-mono-50 hover:text-primary-600',
)}
>
<img
src={link.src}
height="56"
width="56"
alt=""
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-mono-200 bg-white text-[0.625rem] font-medium text-mono-400 group-hover:border-primary-600 group-hover:text-primary-600"
/>
<span class="truncate">{link.label}</span>
</a>
</li>
{/each}
</ul>
</li>
</ul> </ul>
</nav> </nav>
</div> </div>
+165
View File
@@ -0,0 +1,165 @@
import { BROWSER } from 'esm-env';
import clamp from 'just-clamp';
import { get } from 'svelte/store';
import { user_preferences, user_progress } from '../user';
import { audio_attributes, use_audio_element } from './audio-element';
import { audio_element_source } from './audio-element-source';
import { audio_metadata } from './audio-metadata';
/**
* Load audio
* @param {string} src - Audio source
* @param {import('./audio-metadata').AudioMetadata } metadata - Audio metadata
* @returns {void}
*/
const load = (src, metadata = {}, autoplay = false) => {
if (!BROWSER) return;
// Save the current progress if audio is already loaded
user_progress.save();
// Get user preferences
const preferences = get(user_preferences);
// Set audio element source
audio_element_source.set({
src,
start_at: user_progress.get(src) || 0,
playback_rate: preferences.playback_rate,
volume: preferences.volume,
autoplay: autoplay,
});
// Set metadata for current audio
audio_metadata.set(metadata);
};
/**
* Unload audio
* @returns {void}
*/
const unload = () => {
if (!BROWSER) return;
// Save the current progress if audio is already loaded
user_progress.save();
// Unset audio element source
audio_element_source.set(null);
// Unset metadata
audio_metadata.set(null);
};
/**
* @typedef {'toggle' | 'set'} HANDLE_TYPE
*/
/**
* Play audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const play = (t = 'set') => {
const el = use_audio_element('play');
if (t === 'toggle') {
el.paused ? el.play() : el.pause();
} else {
el.play();
}
};
/**
* Pause audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const pause = (t = 'set') => {
user_progress.save();
const el = use_audio_element('pause');
if (t === 'toggle') {
el.paused ? el.play() : el.pause();
} else {
el.pause();
}
};
/**
* Mute audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const mute = (t = 'set') => {
const el = use_audio_element('mute');
if (t === 'toggle') {
el.muted = !el.muted;
} else {
el.muted = true;
}
};
/**
* Unmute audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const unmute = (t = 'set') => {
const el = use_audio_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="from-start"] - Seek from start or end
* @returns {void}
*/
const seek = (seconds, from = 'from-start') => {
const el = use_audio_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='forward'] - Skip forward or backward
* @returns {void}
*/
const skip = (seconds, type = 'forward') => {
const el = use_audio_element('skip');
if (type === 'backward') {
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
} else {
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
}
};
/**
* Audio controls
*/
export const audio = {
subscribe: audio_attributes.subscribe,
load,
unload,
play,
pause,
mute,
unmute,
seek,
skip,
};
+15
View File
@@ -0,0 +1,15 @@
import { writable } from 'svelte/store';
// TODO: implement autoplay
/**
* @typedef {Object} AudioSource
* @property {string} src - A path or URL to the audio file
* @property {number} start_at - The starting point of the audio
* @property {number} [playback_rate=1] - The playback speed of the audio
* @property {number} [volume=1] - The volume of the audio
* @property {boolean} [autoplay=false] - Whether the audio should play as soon as it's loaded
*/
/** @type {import('svelte/store').Writable<AudioSource | null>} */
export const audio_element_source = writable(null);
+132
View File
@@ -0,0 +1,132 @@
import { BROWSER } from 'esm-env';
import { derived, get } from 'svelte/store';
import { announce } from '../internal';
import { seconds_to_timestamp } from '../utility';
import { audio_element_source } from './audio-element-source';
/**
* @typedef {import('svelte/store').Readable<HTMLAudioElement | undefined>} ReadableAudioElement
*/
/**
* @typedef {import('../user').UserPreferences} Preferences
*/
/**
* @typedef {(value: HTMLAudioElement | undefined) => void} SetAudioElement
*/
const ELEMENT_ID = 'svpod--generated-audio-element';
/** @type {ReadableAudioElement} */
export const audio_element = derived(
[audio_element_source],
([$audio_element_src], /** @type {SetAudioElement} */ set) => {
// If not in a browser environment, return early.
if (BROWSER) {
// Find an existing HTMLAudioElement or create a new one.
const found = document.getElementById(ELEMENT_ID);
const el =
found instanceof HTMLAudioElement
? found
: document.createElement('audio');
// Set the HTMLAudioElement's attributes.
el.id = ELEMENT_ID;
el.setAttribute('preload', 'metadata');
el.muted = false;
el.autoplay = false;
el.controls = false;
if ($audio_element_src) {
el.src = $audio_element_src.src;
el.currentTime = $audio_element_src.start_at;
el.playbackRate = $audio_element_src.playback_rate || 1;
el.volume = $audio_element_src.volume || 1;
el.autoplay = $audio_element_src.autoplay || false;
}
// If a new HTMLAudioElement was created, append it to the body.
if (!found) document.body.appendChild(el);
// Define a function to set the HTMLAudioElement and call it.
const handle_update = () => {
set(el);
$audio_element_src?.autoplay && el.play();
announce.info('Updating audio element');
};
handle_update();
// Add event listeners to the HTMLAudioElement to update the store when it changes.
el.addEventListener('loadeddata', handle_update);
el.addEventListener('pause', handle_update);
el.addEventListener('playing', handle_update);
el.addEventListener('timeupdate', handle_update);
// Return a function to clean up the HTMLAudioElement when the store unsubscribes.
return () => {
// Remove the event listeners from the HTMLAudioElement.
el.removeEventListener('loadeddata', handle_update);
el.removeEventListener('pause', handle_update);
el.removeEventListener('playing', handle_update);
el.removeEventListener('timeupdate', handle_update);
// Remove the HTMLAudioElement from the DOM.
el ? el.remove() : null;
};
} else {
set(undefined);
return () => null;
}
},
undefined,
);
/**
* Use audio element
* @param {string} action - Action being performed
* @returns {HTMLAudioElement} - Audio element
* @throws {string} - Error message if no audio element exists
*/
export const use_audio_element = (action) => {
const el = get(audio_element);
if (!el) {
throw announce.error(`could not ${action} :: no audio element found`);
}
return el;
};
/**
* @typedef {Object} AudioAttributes
* @property {boolean} is_loaded - Whether the audio element is loaded or not
* @property {boolean} is_paused - Whether the audio element is paused or not
* @property {number} current_time - The current time of the audio element in seconds
* @property {number} duration - The duration of the audio element in seconds
* @property {string} timestamp_current - The current time of the audio element in timestamp format (hh:mm:ss)
* @property {string} timestamp_end - The end time of the audio element in timestamp format (hh:mm:ss)
*/
/** @type {import('svelte/store').Readable<AudioAttributes>} */
export const audio_attributes = derived(audio_element, ($el, set) => {
if (!$el) {
return set({
is_loaded: false,
is_paused: true,
current_time: 0,
duration: 0,
timestamp_current: seconds_to_timestamp(0),
timestamp_end: seconds_to_timestamp(0),
});
}
set({
is_loaded: Boolean($el.src),
is_paused: $el.paused,
current_time: $el.currentTime,
duration: $el.duration,
timestamp_current: seconds_to_timestamp($el.currentTime),
timestamp_end: seconds_to_timestamp($el.duration),
});
});
+12
View File
@@ -0,0 +1,12 @@
import { writable } from 'svelte/store';
/**
* @typedef {Object.<string, unknown>} AudioMetadata
*/
/**
* Audio metadata store.
* @type {import('svelte/store').Writable<AudioMetadata | null>}
* @description This store holds the metadata for a single audio source.
*/
export const audio_metadata = writable(null);
-116
View File
@@ -1,116 +0,0 @@
<script>
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { announce } from '../internal';
import { user_preferences } from '../user';
import { audio_element } from './stores/audio-element';
$: $audio_element;
onMount(() => {
return user_preferences.subscribe((prefs) => {
const el = get(audio_element);
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 {
--svpod--font: ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--svpod--surface--darker: rgb(0, 0, 0);
--svpod--surface--base: rgb(40, 40, 40);
--svpod--surface--lighter: rgb(60, 60, 60);
--svpod--content--darker: rgb(150, 150, 150);
--svpod--content--base: rgb(200, 200, 200);
--svpod--content--lighter: rgb(255, 255, 255);
--svpod--accent--darker: rgb(75, 15, 0);
--svpod--accent--base: rgb(180, 40, 0);
--svpod--accent--lighter: rgb(255, 55, 25);
--bg: var(--svpod--surface--darker);
--fg: var(--svpod--content--darker);
--svpod--radius--rounded: 9999px;
/* element : timeline */
--svpod--timeline-track--shape--height: 20px;
--svpod--timeline-track--shape--radius: 0;
--svpod--timeline-track--shape--border: 0;
--svpod--timeline-thumb--shape--width: 8px;
--svpod--timeline-thumb--shape--height: 32px;
--svpod--timeline-thumb--shape--radius: 6px;
--svpod--timeline-thumb--shape--border: 2px;
}
.svpod--reset {
text-transform: none;
font-style: normal;
text-indent: 0;
text-shadow: none;
text-align: left;
margin: 0;
padding: 0;
}
.svpod--a11y-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.svpod--button,
.svpod--icon-button {
--fg: var(--svpod--content--base);
--bg: var(--svpod--surface--base);
position: relative;
line-height: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 16px;
border: 0px solid transparent;
border-radius: var(--svpod--radius--rounded);
background-color: var(--bg);
color: var(--fg);
cursor: pointer;
}
.svpod--button:hover,
.svpod--icon-button:hover {
--fg: var(--svpod--content--lighter);
--bg: var(--svpod--surface--lighter);
}
.svpod--button {
padding: 4px calc(1em);
}
.svpod--icon-button {
padding: 8px;
min-width: 48px;
min-height: 48px;
}
</style>
</svelte:head>
+4 -2
View File
@@ -3,7 +3,9 @@
* @module audio * @module audio
*/ */
export { default as AudioContext } from './context.svelte'; export * from './actions';
export * from './audio-element';
export * from './audio-element-source';
export * from './audio-metadata';
export { default as AudioPlayer } from './player.svelte'; export { default as AudioPlayer } from './player.svelte';
export { default as AudioProgress } from './progress.svelte'; export { default as AudioProgress } from './progress.svelte';
export { episode_audio, episode_details, episode_progress } from './stores';
+11 -41
View File
@@ -1,71 +1,48 @@
<script> <script>
import { onMount } from 'svelte'; import { AudioProgress, audio_attributes } from '.';
import { AudioProgress } from '.'; import { audio } from '../audio';
import { episode_audio, episode_progress } from '../audio';
import { user_preferences } from '../user'; import { user_preferences } from '../user';
import { seconds_to_timestamp } from '../utility';
/** @type {string|undefined} */ /** @type {string|undefined} */
export let src; export let src;
/** @type {import('../audio/stores').EpisodeDetails} */ /** @type {import('./audio-metadata').AudioMetadata} */
export let metadata = {}; export let metadata = {};
let mounted = false; $: src && audio.load(src, metadata || {});
$: 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. * Skips the audio forward by a specified number of seconds.
* @param {number} seconds - The number of seconds to skip forward. * @param {number} seconds - The number of seconds to skip forward.
*/ */
const skip_forward = (seconds) => episode_audio.skip(seconds, 'forward'); const skip_forward = (seconds) => audio.skip(seconds, 'forward');
/** /**
* Skips the audio backward by a specified number of seconds. * Skips the audio backward by a specified number of seconds.
* @param {number} seconds - The number of seconds to skip backward. * @param {number} seconds - The number of seconds to skip backward.
*/ */
const skip_back = (seconds) => episode_audio.skip(seconds, 'backward'); const skip_back = (seconds) => audio.skip(seconds, 'backward');
/** /**
* Seeks to a specific time in the audio. * Seeks to a specific time in the audio.
* @param {number} value - The time to seek to, in seconds. * @param {number} value - The time to seek to, in seconds.
*/ */
const seek_to = (value) => episode_audio.seek(value); const seek_to = (value) => audio.seek(value);
/** /**
* Toggles the audio playback between play and pause. * Toggles the audio playback between play and pause.
*/ */
const toggle = () => episode_audio.play('toggle'); const toggle = () => audio.play('toggle');
/** /**
* Starts playing the audio. * Starts playing the audio.
*/ */
const play = () => episode_audio.play('set'); const play = () => audio.play('set');
/** /**
* Pauses the audio. * Pauses the audio.
*/ */
const pause = () => episode_audio.pause('set'); const pause = () => audio.pause('set');
</script> </script>
<slot <slot
@@ -80,12 +57,5 @@
play, play,
pause, pause,
}} }}
episode={{ attributes={$audio_attributes}
is_loaded,
is_playing,
current_time,
duration,
timestamp_hours,
timestamp,
}}
/> />
+22 -9
View File
@@ -1,6 +1,7 @@
<script> <script>
import { episode_audio, episode_progress } from '../audio'; import clamp from 'just-clamp';
import { announce } from '../internal'; import { announce } from '../internal';
import { audio_attributes, audio_element } from './audio-element';
export let step = 10; export let step = 10;
@@ -11,8 +12,8 @@
*/ */
function handle_drag_start(t) { function handle_drag_start(t) {
announce.info('drag_start :: ', t); announce.info('drag_start :: ', t);
was_paused = $episode_audio?.is_paused || true; was_paused = $audio_attributes.is_paused;
episode_audio.pause(); $audio_element?.pause();
} }
/** /**
@@ -21,21 +22,33 @@
function handle_drag_end(t) { function handle_drag_end(t) {
announce.info('drag_end :: ', t); announce.info('drag_end :: ', t);
if (was_paused) return; if (was_paused) return;
else episode_audio.play(); else $audio_element?.play();
} }
/**
* Seek to time audio
* @param {number} seconds - Seconds to seek
* @returns {void}
*/
const handle_seek_to = (seconds) => {
const el = $audio_element;
if (!el) return;
el.currentTime = clamp(0, el.duration, seconds);
};
</script> </script>
{#if $episode_audio && $episode_progress} {#if $audio_attributes.is_loaded}
<input <input
class={$$restProps.class} class={$$restProps.class}
style="display:block; width:100%;" style="display:block; width:100%;"
type="range" type="range"
data-paused={$episode_audio.is_paused ? 'true' : 'false'} data-paused={$audio_attributes.is_paused ? 'true' : 'false'}
min={0} min={0}
{step} {step}
max={$episode_audio.duration || step} max={$audio_attributes.duration}
value={$episode_progress.current_time} value={$audio_attributes.current_time}
on:change={(e) => episode_audio.seek(e.currentTarget.valueAsNumber)} on:change={(e) => handle_seek_to(e.currentTarget.valueAsNumber)}
on:touchstart={() => handle_drag_start('touchstart')} on:touchstart={() => handle_drag_start('touchstart')}
on:mousedown={() => handle_drag_start('mousedown')} on:mousedown={() => handle_drag_start('mousedown')}
on:touchend={() => handle_drag_end('touchend')} on:touchend={() => handle_drag_end('touchend')}
-54
View File
@@ -1,54 +0,0 @@
/**
* @typedef {Object} UserPreferences
* @property {number} playback_rate - The playback rate of the audio element.
* @property {number} volume - The volume of the audio element.
*/
import { BROWSER } from 'esm-env';
import { onMount } from 'svelte';
import { get, readable } from 'svelte/store';
import { user_preferences } from '../../user';
/**
* A readable store that provides an HTMLAudioElement instance.
* @type {import('svelte/store').Readable<HTMLAudioElement | undefined>}
* @exports audio_element
*/
export const audio_element = readable(undefined, (set) => {
if (!BROWSER) return;
const ID = 'svpod--generated-audio-element';
onMount(() => {
const existing_element = document.getElementById(ID);
/** @type {HTMLAudioElement} */
const el =
existing_element instanceof HTMLAudioElement
? existing_element
: document.createElement('audio');
el.id = ID;
el.setAttribute('preload', 'metadata');
el.muted = false;
el.autoplay = false;
el.controls = false;
/** @type {UserPreferences} */
const preferences = get(user_preferences);
el.playbackRate = preferences.playback_rate;
el.volume = preferences.volume;
if (!existing_element) document.body.appendChild(el);
/*
BUG: This is a hack to get around poor types.
The input and output types are correct, so this is safe for now.
*/
// @ts-expect-error - I'm not sure how to fix this using JSDOC
set(el);
});
return () => {
const element = document.getElementById(ID);
element ? element.remove() : null;
};
});
-225
View File
@@ -1,225 +0,0 @@
import clamp from 'just-clamp';
import { derived, get } from 'svelte/store';
import { user_preferences, user_progress } from '../../user';
import { announce } from '../../internal';
import { audio_element } from './audio-element';
import { episode_details } from './episode-details';
/**
* Episode state object
* @typedef {Object} EpisodeState
* @property {boolean} will_autoplay - Whether the episode will autoplay
* @property {boolean} is_paused - Whether the episode is paused
* @property {number} duration - The duration of the episode
* @property {string} src - The source of the episode
* @property {number} start_at - The starting point of the episode
* @property {import('./episode-details').EpisodeDetails | null} details - The details of the episode or null if there are none
*/
/** @type {Omit<EpisodeState, 'src'>} */
const default_episode_state = {
will_autoplay: false,
is_paused: true,
duration: 0,
start_at: 0,
details: null,
};
/**
* Episode state store
* @type {import('svelte/store').Readable<EpisodeState | null>}
*/
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);
};
},
);
/**
* @typedef {'toggle' | 'set'} HANDLE_TYPE
*/
/**
* 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) => {
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 {import('./episode-details').EpisodeDetails } details - Episode details
* @returns {void}
*/
const load_audio = (src, details) => {
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
* @returns {void}
*/
const unload_audio = () => {
user_progress.save();
const el = use_element('unload');
el.src = '';
episode_details.set(null);
};
/**
* Play audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const play_audio = (t = 'set') => {
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
* @returns {void}
*/
const pause_audio = (t = 'set') => {
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
* @returns {void}
*/
const mute_audio = (t = 'set') => {
const el = use_element('mute');
if (t === 'toggle') {
el.muted = !el.muted;
} else {
el.muted = true;
}
};
/**
* Unmute audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const unmute_audio = (t = 'set') => {
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="from-start"] - Seek from start or end
* @returns {void}
*/
const seek_audio = (seconds, from = 'from-start') => {
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='forward'] - Skip forward or backward
* @returns {void}
*/
const skip_audio = (seconds, type = 'forward') => {
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
@@ -1,12 +0,0 @@
import { writable } from 'svelte/store';
/**
* @typedef {Object.<string, unknown>} EpisodeDetails
*/
/**
* Episode details store.
* @type {import('svelte/store').Writable<EpisodeDetails | null>}
* @description This store holds the metadata for a single episode.
*/
export const episode_details = writable(null);
-48
View File
@@ -1,48 +0,0 @@
import { seconds_to_timestamp } from 'svelte-podcast/utility';
import { audio_element } from './audio-element';
import { derived } from 'svelte/store';
/**
* Episode progress object.
* @typedef {Object} EpisodeProgress
* @property {number} current_time - The current time of the episode in seconds.
* @property {string} timestamp - The current time of the episode in the format 'mm:ss'.
* @property {boolean} has_ended - Whether the episode has ended or not.
*/
/**
* The default episode progress object.
* @type {EpisodeProgress}
*/
const default_episode_progress = {
current_time: 0,
timestamp: '00:00',
has_ended: false,
};
/**
* The default episode progress object.
* @type {import('svelte/store').Readable<EpisodeProgress>}
*/
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: seconds_to_timestamp($audio.currentTime),
has_ended: $audio.ended,
});
}
$audio.addEventListener('timeupdate', () => set_value());
/**
* Remove event listener.
*/
return () => $audio.removeEventListener('timeupdate', () => set_value());
});
-4
View File
@@ -1,4 +0,0 @@
export * from './audio-element';
export * from './episode-data';
export * from './episode-details';
export * from './episode-progress';
+6 -3
View File
@@ -1,4 +1,7 @@
export * from './audio'; import { default as AudioPlayer } from './audio/player.svelte';
export { AudioPlayer as default } from './audio';
export * from './audio/actions';
export * from './user'; export * from './user';
export * from './utility';
export { AudioPlayer };
export default AudioPlayer;
+5 -5
View File
@@ -1,7 +1,7 @@
import { persisted } from 'svelte-local-storage-store'; import { persisted } from 'svelte-local-storage-store';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { episode_audio, episode_progress } from '../audio'; import { audio_element } from '../audio';
import { use_url, announce } from '../internal'; import { announce, use_url } from '../internal';
/** /**
* User progress object type. * User progress object type.
@@ -14,7 +14,7 @@ import { use_url, announce } from '../internal';
* @type {UserProgress} * @type {UserProgress}
*/ */
const DEFAULT_USER_PROGRESS = {}; const DEFAULT_USER_PROGRESS = {};
import {} from 'svelte-local-storage-store';
/** /**
* User progress store * User progress store
* @type {import('svelte/store').Writable<UserProgress>} * @type {import('svelte/store').Writable<UserProgress>}
@@ -27,13 +27,13 @@ const USER_PROGRESS_STORE = persisted('USER_PROGRESS', DEFAULT_USER_PROGRESS);
* @returns {void} * @returns {void}
*/ */
const save_user_progress = () => { const save_user_progress = () => {
const audio = get(episode_audio); const audio = get(audio_element);
if (!audio?.src) return; if (!audio?.src) return;
announce.info('saving progress: ', audio.src); announce.info('saving progress: ', audio.src);
const pathname = use_url(audio.src).pathname; const pathname = use_url(audio.src).pathname;
const current_time = get(episode_progress).current_time; const current_time = audio.currentTime;
USER_PROGRESS_STORE.update((prev) => ({ USER_PROGRESS_STORE.update((prev) => ({
...prev, ...prev,
[pathname]: current_time, [pathname]: current_time,
+58 -13
View File
@@ -3,7 +3,6 @@
import GitHubIcon from '@inqling/svelte-icons/simple-icons/github.svelte'; import GitHubIcon from '@inqling/svelte-icons/simple-icons/github.svelte';
import NPMIcon from '@inqling/svelte-icons/simple-icons/npm.svelte'; import NPMIcon from '@inqling/svelte-icons/simple-icons/npm.svelte';
import github from 'svelte-highlight/styles/github'; import github from 'svelte-highlight/styles/github';
import { AudioContext } from 'svelte-podcast';
import { circIn, circOut } from 'svelte/easing'; import { circIn, circOut } from 'svelte/easing';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import '../app.postcss'; import '../app.postcss';
@@ -18,21 +17,54 @@
label: 'Setup', label: 'Setup',
href: '/setup', href: '/setup',
sections: [ sections: [
{ label: 'installation', anchor: 'installation' }, { label: 'Installation', anchor: 'installation' },
{ label: 'Loader', anchor: 'add_loader' }, { label: 'Audio Sources', anchor: 'audio_sources' },
{ label: 'Type Safety', anchor: 'type_safety' },
], ],
}, },
{ {
label: 'API', label: 'API',
href: '/api', href: '/api',
sections: [ sections: [
{ label: 'Episode Audio', anchor: 'episode_audio' }, { label: 'audio', anchor: 'audio' },
{ label: 'Load Audio Source', anchor: 'load_audio_source' }, { label: 'audio_data', anchor: 'audio_data' },
{ label: 'Episode Details', anchor: 'episode_details' }, { label: 'audio.subscribe', anchor: 'audio.subscribe' },
{ label: 'Episode Progress', anchor: 'episode_progress' }, { label: 'audio.load', anchor: 'audio.load' },
{ label: 'Seconds To Timestamp', anchor: 'seconds_to_timestamp' }, { label: 'audio.unload', anchor: 'audio.unload' },
{ label: 'User Preferences', anchor: 'user_preferences' }, { label: 'audio.play', anchor: 'audio.play' },
{ label: 'User Progress', anchor: 'user_progress' }, { label: 'audio.pause', anchor: 'audio.pause' },
{ label: 'audio.mute', anchor: 'audio.mute' },
{ label: 'audio.unmute', anchor: 'audio.unmute' },
{ label: 'audio.seek', anchor: 'audio.seek' },
{ label: 'audio.skip', anchor: 'audio.skip' },
{ label: 'user_preferences', anchor: 'user_preferences' },
{ label: 'user_preferences_data', anchor: 'user_preferences_data' },
{
label: 'user_preferences.subscribe',
anchor: 'user_preferences.subscribe',
},
{
label: 'user_preferences.set_playback_rate',
anchor: 'user_preferences.set_playback_rate',
},
{
label: 'user_preferences.set_volume',
anchor: 'user_preferences.set_volume',
},
{
label: 'user_preferences.clear',
anchor: 'user_preferences.clear',
},
{ label: 'user_progress', anchor: 'user_progress' },
{ label: 'user_progress_data', anchor: 'user_progress_data' },
{
label: 'user_progress.subscribe',
anchor: 'user_progress.subscribe',
},
{ label: 'user_progress.get', anchor: 'user_progress.get' },
{ label: 'user_progress.clear', anchor: 'user_progress.clear' },
{ label: 'user_progress.save', anchor: 'user_progress.save' },
{ label: 'seconds_to_timestamp', anchor: 'seconds_to_timestamp' },
], ],
}, },
{ {
@@ -55,6 +87,19 @@
}, },
]); ]);
const podcast_links = Object.freeze([
{
label: 'Svelte Radio',
href: 'https://www.svelteradio.com/',
src: '/svelte-radio.png',
},
{
label: 'Syntax',
href: 'https://syntax.fm/',
src: '/syntax.png',
},
]);
let is_menu_open = false; let is_menu_open = false;
const open_menu = () => (is_menu_open = true); const open_menu = () => (is_menu_open = true);
@@ -65,8 +110,6 @@
{@html github} {@html github}
</svelte:head> </svelte:head>
<AudioContext />
<!-- Off-canvas menu for mobile, show/hide based on off-canvas menu state. --> <!-- Off-canvas menu for mobile, show/hide based on off-canvas menu state. -->
<div <div
class="pointer-events-none relative z-50 lg:hidden" class="pointer-events-none relative z-50 lg:hidden"
@@ -122,6 +165,7 @@
class="pb-2" class="pb-2"
pages={page_links} pages={page_links}
resources={resource_links} resources={resource_links}
podcasts={podcast_links}
/> />
</div> </div>
{/if} {/if}
@@ -134,6 +178,7 @@
class="border-r border-mono-200" class="border-r border-mono-200"
pages={page_links} pages={page_links}
resources={resource_links} resources={resource_links}
podcasts={podcast_links}
/> />
</div> </div>
@@ -179,7 +224,7 @@
</div> </div>
<main class="py-10 lg:pl-72"> <main class="py-10 lg:pl-72">
<div class="px-4 sm:px-6 lg:px-8"> <div class="px-2">
<slot /> <slot />
</div> </div>
</main> </main>
+259 -297
View File
@@ -1,242 +1,289 @@
<script> <script>
import { DocsPage } from '$src/layout/page'; import { DocsPage } from '$src/layout/page';
import { Highlight } from 'svelte-highlight'; import { Highlight, HighlightSvelte } from 'svelte-highlight';
import lang_ts from 'svelte-highlight/languages/typescript'; import lang_ts from 'svelte-highlight/languages/typescript';
import { import {
load_audio_local, audio_load,
load_audio_remote, audio_mute,
override_episode_state, audio_pause,
audio_play,
audio_seek,
audio_skip,
audio_subscribe,
audio_unload,
audio_unmute,
seconds_to_timestamp, seconds_to_timestamp,
user_preferences_clear,
user_preferences_set_playback_rate,
user_preferences_set_volume,
user_preferences_subscribe,
user_progress_clear,
user_progress_get,
user_progress_save,
user_progress_subscribe,
} from './code'; } from './code';
</script> </script>
<DocsPage <DocsPage title="API" let:Section let:SectionArticle let:TableSchema>
title="API" <Section title="audio">
let:Section <SectionArticle title="audio data">
let:SectionArticle <TableSchema
let:TableModule rows={[
let:TableSchema {
> property: 'is_loaded',
<Section title="episode_audio"> type: 'boolean',
<!-- methods --> description: 'Whether the audio element is loaded or not',
<h3>Methods</h3> },
{
property: 'is_paused',
type: 'boolean',
description: 'Whether the audio element is paused or not',
},
{
property: 'current_time',
type: 'number',
description:
'The current time of the audio element in seconds',
},
{
property: 'duration',
type: 'number',
description: 'The duration of the audio element in seconds',
},
{
property: 'timestamp_current',
type: 'string',
description:
'The current time of the audio element in timestamp format (hh:mm:ss)',
},
{
property: 'timestamp_end',
type: 'string',
description:
'The end time of the audio element in timestamp format (hh:mm:ss)',
},
]}
/>
</SectionArticle>
<TableModule <!--
rows={[ audio.subscribe
{ audio.load
method: '.subscribe()', audio.unload
description: 'You can subscribe to changes in the audio state.', audio.play
}, audio.pause
{ audio.mute
method: '.load(\n src:string, \n details:EpisodeDetails\n)', audio.unmute
description: audio.seek
'Load a new audio source. This will stop the current audio source and replace it with the new one.', audio.skip
}, -->
{ <SectionArticle title="audio.subscribe">
method: '.unload()',
description:
'Unload the current audio source. This will stop the current audio source and remove it from the audio state.',
},
{
method: '.play(\n action: "set" | "toggle" = "set"\n)',
description: 'Set or toggle the play state',
},
{
method: '.pause(\n action: "set" | "toggle" = "set"\n)',
description: 'Set or toggle the pause state',
},
{
method: '.mute(\n action: "set" | "toggle" = "set"\n)',
description: 'Set or toggle the mute state',
},
{
method: '.unmute(\n action: "set" | "toggle" = "set"\n)',
description: 'Set or toggle the unmute state',
},
{
method:
'.seek(\n seconds: number, \n from: "from-start" | "from-end" = "from-start"\n)',
description:
'Seek to a specific time in the audio. The `from` parameter determines whether the time is relative to the start or end of the audio.',
},
{
method:
'.skip(\n seconds: number, \n type: "forward" | "backward" = "forward"\n)',
description:
'Skip forward or backward in the audio by a specific number of seconds.',
},
]}
/>
<!-- data -->
<h3>EpisodeState</h3>
<TableSchema
rows={[
{
property: 'will_autoplay',
type: 'boolean',
description: 'Whether episodes will autoplay once loaded.',
},
{
property: 'is_paused',
type: 'boolean',
description: 'Whether the episode is paused.',
},
{
property: 'duration',
type: 'number',
description: 'The duration of the episode in seconds.',
},
{
property: 'src',
type: 'string',
description: 'The source of the episode.',
},
{
property: 'start_at',
type: 'number',
description: 'The starting point of the episode in seconds.',
},
{
property: 'details',
type: 'EpisodeDetails | null',
description:
'The details of the episode or null if there are none.',
},
]}
/>
<SectionArticle title="Load audio source">
<p> <p>
All you need to load an episode is a URL to an audio file. Subscribe to specific changes in the audio attributes. This will
svelte-podcast uses a html audio element under the hood, so any return a readable store that you can use to access the attributes.
audio file compatible with the autio element is also compatible with
this package.
</p> </p>
<div class="not-prose codeblock">
<h4>Using a remote file (URL)</h4> <HighlightSvelte code={audio_subscribe} />
<p>
An <em>audio url</em> could be a URL to an MP3 file from an RSS
feed, like this:
<code>https://media.transistor.fm/27a058c9/27b595e2.mp3</code>. It
could also be a path to a static file on your server.
</p>
<div
class="codeblock not-prose flex flex-col items-stretch gap-2 py-1"
>
<Highlight code={load_audio_remote} language={lang_ts} />
</div> </div>
</SectionArticle>
<h4>Using a local file (relative path)</h4> <SectionArticle title="audio.load">
<p> <p>
If you're using SvelteKit, you can store <em>static files</em> in Load a new audio source. This will stop the current audio source and
the /static directory. When your site is built, everything in the replace it with the new one.
static directory will be at the root of your site. If you have a
file in <code>/static/episides/episode-01.mp3</code> you could load
it as <code>/episides/episode-01.mp3</code>
</p> </p>
<div class="not-prose codeblock">
<Highlight code={audio_load} language={lang_ts} />
</div>
</SectionArticle>
<div <SectionArticle title="audio.unload">
class="codeblock not-prose flex flex-col items-stretch gap-2 py-1" <p>
> Unload the current audio source. This will stop the current audio
<Highlight code={load_audio_local} language={lang_ts} /> source and remove it from the audio state.
</p>
<div class="not-prose codeblock">
<Highlight code={audio_unload} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.play">
<p>Set or toggle the play state.</p>
<div class="not-prose codeblock">
<Highlight code={audio_play} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.pause">
<p>Set or toggle the pause state.</p>
<div class="not-prose codeblock">
<Highlight code={audio_pause} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.mute">
<p>Set or toggle the mute state.</p>
<div class="not-prose codeblock">
<Highlight code={audio_mute} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.unmute">
<p>Set or toggle the unmute state.</p>
<div class="not-prose codeblock">
<Highlight code={audio_unmute} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.seek">
<p>
Seek to a specific time in the audio. The `from` parameter
determines whether the time is relative to the start or end of the
audio.
</p>
<div class="not-prose codeblock">
<Highlight code={audio_seek} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.skip">
<p>
Skip forward or backward in the audio by a specific number of
seconds.
</p>
<div class="not-prose codeblock">
<Highlight code={audio_skip} language={lang_ts} />
</div> </div>
</SectionArticle> </SectionArticle>
</Section> </Section>
<Section title="episode_details"> <Section title="user_preferences">
<h3>Methods</h3> <SectionArticle title="user_preferences data">
<TableSchema
rows={[
{
property: 'playback_rate',
type: 'number',
description: 'The audio player playback rate (speed)',
},
{
property: 'volume',
type: 'number',
description: 'The audio player volume.',
},
]}
/>
</SectionArticle>
<TableModule <!--
rows={[ user_preferences.subscribe
{ user_preferences.set_playback_rate
method: '.subscribe() | $episode_details', user_preferences.set_volume
description: 'You can subscribe to changes in the audio state.', user_preferences.clear
}, -->
{ <SectionArticle title="user_preferences.subscribe">
method: '.set(\n data:EpisodeDetails\n)', <p>
description: 'You can set the audio state.', Subscribe to changes in the user preferences. This will return a
}, readable store that you can use to access the user preferences.
{ </p>
method: '.update((\n data:EpisodeDetails) => EpisodeDetails\n)', <div class="not-prose codeblock">
description: <HighlightSvelte code={user_preferences_subscribe} />
'You can update the audio state. This is useful if you want to update the state based on the previous state.', </div>
}, </SectionArticle>
]}
/>
<!-- data --> <SectionArticle title="user_preferences.set_playback_rate">
<h3>EpisodeState</h3> <p>
<div class="not-prose codeblock"> Set the playback rate. This will update the user preferences and
<Highlight code={`Record<string, unknown>`} language={lang_ts} /> apply the new playback rate to the audio.
</div> </p>
<p> <div class="not-prose codeblock">
Episode state holds the metadata for the current episode. You derermine <Highlight
the shape of the this data. code={user_preferences_set_playback_rate}
</p> language={lang_ts}
/>
</div>
</SectionArticle>
<p> <SectionArticle title="user_preferences.set_volume">
For complete type safety, we recommend defining this type yourself in a <p>
.d.ts file. In SvelteKit, you should do this in <code> Set the volume. This will update the user preferences and apply the
/src/app.d.ts new volume to the audio.
</code>. </p>
</p> <div class="not-prose codeblock">
<Highlight code={user_preferences_set_volume} language={lang_ts} />
</div>
</SectionArticle>
<h4>Override EpisodeState</h4> <SectionArticle title="user_preferences.clear">
<div class="not-prose codeblock"> <p>
<Highlight code={override_episode_state} language={lang_ts} /> Clear the user preferences. This will reset the user preferences to
</div> the default values.
</p>
<div class="not-prose codeblock">
<Highlight code={user_preferences_clear} language={lang_ts} />
</div>
</SectionArticle>
</Section> </Section>
<!-- episode_progress --> <!-- user_progress -->
<Section title="episode_progress"> <Section title="user_progress">
<!-- methods --> <SectionArticle title="user_progress data">
<h3>Methods</h3> <p>
<TableModule User progress is a record where the keys are audio sources, and the
rows={[ values are timestamps in seconds. Progress is retrieved by matching
{ the source, and returning the timestamp.
method: '.subscribe() | $episode_progress', </p>
description: 'You can subscribe to changes in the audio state.',
},
{
method: '.set(\n data:EpisodeProgress\n)',
description: 'You can set the audio state.',
},
{
method:
'.update(\n (data:EpisodeProgress) => EpisodeProgress\n)',
description:
'You can update the audio state. This is useful if you want to update the state based on the previous state.',
},
]}
/>
<!-- data --> <div class="not-prose codeblock">
<h3>EpisodeProgress</h3> <Highlight code={`Record<string, number>`} language={lang_ts} />
<TableSchema </div>
rows={[ </SectionArticle>
{
property: 'current_time', <!--
type: 'number', * user_progress.subscribe - Subscribes to user progress store
description: 'The current time of the episode in seconds.', * user_progress.get - Gets user progress for a given audio source
}, * user_progress.clear - Clears user progress store
{ * user_progress.save - Saves user progress
property: 'timestamp', -->
type: 'string', <SectionArticle title="user_progress.subscribe">
description: <p>
"The current time of the episode in the format 'mm:ss'.", Subscribe to changes in the user progress. This will return a
}, readable store that you can use to access the user progress.
{ </p>
property: 'has_ended', <div class="not-prose codeblock">
type: 'boolean', <HighlightSvelte code={user_progress_subscribe} />
description: 'Whether the episode has ended or not.', </div>
}, </SectionArticle>
]}
/> <SectionArticle title="user_progress.get">
<p>
Get the user progress for a given audio source. This will return the
timestamp in seconds.
</p>
<div class="not-prose codeblock">
<Highlight code={user_progress_get} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="user_progress.clear">
<p>
Clear the user progress. This will reset the user progress to the
default values.
</p>
<div class="not-prose codeblock">
<Highlight code={user_progress_clear} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="user_progress.save">
<p>
Save the user progress for a given audio source. This will update
the user progress with the new timestamp.
</p>
<div class="not-prose codeblock">
<Highlight code={user_progress_save} language={lang_ts} />
</div>
</SectionArticle>
</Section> </Section>
<!-- seconds_to_timestamp --> <!-- seconds_to_timestamp -->
@@ -261,89 +308,4 @@
<Highlight code={seconds_to_timestamp} language={lang_ts} /> <Highlight code={seconds_to_timestamp} language={lang_ts} />
</div> </div>
</Section> </Section>
<!-- user_preferences -->
<Section title="user_preferences">
<!-- methods -->
<h3>Methods</h3>
<TableModule
rows={[
{
method: '.subscribe() | $episode_audio',
description:
'You can subscribe to changes in the users preferences.',
},
{
method: '.set_playback_rate(\n value:number\n)',
description: 'You can set the users playback rate.',
},
{
method: '.set_volume(\n value:number\n)',
description: 'You can set the users volume.',
},
{
method: '.clear()',
description: 'You can clear the users preferences.',
},
]}
/>
<!-- data -->
<h3>UserPreferences</h3>
<TableSchema
rows={[
{
property: 'playback_rate',
type: 'number',
description: 'The audio player playback rate (speed)',
},
{
property: 'volume',
type: 'number',
description: 'The audio player volume.',
},
]}
/>
</Section>
<!-- user_progress -->
<Section title="user_progress">
<!-- methods -->
<h3>Methods</h3>
<TableModule
rows={[
{
method: '.subscribe() | $episode_progress',
description:
'You can subscribe to changes in the users progress.',
},
{
method: '.get(\n src:string\n): number | undefined',
description:
'You can get the users progress for a given audio source.',
},
{
method: '.save(\n src:string, \n value:number\n)',
description:
'You can save the users progress for a given audio source.',
},
{
method: '.clear()',
description: 'You can clear the users progress.',
},
]}
/>
<!-- data -->
<h3>UserProgress</h3>
<p>
User progress is a record where the keys are audio sources, and the
values are timestamps in seconds. Progress is retrieved by matching the
source, and returning the timestamp.
</p>
<div class="not-prose codeblock">
<Highlight code={`Record<string, number>`} language={lang_ts} />
</div>
</Section>
</DocsPage> </DocsPage>
+17
View File
@@ -0,0 +1,17 @@
import { audio } from 'svelte-podcast';
// load a new audio source
audio.load(
// path to audio file
'/episode-377.mp3',
// custom metadata
{
title: 'Supper Club × Rich Harris, Author of Svelte',
guests: ['Rich Harris'],
hosts: ['Wes Bos', 'Scott Tolinski'],
},
// autoplay
true,
);
+9
View File
@@ -0,0 +1,9 @@
import { audio } from 'svelte-podcast';
// mute the current audio source
// do nothing if it's already muted
audio.mute('set');
// mute the current audio if it's unmuted
// unmute the current audio if it's muted
audio.mute('toggle');
+9
View File
@@ -0,0 +1,9 @@
import { audio } from 'svelte-podcast';
// pause the current audio source
// do nothing if it's already paused
audio.pause('set');
// pause the current audio if it's playing
// play the current audio if it's paused
audio.pause('toggle');
+9
View File
@@ -0,0 +1,9 @@
import { audio } from 'svelte-podcast';
// play the current audio source
// do nothing if it's already playing
audio.play('set');
// play the current audio if it's paused
// pause the current audio if it's playing
audio.play('toggle');
+7
View File
@@ -0,0 +1,7 @@
import { audio } from 'svelte-podcast';
// go to 200 seconds from the start
audio.seek(200);
// go to 200 seconds before the end
audio.seek(200, 'from-end');
+7
View File
@@ -0,0 +1,7 @@
import { audio } from 'svelte-podcast';
// skip forward 30 seconds from the current position
audio.skip(30);
// skip backward 30 seconds from the current position
audio.skip(30, 'backward');
@@ -0,0 +1,16 @@
<script>
import { audio } from 'svelte-podcast';
// return the current user preferences
const attributes = $audio;
// or subscribe to changes in the user preferences
audio.subscribe((attr) => {
// do something with the new preferences whenever they change
console.log(attr);
});
</script>
<p>Current timestamp {attributes.timestamp_current}.</p>
<p>{attributes.is_paused ? 'Paused...' : 'Playing!'}.</p>
+4
View File
@@ -0,0 +1,4 @@
import { audio } from 'svelte-podcast';
// unload the current audio source
audio.unload();
+9
View File
@@ -0,0 +1,9 @@
import { audio } from 'svelte-podcast';
// unmute the current audio source
// do nothing if it's already unmuted
audio.unmute('set');
// unmute the current audio if it's muted
// mute the current audio if it's unmuted
audio.unmute('toggle');
+41 -8
View File
@@ -1,15 +1,48 @@
import { format_code } from '../../../layout/helper';
import { default as audio_load_src } from './audio-load.js?raw';
import { default as audio_mute_src } from './audio-mute.js?raw';
import { default as audio_pause_src } from './audio-pause.js?raw';
import { default as audio_play_src } from './audio-play.js?raw';
import { default as audio_seek_src } from './audio-seek.js?raw';
import { default as audio_skip_src } from './audio-skip.js?raw';
import { default as audio_subscribe_src } from './audio-subscribe.svelte?raw';
import { default as audio_unload_src } from './audio-unload.js?raw';
import { default as audio_unmute_src } from './audio-unmute.js?raw';
import { default as load_audio_after_click_src } from './load-audio-after-click.svelte?raw'; import { default as load_audio_after_click_src } from './load-audio-after-click.svelte?raw';
import { default as load_audio_after_mount_src } from './load-audio-after-mount.svelte?raw'; import { default as load_audio_after_mount_src } from './load-audio-after-mount.svelte?raw';
import { default as load_audio_local_src } from './load-audio-local?raw';
import { default as load_audio_remote_src } from './load-audio-remote?raw';
import { default as override_episode_state_src } from './override-episode-state?raw';
import { default as seconds_to_timestamp_src } from './seconds-to-timestamp?raw'; import { default as seconds_to_timestamp_src } from './seconds-to-timestamp?raw';
import { default as user_preferences_clear_src } from './user_preferences-clear?raw';
const format_code = (/** @type {string} */ code) => code.replaceAll('\t', ' '); import { default as user_preferences_set_playback_rate_src } from './user_preferences-set_playback_rate?raw';
import { default as user_preferences_set_volume_src } from './user_preferences-set_volume?raw';
import { default as user_preferences_subscribe_src } from './user_preferences-subscribe.svelte?raw';
import { default as user_progress_clear_src } from './user_progress-clear?raw';
import { default as user_progress_get_src } from './user_progress-get?raw';
import { default as user_progress_save_src } from './user_progress-save?raw';
import { default as user_progress_subscribe_src } from './user_progress-subscribe.svelte?raw';
export const load_audio_after_click = format_code(load_audio_after_click_src); export const load_audio_after_click = format_code(load_audio_after_click_src);
export const load_audio_after_mount = format_code(load_audio_after_mount_src); export const load_audio_after_mount = format_code(load_audio_after_mount_src);
export const load_audio_local = format_code(load_audio_local_src);
export const load_audio_remote = format_code(load_audio_remote_src);
export const override_episode_state = format_code(override_episode_state_src);
export const seconds_to_timestamp = format_code(seconds_to_timestamp_src); export const seconds_to_timestamp = format_code(seconds_to_timestamp_src);
export const audio_load = format_code(audio_load_src);
export const audio_play = format_code(audio_play_src);
export const audio_unload = format_code(audio_unload_src);
export const audio_pause = format_code(audio_pause_src);
export const audio_skip = format_code(audio_skip_src);
export const audio_seek = format_code(audio_seek_src);
export const audio_mute = format_code(audio_mute_src);
export const audio_unmute = format_code(audio_unmute_src);
export const user_preferences_clear = format_code(user_preferences_clear_src);
export const user_preferences_set_playback_rate = format_code(
user_preferences_set_playback_rate_src,
);
export const user_preferences_set_volume = format_code(
user_preferences_set_volume_src,
);
export const user_preferences_subscribe = format_code(
user_preferences_subscribe_src,
);
export const audio_subscribe = format_code(audio_subscribe_src);
export const user_progress_clear = format_code(user_progress_clear_src);
export const user_progress_get = format_code(user_progress_get_src);
export const user_progress_save = format_code(user_progress_save_src);
export const user_progress_subscribe = format_code(user_progress_subscribe_src);
@@ -1,11 +1,11 @@
<script> <script>
import { episode_audio } from 'svelte-podcast'; import { audio } from 'svelte-podcast';
</script> </script>
<!-- load the episode on click --> <!-- load the episode on click -->
<button <button
on:click={() => on:click={() =>
episode_audio.load('/episode-audio.mp3', { audio.load('/episode-audio.mp3', {
/* optional metadata */ /* optional metadata */
})} })}
> >
@@ -13,4 +13,4 @@
</button> </button>
<!-- unload the episode on click --> <!-- unload the episode on click -->
<button on:click={() => episode_audio.unload()}>Unload Episode</button> <button on:click={() => audio.unload()}>Unload Episode</button>
@@ -1,10 +1,10 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { episode_audio } from 'svelte-podcast'; import { audio } from 'svelte-podcast';
onMount(() => { onMount(() => {
// load the episode on mount without any metadata // load the episode on mount without any metadata
episode_audio.load('/episode-audio.mp3', { audio.load('/episode-audio.mp3', {
/* optional metadata */ /* optional metadata */
}); });
}); });
-9
View File
@@ -1,9 +0,0 @@
import { episode_audio } from 'svelte-podcast';
episode_audio.load(
// path to file
'/episode-audio.mp3',
// custom metadata
{},
);
-9
View File
@@ -1,9 +0,0 @@
import { episode_audio } from 'svelte-podcast';
episode_audio.load(
// url
'https://media.transistor.fm/27a058c9/27b595e2.mp3',
// custom metadata
{},
);
+1 -1
View File
@@ -1,4 +1,4 @@
import { seconds_to_timestamp } from 'svelte-podcast'; import { seconds_to_timestamp } from 'svelte-podcast/utility';
// Example // Example
seconds_to_timestamp(5173); // 01:26:13 seconds_to_timestamp(5173); // 01:26:13
@@ -0,0 +1,4 @@
import { user_preferences } from '../../../lib/user';
// Clears all user preferences
user_preferences.clear();
@@ -0,0 +1,4 @@
import { user_preferences } from '../../../lib/user';
// Sets the playback speed to 150%
user_preferences.set_playback_rate(1.5);
@@ -0,0 +1,4 @@
import { user_preferences } from '../../../lib/user';
// Sets the volume to 75%
user_preferences.set_volume(0.75);
@@ -0,0 +1,16 @@
<script>
import { user_preferences } from 'svelte-podcast';
// return the current user preferences
const preferences = $user_preferences;
// or subscribe to changes in the user preferences
user_preferences.subscribe((prefs) => {
// do something with the new preferences whenever they change
console.log(prefs);
});
</script>
<p>Listening at {preferences.playback_rate * 100}% speed.</p>
<p>Volume set to {preferences.volume * 100}%.</p>
@@ -0,0 +1,4 @@
import { user_progress } from 'svelte-podcast';
// Clear the user's progress for ALL episodes.
user_progress.clear();
+4
View File
@@ -0,0 +1,4 @@
import { user_progress } from 'svelte-podcast';
// Get the user's progress for a specific episode.
user_progress.get('https://example.com/episodes/1');
@@ -0,0 +1,4 @@
import { user_progress } from 'svelte-podcast';
// Save the user's progress for the current episode.
user_progress.save();
@@ -0,0 +1,16 @@
<script>
import { user_progress } from 'svelte-podcast';
// return the current user progress
const progress = $user_progress;
// or subscribe to changes in the user progress
user_progress.subscribe((prog) => {
// do something with the new progress whenever they change
console.log(prog);
});
</script>
<p>
Listened to {progress['https://example.com/episodes/1']} seconds of episode 1.
</p>
+2 -59
View File
@@ -1,12 +1,8 @@
<script> <script>
import PlayerStack from '$src/components/example-player/player-stack.svelte';
import PlayerWidget from '$src/components/example-player/player-widget.svelte'; import PlayerWidget from '$src/components/example-player/player-widget.svelte';
import { episodes } from '$src/content/episodes'; import { episodes } from '$src/content/episodes';
import { DocsPage } from '$src/layout/page'; import { DocsPage } from '$src/layout/page';
import PreviewComponent from '$src/layout/preview-component.svelte'; import PreviewComponent from '$src/layout/preview-component.svelte';
import { episode_audio } from 'svelte-podcast';
$: console.log('details :: ', $episode_audio?.details);
$: player_widget = { $: player_widget = {
current_time: true, current_time: true,
@@ -16,24 +12,12 @@
skip_forward: 30, skip_forward: 30,
}; };
$: player_stack = {
playback_rate: true,
timestamps: true,
skip_back: 10,
skip_forward: 30,
};
/** @type { string | undefined} */ /** @type { string | undefined} */
let audio_src = episodes.knomii.src; let audio_src = episodes.knomii.src;
</script> </script>
<DocsPage <DocsPage title="Examples" let:Section>
title="Examples" <p class="text-xl font-semibold text-red-500">Work in progress!</p>
let:Section
let:SectionArticle
let:TableModule
let:TableSchema
>
<Section title="PlayerWidget"> <Section title="PlayerWidget">
<PreviewComponent name="PlayerWidget"> <PreviewComponent name="PlayerWidget">
<svelte:fragment slot="options"> <svelte:fragment slot="options">
@@ -82,45 +66,4 @@
<PlayerWidget src={audio_src} include={player_widget} /> <PlayerWidget src={audio_src} include={player_widget} />
</PreviewComponent> </PreviewComponent>
</Section> </Section>
<Section title="PlayerStack">
<PreviewComponent name="PlayerStack">
<svelte:fragment slot="options">
<div>
<label for="pw_playback_rate">playback_rate</label>
<input
id="pw_playback_rate"
type="checkbox"
bind:checked={player_stack.playback_rate}
/>
</div>
<div>
<label for="pw_timestamps">timestamps</label>
<input
id="pw_timestamps"
type="checkbox"
bind:checked={player_stack.timestamps}
/>
</div>
<div>
<label for="pw_skip_back">skip_back</label>
<input
id="pw_skip_back"
type="number"
bind:value={player_stack.skip_back}
/>
</div>
<div>
<label for="pw_skip_forward">skip_forward</label>
<input
id="pw_skip_forward"
type="number"
bind:value={player_stack.skip_forward}
/>
</div>
</svelte:fragment>
<PlayerStack src={audio_src} class="max-w-sm" include={player_stack} />
</PreviewComponent>
</Section>
</DocsPage> </DocsPage>
+76 -25
View File
@@ -1,9 +1,15 @@
<script> <script>
import { DocsPage } from '$src/layout/page'; import { DocsPage } from '$src/layout/page';
import { add_audio_loader, install } from './code'; import { Highlight } from 'svelte-highlight';
import { Highlight, HighlightSvelte } from 'svelte-highlight';
import lang_shell from 'svelte-highlight/languages/shell'; import lang_shell from 'svelte-highlight/languages/shell';
import lang_ts from 'svelte-highlight/languages/typescript';
import {
install,
load_audio_advanced,
load_audio_local,
load_audio_remote,
override_episode_state,
} from './code';
</script> </script>
<DocsPage title="Setup" let:Section> <DocsPage title="Setup" let:Section>
@@ -17,37 +23,82 @@
<Highlight language={lang_shell} code={install} /> <Highlight language={lang_shell} code={install} />
</div> </div>
</Section> </Section>
<Section title="Add Loader">
<p class="text-primary-600"> <Section title="Audio Sources">
<b class="font-medium">Important</b>: The AudioLoader component does <p>
<em>not</em> To get started, you'll need an audio file. velte-podcast uses a html <code
render any UI. However it <em>is required</em> to use svelte-podcast. >{'<audio />'}</code
> element under the hood, so any file compatible with the audio element
will work.
</p> </p>
<p> <p>
This component is responsible for loading the audio sources and You can provide your audio files via absolute URL's - hosted online, or
persisting the audio state between page loads. This means that your relative paths - hosted within your project.
audio will continue playing even if the user refreshes the page, or you
use different UI for the media player.
</p> </p>
<h3>Using a remote file (URL)</h3>
<p> <p>
Add the AudioLoader component to your app. It should be as close to the An <em>audio url</em> could be a URL to an MP3 file from an RSS feed,
root of your app as possible to ensure the audio state behaves as like this:
expected in nested routes. <code>https://media.transistor.fm/27a058c9/27b595e2.mp3</code>. It
could also be a path to a static file on your server.
</p> </p>
<ul>
<li>You must have one of these for svelte-podcast to work.</li>
<li>You should also only load one instance of this at a time</li>
<li>
We recommend you loading it at the base of your app in your
layout.svelte file.
</li>
</ul>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1"> <div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<HighlightSvelte code={add_audio_loader} /> <Highlight code={load_audio_remote} language={lang_ts} />
</div>
<h3>Using a local file (relative path)</h3>
<p>
If you're using SvelteKit, you can store <em>static files</em> in the
/static directory. When your site is built, everything in the static
directory will be at the root of your site. If you have a file in
<code>/static/episides/episode-01.mp3</code>
you could load it as <code>/episides/episode-01.mp3</code>
</p>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<Highlight code={load_audio_local} language={lang_ts} />
</div>
<h3>Additional options</h3>
<p>
Along side your audio source, you can optionally define arbitrary
metadata for you to use in your player, and determine if the episode
should autoplay once loaded. Learn more in the <a
href="/api/#audio.load">api docs</a
>
</p>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<Highlight code={load_audio_advanced} language={lang_ts} />
</div> </div>
</Section> </Section>
<Section title="Type Safety">
<p>
Svelte podast makes it easy for you to associate arbitrary metadata
with each audio source. We recommend defining the shape of your
metadata in a <code>.d.ts</code> file.
</p>
<p>
In this example, we'll assume that your metadata consists of an episode
title, episode artwork, and an optional episode guest name.
</p>
<div class="not-prose codeblock">
<Highlight code={override_episode_state} language={lang_ts} />
</div>
<p>
If you're using SvelteKit, we recommend keeping this in <code
>src/app.d.ts</code
>.
</p>
</Section>
</DocsPage> </DocsPage>
@@ -1,7 +1,4 @@
<script> <script>
import { AudioContext } from 'svelte-podcast';
</script> </script>
<AudioContext />
<slot /> <slot />
+8
View File
@@ -1,6 +1,14 @@
import { format_code } from '$src/layout/helper'; import { format_code } from '$src/layout/helper';
import { default as add_audio_loader_src } from './add-audio-loader.svelte?raw'; import { default as add_audio_loader_src } from './add-audio-loader.svelte?raw';
import { default as install_src } from './install.sh?raw'; import { default as install_src } from './install.sh?raw';
import { default as load_audio_advanced_src } from './load-audio-advanced?raw';
import { default as load_audio_local_src } from './load-audio-local?raw';
import { default as load_audio_remote_src } from './load-audio-remote?raw';
import { default as override_episode_state_src } from './override-episode-state?raw';
export const add_audio_loader = format_code(add_audio_loader_src); export const add_audio_loader = format_code(add_audio_loader_src);
export const install = format_code(install_src); export const install = format_code(install_src);
export const override_episode_state = format_code(override_episode_state_src);
export const load_audio_local = format_code(load_audio_local_src);
export const load_audio_remote = format_code(load_audio_remote_src);
export const load_audio_advanced = format_code(load_audio_advanced_src);
@@ -0,0 +1,14 @@
import { audio } from 'svelte-podcast';
audio.load('/episode-audio.mp3');
audio.load(
'https://media.transistor.fm/27a058c9/27b595e2.mp3',
{
title: 'SvelteKit-superforms with Andreas Söderlund',
artwork:
'https://images.transistor.fm/file/transistor/images/show/12899/medium_1597678946-artwork.jpg',
guest_name: 'Andreas Söderlund',
},
false,
);
@@ -0,0 +1,3 @@
import { audio } from 'svelte-podcast';
audio.load('/episode-audio.mp3');
@@ -0,0 +1,3 @@
import { audio } from 'svelte-podcast';
audio.load('https://media.transistor.fm/27a058c9/27b595e2.mp3');
@@ -2,9 +2,9 @@
declare module 'svelte-podcast' { declare module 'svelte-podcast' {
interface EpisodeDetails { interface EpisodeDetails {
// define your own properties here
title: string; title: string;
artwork?: string; artwork: string;
guest_name: string;
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+39 -7
View File
@@ -3,16 +3,48 @@
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true,
// Additional Rules // Enable top-level await, and other modern ESM features.
"target": "ESNext",
"module": "ESNext",
// Enable node-style module resolution, for things like npm package imports.
"moduleResolution": "node",
// Enable JSON imports.
"resolveJsonModule": true,
// Enable stricter transpilation for better output.
"isolatedModules": true,
// Astro directly run TypeScript code, no transpilation needed.
"noEmit": true,
// Report an error when importing a file using a casing different from the casing on disk.
"forceConsistentCasingInFileNames": true,
// Properly support importing CJS modules in ESM
"esModuleInterop": true,
// Skip typechecking libraries and .d.ts files
"skipLibCheck": true,
"strict": true,
// Error when a value import is only used as a type.
"importsNotUsedAsValues": "error",
// Report errors for fallthrough cases in switch statements
"noFallthroughCasesInSwitch": true,
// Force functions designed to override their parent class to be specified as `override`.
"noImplicitOverride": true,
// Force functions to specify that they can return `undefined` if a possible code path does not return a value.
"noImplicitReturns": true,
// Report an error when a variable is declared but never used.
"noUnusedLocals": true,
// Report an error when a parameter is declared but never used.
"noUnusedParameters": true,
// Force the usage of the indexed syntax to access fields declared using an index signature.
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noEmit": true // Report an error when the value `undefined` is given to an optional property that doesn't specify `undefined` as a valid value.
"exactOptionalPropertyTypes": false,
// Report an error for unreachable code instead of just a warning.
"allowUnreachableCode": false,
// Report an error for unused labels instead of just a warning.
"allowUnusedLabels": false
} }
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// //