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/kit": "^1.22.1",
|
||||||
"@sveltejs/package": "^2.1.0",
|
"@sveltejs/package": "^2.1.0",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
"@tailwindcss/line-clamp": "^0.4.4",
|
|
||||||
"@tailwindcss/typography": "^0.5.9",
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||||
"@typescript-eslint/parser": "^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">
|
<script lang="ts">
|
||||||
import { secondsToTimestamp } from '../../utility';
|
import { seconds_to_timestamp } from 'svelte-podcast';
|
||||||
|
|
||||||
export let value: number;
|
export let value: number;
|
||||||
export let force_hours = false;
|
export let force_hours = false;
|
||||||
|
|
||||||
$: timestamp = secondsToTimestamp(value, force_hours);
|
$: timestamp = seconds_to_timestamp(value, force_hours);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span style="width:{timestamp.length}ch">{timestamp}</span>
|
<span style="width:{timestamp.length}ch">{timestamp}</span>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { AudioLoader } from 'svelte-podcast';
|
import { Audio } from 'svelte-podcast';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AudioLoader />
|
<Audio />
|
||||||
|
|
||||||
<!-- your layout -->
|
<!-- your layout -->
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { user_preferences } from '../user';
|
import { user_preferences } from '../user';
|
||||||
import { info, warn } from '../utility/package/log';
|
import { announce } from '../utility';
|
||||||
import { audio_element } from './audio-element';
|
import { audio_element } from './stores/audio-element';
|
||||||
|
|
||||||
$: $audio_element;
|
$: $audio_element;
|
||||||
/* onMount(() => {
|
/* onMount(() => {
|
||||||
@@ -15,14 +15,17 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
return user_preferences.subscribe((prefs) => {
|
return user_preferences.subscribe((prefs) => {
|
||||||
const el = get(audio_element);
|
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.volume = prefs.volume;
|
||||||
el.playbackRate = prefs.playback_rate;
|
el.playbackRate = prefs.playback_rate;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- TODO: move styles to headless -->
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
: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 { default as Audio } from './component.svelte';
|
||||||
export * from './episode-data';
|
export { episode_audio, episode_details, episode_progress } from './stores';
|
||||||
export * from './episode-details';
|
|
||||||
export * from './episode-progress';
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { get, readable } from 'svelte/store';
|
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) => {
|
export const audio_element = readable<HTMLAudioElement | null>(null, (set) => {
|
||||||
if (!browser) return;
|
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 { derived, type Readable } from 'svelte/store';
|
||||||
import type { EpisodeProgress } from '../types';
|
import { seconds_to_timestamp } from '../../utility';
|
||||||
import { secondsToTimestamp } from '../utility';
|
|
||||||
import { audio_element } from './audio-element';
|
import { audio_element } from './audio-element';
|
||||||
|
|
||||||
|
export type EpisodeProgress = {
|
||||||
|
current_time: number;
|
||||||
|
timestamp: string;
|
||||||
|
has_ended: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const default_episode_progress = {
|
const default_episode_progress = {
|
||||||
current_time: 0,
|
current_time: 0,
|
||||||
timestamp: '00:00',
|
timestamp: '00:00',
|
||||||
has_ended: false,
|
has_ended: false,
|
||||||
} satisfies EpisodeProgress;
|
} 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) => {
|
export const episode_progress = derived(audio_element, ($audio, set) => {
|
||||||
if (!$audio) return set(default_episode_progress);
|
if (!$audio) return set(default_episode_progress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set episode progress value.
|
||||||
|
*/
|
||||||
function set_value() {
|
function set_value() {
|
||||||
if (!$audio) return;
|
if (!$audio) return;
|
||||||
set({
|
set({
|
||||||
current_time: $audio.currentTime,
|
current_time: $audio.currentTime,
|
||||||
timestamp: secondsToTimestamp($audio.currentTime),
|
timestamp: seconds_to_timestamp($audio.currentTime),
|
||||||
has_ended: $audio.ended,
|
has_ended: $audio.ended,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$audio.addEventListener('timeupdate', () => set_value());
|
$audio.addEventListener('timeupdate', () => set_value());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove event listener.
|
||||||
|
*/
|
||||||
return () => $audio.removeEventListener('timeupdate', () => set_value());
|
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 AudioPlayer } from './player.svelte';
|
||||||
export { default as PlayerStack } from './player-stack.svelte';
|
export { default as AudioProgress } from './progress.svelte';
|
||||||
export { default as PlayerWidget } from './player-widget.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 './audio';
|
||||||
export * from './components';
|
export * from './components';
|
||||||
|
export { AudioPlayer as default } from './components';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './utility';
|
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 './user-preferences';
|
||||||
export * from './progress';
|
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 './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}`;
|
* Returns a string timestamp in the format of "hh:mm:ss" from a given number of seconds.
|
||||||
return t;
|
* @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);
|
* Returns a string representation of a number with a leading 0 if it's less than 10.
|
||||||
const hh_remainder = seconds % 3600;
|
* @param num - The number to format.
|
||||||
const mm = Math.floor(hh_remainder / 60);
|
* @returns A string representation of the number with a leading 0 if it's less than 10.
|
||||||
const mm_remainder = hh_remainder % 60;
|
*/
|
||||||
const ss = Math.floor(mm_remainder);
|
function section(num: number) {
|
||||||
|
if (num < 10) return `0${num}`;
|
||||||
const hrs = force_hours || hh > 0 ? `${section(hh)}:` : '';
|
return num.toString();
|
||||||
const mins = mm ? `${section(mm)}:` : '00:';
|
|
||||||
const secs = ss ? `${section(ss)}` : '00';
|
|
||||||
|
|
||||||
return `${hrs}${mins}${secs}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Github } from '@inqling/svelte-icons/simple-icons';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import 'highlight.js/styles/github-dark.css';
|
import 'highlight.js/styles/github-dark.css';
|
||||||
import { AudioLoader } from 'svelte-podcast';
|
import { Audio } from 'svelte-podcast';
|
||||||
import '../app.postcss';
|
import '../app.postcss';
|
||||||
|
|
||||||
const page_links = [
|
const page_links = [
|
||||||
@@ -62,6 +62,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<AudioLoader />
|
<Audio />
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import content from '$src/content/docs.md?raw';
|
import content from '$src/content/docs.md?raw';
|
||||||
|
import { use_markdown } from '$src/markdown';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { use_markdown } from './use-markdown';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
export const load = (async () => {
|
||||||
const post = await use_markdown(content);
|
const post = await use_markdown(content);
|
||||||
|
|||||||
@@ -2,21 +2,18 @@
|
|||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { Section } from '$src/content/components';
|
import { Section } from '$src/content/components';
|
||||||
// import Docs from '$src/content/docs.md';
|
// 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 { episodes } from '$src/content/episodes';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { episode_audio, PlayerWidget } from 'svelte-podcast';
|
|
||||||
import type { PageServerData } from './$types';
|
import type { PageServerData } from './$types';
|
||||||
|
|
||||||
export let data: PageServerData;
|
export let data: PageServerData;
|
||||||
|
|
||||||
onMount(() => {
|
let audio_src: string | undefined = episodes.knomii.src;
|
||||||
// load the episode on mount without any metadata
|
|
||||||
episode_audio.load(episodes.knomii.src, {
|
|
||||||
/* optional metadata */
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- <PodcastPlayer title="Example title" /> -->
|
||||||
|
|
||||||
<div class="relative isolate bg-white">
|
<div class="relative isolate bg-white">
|
||||||
<svg
|
<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)]"
|
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"
|
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
|
<PlayerWidget
|
||||||
|
src={audio_src}
|
||||||
class="w-full border border-mono-100"
|
class="w-full border border-mono-100"
|
||||||
include={{
|
options={{
|
||||||
duration: true,
|
duration: true,
|
||||||
current_time: true,
|
current_time: true,
|
||||||
playback_rate: true,
|
playback_rate: true,
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<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 { Section } from '$src/content/components';
|
||||||
import {
|
import {
|
||||||
episode_audio,
|
episode_audio,
|
||||||
episode_progress,
|
episode_progress,
|
||||||
PlayerStack,
|
|
||||||
PlayerWidget,
|
|
||||||
user_preferences,
|
user_preferences,
|
||||||
user_progress,
|
user_progress,
|
||||||
} from 'svelte-podcast';
|
} from 'svelte-podcast';
|
||||||
import PreviewComponent from './preview-component.svelte';
|
|
||||||
import PreviewDataCode from './preview-data-code.svelte';
|
|
||||||
import PreviewData from './preview-data.svelte';
|
|
||||||
|
|
||||||
const sources = {
|
const sources = {
|
||||||
syntax: {
|
syntax: {
|
||||||
@@ -180,11 +180,11 @@
|
|||||||
description="clears all user preferences and resets them to default values"
|
description="clears all user preferences and resets them to default values"
|
||||||
/>
|
/>
|
||||||
<PreviewDataCode
|
<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)"
|
description="sets the users prefered playback_rate (speed)"
|
||||||
/>
|
/>
|
||||||
<PreviewDataCode
|
<PreviewDataCode
|
||||||
code={`user_preferences.set.volume(value: number)`}
|
code={`user_preferences.set_volume(value: number)`}
|
||||||
description="sets the users prefered volume"
|
description="sets the users prefered volume"
|
||||||
/>
|
/>
|
||||||
</PreviewData>
|
</PreviewData>
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
<h6>Playback Rate</h6>
|
<h6>Playback Rate</h6>
|
||||||
|
|
||||||
{#each [0.5, 1, 2, 3] as rate}
|
{#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
|
{rate}x
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
+1
-5
@@ -16,11 +16,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [
|
plugins: [require('@tailwindcss/typography'), require('@tailwindcss/forms')],
|
||||||
require('@tailwindcss/typography'),
|
|
||||||
require('@tailwindcss/forms'),
|
|
||||||
require('@tailwindcss/line-clamp'),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
@@ -559,11 +559,6 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
mini-svg-data-uri "^1.2.3"
|
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":
|
"@tailwindcss/typography@^0.5.9":
|
||||||
version "0.5.9"
|
version "0.5.9"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.9.tgz#027e4b0674929daaf7c921c900beee80dbad93e8"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.9.tgz#027e4b0674929daaf7c921c900beee80dbad93e8"
|
||||||
|
|||||||
Reference in New Issue
Block a user