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