Cleanup lib exports (#50)
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@
|
||||
<meta name="theme-color" content="#f97316" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
|
||||
<link rel="bookmark" title="svelte-podcast Docs" />
|
||||
<link rel="bookmark" title="Svelte Podcast Docs" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {
|
||||
LoadingSpinner,
|
||||
Pause,
|
||||
@@ -8,14 +8,8 @@
|
||||
} from '$src/components/icon';
|
||||
import { AudioPlayer, user_preferences } from 'svelte-podcast';
|
||||
|
||||
/** @type {string | undefined} */
|
||||
export let src;
|
||||
export let skip_back = 30;
|
||||
export let skip_forward = 10;
|
||||
|
||||
/** @type {import('svelte-podcast/audio').AudioMetadata} */
|
||||
export let metadata = {};
|
||||
|
||||
export let playback_rate_values = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
|
||||
</script>
|
||||
|
||||
@@ -37,14 +31,20 @@
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<AudioPlayer {src} {metadata} let:Player let:action let:attributes>
|
||||
<AudioPlayer
|
||||
let:PlayerProgress
|
||||
let:skip_by
|
||||
let:play
|
||||
let:preference
|
||||
let:attributes
|
||||
>
|
||||
<div
|
||||
class="svpod-container {$$restProps.class}"
|
||||
data-loaded={attributes.is_loaded ? 'true' : 'false'}
|
||||
>
|
||||
<!-- Player Action: Skip back -->
|
||||
<button
|
||||
on:click={() => action.skip_back(skip_back)}
|
||||
on:click={() => skip_by(skip_back, 'backward')}
|
||||
class="svpod-skip"
|
||||
type="button"
|
||||
aria-label="Skip back {skip_back} seconds"
|
||||
@@ -62,7 +62,7 @@
|
||||
<!-- Player Action: Toggle play / pause -->
|
||||
{#if attributes.is_loaded}
|
||||
<button
|
||||
on:click={() => action.toggle()}
|
||||
on:click={() => play('toggle')}
|
||||
class="svpod-toggle"
|
||||
type="button"
|
||||
aria-label={attributes.is_paused ? 'Play' : 'Pause'}
|
||||
@@ -70,7 +70,7 @@
|
||||
<svelte:component this={attributes.is_paused ? Play : Pause} />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="svpod-toggle">
|
||||
<div class="svpod-toggle text-white">
|
||||
<LoadingSpinner />
|
||||
<span class="svpod--a11y">Waiting for audio...</span>
|
||||
</div>
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
<!-- Player Action: Skip forward -->
|
||||
<button
|
||||
on:click={() => action.skip_forward(skip_forward)}
|
||||
on:click={() => skip_by(skip_forward, 'forward')}
|
||||
class="svpod-skip"
|
||||
type="button"
|
||||
aria-label="Skip forward {skip_forward} seconds"
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
<!-- Player Component: Timeline -->
|
||||
<div class="svpod--timeline">
|
||||
<Player.Progress />
|
||||
<PlayerProgress />
|
||||
</div>
|
||||
|
||||
<!-- Player Data: Total duration timestamp -->
|
||||
@@ -119,7 +119,7 @@
|
||||
value={$user_preferences.playback_rate}
|
||||
on:change={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value);
|
||||
action.set_playback_rate(value);
|
||||
preference.set_playback_rate(value);
|
||||
}}
|
||||
>
|
||||
{#each playback_rate_values as value}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { AudioProgress } from '.';
|
||||
import { use_audio_element } from './_internal_/audio-element';
|
||||
import { audio } from './actions';
|
||||
import { audio_attributes } from './attributes';
|
||||
import { user_preferences } from './user-preferences';
|
||||
import { user_progress } from './user-progress';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(() => {
|
||||
use_audio_element('initialise');
|
||||
dispatch('ready');
|
||||
});
|
||||
|
||||
$: $audio;
|
||||
</script>
|
||||
|
||||
<slot
|
||||
PlayerProgress={AudioProgress}
|
||||
attributes={$audio_attributes}
|
||||
preference={{
|
||||
set_playback_rate: user_preferences.set_playback_rate,
|
||||
set_volume: user_preferences.set_volume,
|
||||
save_progress: user_progress.save,
|
||||
}}
|
||||
play={audio.play}
|
||||
mute={audio.mute}
|
||||
seek_to={audio.seek_to}
|
||||
skip_by={audio.skip_by}
|
||||
/>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import clamp from 'just-clamp';
|
||||
import { announce } from '../internal';
|
||||
import { audio_attributes, audio_element } from './audio-element';
|
||||
|
||||
import { announce } from './_internal_/announce';
|
||||
import { audio_element } from './_internal_/audio-element';
|
||||
import { audio_attributes } from './attributes';
|
||||
export let step = 10;
|
||||
|
||||
let was_paused = true;
|
||||
@@ -10,7 +10,7 @@
|
||||
/**
|
||||
* @param {string} t - The type of event that triggered the drag end.
|
||||
*/
|
||||
function handle_drag_start(t) {
|
||||
function handle_drag_start(t: string) {
|
||||
announce.info('drag_start :: ', t);
|
||||
was_paused = $audio_attributes.is_paused;
|
||||
$audio_element?.pause();
|
||||
@@ -19,7 +19,7 @@
|
||||
/**
|
||||
* @param {string} t - The type of event that triggered the drag end.
|
||||
*/
|
||||
function handle_drag_end(t) {
|
||||
function handle_drag_end(t: string) {
|
||||
announce.info('drag_end :: ', t);
|
||||
if (was_paused) return;
|
||||
else $audio_element?.play();
|
||||
@@ -30,7 +30,7 @@
|
||||
* @param {number} seconds - Seconds to seek
|
||||
* @returns {void}
|
||||
*/
|
||||
const handle_seek_to = (seconds) => {
|
||||
const handle_seek_to = (seconds: number) => {
|
||||
const el = $audio_element;
|
||||
if (!el) return;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { DEV } from 'esm-env';
|
||||
|
||||
const use_logger = Object.freeze({
|
||||
info: console.info,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
});
|
||||
|
||||
const base_styles = `padding: 4px 8px; border-radius:4px;`;
|
||||
const logger_styles = {
|
||||
error: `color: #FFFFFF; background-color: #FF0055; ${base_styles}`,
|
||||
warn: `color: #662200; background-color: #FFBB33; ${base_styles}`,
|
||||
info: `color: #80EAFF; background-color: #132686; ${base_styles}`,
|
||||
} satisfies { [K in keyof typeof use_logger]: string };
|
||||
|
||||
function log(type: keyof typeof use_logger, ...content: unknown[]) {
|
||||
// If type is info and not in dev mode, return early
|
||||
if (type === 'info' && !DEV) return;
|
||||
|
||||
const logger = use_logger[type];
|
||||
const styles = logger_styles[type];
|
||||
|
||||
// Log message
|
||||
logger('%c🔊 svelte-podcast:', styles, ...content);
|
||||
}
|
||||
|
||||
export const announce = {
|
||||
/**
|
||||
* Log an info message.
|
||||
* @param {...unknown} content - Content to log.
|
||||
*/
|
||||
info: (...content: unknown[]) => log('info', ...content),
|
||||
/**
|
||||
* Log a warning message.
|
||||
* @param {...unknown} content - Content to log.
|
||||
*/
|
||||
warn: (...content: unknown[]) => log('warn', ...content),
|
||||
/**
|
||||
* Log an error message.
|
||||
* @param {...unknown} content - Content to log.
|
||||
*/
|
||||
error: (...content: unknown[]) => log('error', ...content),
|
||||
} as const;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { BROWSER } from 'esm-env';
|
||||
import { derived, get, type Readable } from 'svelte/store';
|
||||
import { announce } from './announce';
|
||||
import { audio_state } from './audio-state';
|
||||
|
||||
const ELEMENT_ID = 'svpod--generated-audio-element';
|
||||
|
||||
/** A derived Svelte store containing an HTMLAudioElement or undefined. */
|
||||
export const audio_element: Readable<HTMLAudioElement | null> = derived(
|
||||
[audio_state],
|
||||
|
||||
([$audio_state], set) => {
|
||||
// If not in a browser environment, return early.
|
||||
if (!BROWSER) {
|
||||
set(null);
|
||||
return () => null;
|
||||
}
|
||||
|
||||
// Find an existing HTMLAudioElement or create a new one.
|
||||
const found = document.getElementById(ELEMENT_ID);
|
||||
const el =
|
||||
found instanceof HTMLAudioElement
|
||||
? found
|
||||
: document.createElement('audio');
|
||||
|
||||
// Set the HTMLAudioElement's attributes.
|
||||
el.id = ELEMENT_ID;
|
||||
el.setAttribute('preload', 'metadata');
|
||||
el.muted = false;
|
||||
el.autoplay = false;
|
||||
el.controls = false;
|
||||
|
||||
if ($audio_state) {
|
||||
el.src = $audio_state.src;
|
||||
el.currentTime = $audio_state.start_at;
|
||||
el.playbackRate = $audio_state.playback_rate || 1;
|
||||
el.volume = $audio_state.volume || 1;
|
||||
el.autoplay = $audio_state.autoplay || false;
|
||||
}
|
||||
|
||||
// If a new HTMLAudioElement was created, append it to the body.
|
||||
if (!found) document.body.appendChild(el);
|
||||
|
||||
// Define a function to set the HTMLAudioElement and call it.
|
||||
const handle_update = () => {
|
||||
set(el);
|
||||
$audio_state?.autoplay && el.play();
|
||||
announce.info('Updating audio element');
|
||||
};
|
||||
|
||||
handle_update();
|
||||
|
||||
// Add event listeners to the HTMLAudioElement to update the store when it changes.
|
||||
el.addEventListener('loadeddata', handle_update);
|
||||
el.addEventListener('pause', handle_update);
|
||||
el.addEventListener('playing', handle_update);
|
||||
el.addEventListener('timeupdate', handle_update);
|
||||
|
||||
// Return a function to clean up the HTMLAudioElement when the store unsubscribes.
|
||||
return () => {
|
||||
// Remove the event listeners from the HTMLAudioElement.
|
||||
el.removeEventListener('loadeddata', handle_update);
|
||||
el.removeEventListener('pause', handle_update);
|
||||
el.removeEventListener('playing', handle_update);
|
||||
el.removeEventListener('timeupdate', handle_update);
|
||||
|
||||
// Remove the HTMLAudioElement from the DOM.
|
||||
el ? el.remove() : null;
|
||||
};
|
||||
},
|
||||
null as HTMLAudioElement | null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Use audio element
|
||||
* @param action - Action being performed
|
||||
*/
|
||||
export const use_audio_element = (action: string): HTMLAudioElement => {
|
||||
const el = get(audio_element);
|
||||
if (!el) {
|
||||
throw announce.error(`could not ${action} :: no audio element found`);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import type { AudioMetadata } from '../metadata';
|
||||
|
||||
/** A writable Svelte store containing the audio metadata. */
|
||||
export const audio_metadata: Writable<AudioMetadata | null> = writable(null);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
|
||||
// TODO: implement autoplay
|
||||
|
||||
/**
|
||||
* An object representing the state of an audio player.
|
||||
*/
|
||||
interface AudioState {
|
||||
src: string;
|
||||
start_at: number;
|
||||
playback_rate?: number;
|
||||
volume?: number;
|
||||
autoplay?: boolean;
|
||||
}
|
||||
|
||||
/** A writable Svelte store containing the audio state. */
|
||||
export const audio_state: Writable<AudioState | null> = writable(null);
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Utility functions module.
|
||||
* @module Core
|
||||
*/
|
||||
|
||||
export * from './audio-element';
|
||||
export * from './audio-metadata';
|
||||
export * from './audio-state';
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* An object representing a relative URL.
|
||||
*/
|
||||
interface RelativeURL {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
searchParams: URLSearchParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function string_to_url(href: string): URL {
|
||||
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 function use_url(href: string): RelativeURL {
|
||||
const { pathname, search, hash, searchParams } = string_to_url(href);
|
||||
|
||||
return { pathname, search, hash, searchParams };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { BROWSER } from 'esm-env';
|
||||
import clamp from 'just-clamp';
|
||||
|
||||
import { get } from 'svelte/store';
|
||||
import { use_audio_element } from './_internal_/audio-element';
|
||||
import { audio_metadata } from './_internal_/audio-metadata';
|
||||
import { audio_state } from './_internal_/audio-state';
|
||||
import { audio_attributes } from './attributes';
|
||||
import type { AudioMetadata } from './metadata';
|
||||
import { user_preferences } from './user-preferences';
|
||||
import { user_progress } from './user-progress';
|
||||
|
||||
/**
|
||||
* Load audio
|
||||
* @param src - Audio source
|
||||
* @param metadata - Audio metadata
|
||||
*/
|
||||
const load_src = (src: string, metadata: AudioMetadata, autoplay = false) => {
|
||||
if (!BROWSER) return;
|
||||
|
||||
// Save the current progress if audio is already loaded
|
||||
user_progress.save();
|
||||
|
||||
// Get user preferences
|
||||
const preferences = get(user_preferences);
|
||||
|
||||
// Set audio element source
|
||||
audio_state.set({
|
||||
src,
|
||||
start_at: user_progress.get(src) || 0,
|
||||
playback_rate: preferences.playback_rate,
|
||||
volume: preferences.volume,
|
||||
autoplay: autoplay,
|
||||
});
|
||||
|
||||
// Set metadata for current audio
|
||||
audio_metadata.set(metadata);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unload audio
|
||||
*/
|
||||
const unload_src = () => {
|
||||
if (!BROWSER) return;
|
||||
|
||||
// Save the current progress if audio is already loaded
|
||||
user_progress.save();
|
||||
|
||||
// Unset audio element source
|
||||
audio_state.set(null);
|
||||
|
||||
// Unset metadata
|
||||
audio_metadata.set(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Play / Pause audio
|
||||
* @param playing - Set or toggle the play state
|
||||
*/
|
||||
const play = (playing: boolean | 'toggle' = 'toggle') => {
|
||||
const el = use_audio_element('play');
|
||||
|
||||
if (typeof playing === 'boolean') {
|
||||
playing ? el.play() : el.pause();
|
||||
}
|
||||
|
||||
if (playing === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mute / Unmute audio
|
||||
* @param muted - Set or toggle the mute state
|
||||
*/
|
||||
const mute = (muted: boolean | 'toggle' = 'toggle') => {
|
||||
const el = use_audio_element('mute');
|
||||
|
||||
if (typeof muted === 'boolean') {
|
||||
el.muted = muted;
|
||||
}
|
||||
|
||||
if (muted === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeks to a specific time in the audio.
|
||||
* @param seconds Timestamp in seconds
|
||||
* @param from Seek from-start or from-end
|
||||
*/
|
||||
const seek_to = (
|
||||
seconds: number,
|
||||
from: 'from-start' | 'from-end' = 'from-start',
|
||||
) => {
|
||||
const el = use_audio_element('seek');
|
||||
|
||||
if (from === 'from-end') {
|
||||
el.currentTime = clamp(0, el.duration, el.duration - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Skips the audio forward or backward by a specified number of seconds.
|
||||
* @param seconds - Time to skip in seconds
|
||||
* @param type [type='forward'] - Skip forward or backward
|
||||
*/
|
||||
const skip_by = (seconds: number, type: 'forward' | 'backward' = 'forward') => {
|
||||
const el = use_audio_element('skip');
|
||||
|
||||
if (type === 'backward') {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Audio controls
|
||||
*/
|
||||
export const audio = {
|
||||
subscribe: audio_attributes.subscribe,
|
||||
src: {
|
||||
load: load_src,
|
||||
unload: unload_src,
|
||||
},
|
||||
play,
|
||||
mute,
|
||||
seek_to,
|
||||
skip_by,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { derived, type Readable } from 'svelte/store';
|
||||
import { audio_element } from './_internal_/audio-element';
|
||||
import { audio_metadata } from './_internal_/audio-metadata';
|
||||
import type { AudioMetadata } from './metadata';
|
||||
import { format_seconds } from './utility/format-seconds';
|
||||
|
||||
/**
|
||||
* An object representing the attributes of an audio element.
|
||||
*/
|
||||
export interface AudioAttributes {
|
||||
is_loaded: boolean;
|
||||
is_paused: boolean;
|
||||
current_time: number;
|
||||
duration: number;
|
||||
timestamp_current: string;
|
||||
timestamp_end: string;
|
||||
metadata: AudioMetadata | null;
|
||||
}
|
||||
|
||||
/** A readable Svelte store containing the audio attributes. */
|
||||
export const audio_attributes: Readable<AudioAttributes> = derived(
|
||||
[audio_element, audio_metadata],
|
||||
([$el, $meta], set) => {
|
||||
if (!$el) {
|
||||
return set({
|
||||
is_loaded: false,
|
||||
is_paused: true,
|
||||
current_time: 0,
|
||||
duration: 0,
|
||||
timestamp_current: format_seconds.to_timestamp(0),
|
||||
timestamp_end: format_seconds.to_timestamp(0),
|
||||
metadata: null,
|
||||
} satisfies AudioAttributes);
|
||||
}
|
||||
|
||||
set({
|
||||
is_loaded: Boolean($el.src),
|
||||
is_paused: $el.paused,
|
||||
current_time: $el.currentTime,
|
||||
duration: $el.duration,
|
||||
timestamp_current: format_seconds.to_timestamp($el.currentTime),
|
||||
timestamp_end: format_seconds.to_timestamp($el.duration),
|
||||
metadata: $meta,
|
||||
} satisfies AudioAttributes);
|
||||
},
|
||||
);
|
||||
@@ -1,165 +0,0 @@
|
||||
import { BROWSER } from 'esm-env';
|
||||
import clamp from 'just-clamp';
|
||||
|
||||
import { get } from 'svelte/store';
|
||||
import { user_preferences, user_progress } from '../user';
|
||||
import { audio_attributes, use_audio_element } from './audio-element';
|
||||
import { audio_element_source } from './audio-element-source';
|
||||
import { audio_metadata } from './audio-metadata';
|
||||
|
||||
/**
|
||||
* Load audio
|
||||
* @param {string} src - Audio source
|
||||
* @param {import('./audio-metadata').AudioMetadata } metadata - Audio metadata
|
||||
* @returns {void}
|
||||
*/
|
||||
const load = (src, metadata = {}, autoplay = false) => {
|
||||
if (!BROWSER) return;
|
||||
|
||||
// Save the current progress if audio is already loaded
|
||||
user_progress.save();
|
||||
|
||||
// Get user preferences
|
||||
const preferences = get(user_preferences);
|
||||
|
||||
// Set audio element source
|
||||
audio_element_source.set({
|
||||
src,
|
||||
start_at: user_progress.get(src) || 0,
|
||||
playback_rate: preferences.playback_rate,
|
||||
volume: preferences.volume,
|
||||
autoplay: autoplay,
|
||||
});
|
||||
|
||||
// Set metadata for current audio
|
||||
audio_metadata.set(metadata);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unload audio
|
||||
* @returns {void}
|
||||
*/
|
||||
const unload = () => {
|
||||
if (!BROWSER) return;
|
||||
|
||||
// Save the current progress if audio is already loaded
|
||||
user_progress.save();
|
||||
|
||||
// Unset audio element source
|
||||
audio_element_source.set(null);
|
||||
|
||||
// Unset metadata
|
||||
audio_metadata.set(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {'toggle' | 'set'} HANDLE_TYPE
|
||||
*/
|
||||
|
||||
/**
|
||||
* Play audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const play = (t = 'set') => {
|
||||
const el = use_audio_element('play');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.play();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const pause = (t = 'set') => {
|
||||
user_progress.save();
|
||||
const el = use_audio_element('pause');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mute audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const mute = (t = 'set') => {
|
||||
const el = use_audio_element('mute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unmute audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const unmute = (t = 'set') => {
|
||||
const el = use_audio_element('unmute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Seek to time audio
|
||||
* @param {number} seconds - Seconds to seek
|
||||
* @param {'from-start' | 'from-end'} [from="from-start"] - Seek from start or end
|
||||
* @returns {void}
|
||||
*/
|
||||
const seek = (seconds, from = 'from-start') => {
|
||||
const el = use_audio_element('seek');
|
||||
|
||||
if (from === 'from-end') {
|
||||
el.currentTime = clamp(0, el.duration, el.duration - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip by about audio
|
||||
* @param {number} seconds - Seconds to skip
|
||||
* @param {'forward' | 'backward'} [type='forward'] - Skip forward or backward
|
||||
* @returns {void}
|
||||
*/
|
||||
const skip = (seconds, type = 'forward') => {
|
||||
const el = use_audio_element('skip');
|
||||
|
||||
if (type === 'backward') {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Audio controls
|
||||
*/
|
||||
export const audio = {
|
||||
subscribe: audio_attributes.subscribe,
|
||||
load,
|
||||
unload,
|
||||
play,
|
||||
pause,
|
||||
mute,
|
||||
unmute,
|
||||
seek,
|
||||
skip,
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// TODO: implement autoplay
|
||||
|
||||
/**
|
||||
* @typedef {Object} AudioSource
|
||||
* @property {string} src - A path or URL to the audio file
|
||||
* @property {number} start_at - The starting point of the audio
|
||||
* @property {number} [playback_rate=1] - The playback speed of the audio
|
||||
* @property {number} [volume=1] - The volume of the audio
|
||||
* @property {boolean} [autoplay=false] - Whether the audio should play as soon as it's loaded
|
||||
*/
|
||||
|
||||
/** @type {import('svelte/store').Writable<AudioSource | null>} */
|
||||
export const audio_element_source = writable(null);
|
||||
@@ -1,132 +0,0 @@
|
||||
import { BROWSER } from 'esm-env';
|
||||
import { derived, get } from 'svelte/store';
|
||||
import { announce } from '../internal';
|
||||
import { seconds_to_timestamp } from '../utility';
|
||||
import { audio_element_source } from './audio-element-source';
|
||||
|
||||
/**
|
||||
* @typedef {import('svelte/store').Readable<HTMLAudioElement | undefined>} ReadableAudioElement
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('../user').UserPreferences} Preferences
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {(value: HTMLAudioElement | undefined) => void} SetAudioElement
|
||||
*/
|
||||
|
||||
const ELEMENT_ID = 'svpod--generated-audio-element';
|
||||
|
||||
/** @type {ReadableAudioElement} */
|
||||
export const audio_element = derived(
|
||||
[audio_element_source],
|
||||
|
||||
([$audio_element_src], /** @type {SetAudioElement} */ set) => {
|
||||
// If not in a browser environment, return early.
|
||||
if (BROWSER) {
|
||||
// Find an existing HTMLAudioElement or create a new one.
|
||||
const found = document.getElementById(ELEMENT_ID);
|
||||
const el =
|
||||
found instanceof HTMLAudioElement
|
||||
? found
|
||||
: document.createElement('audio');
|
||||
|
||||
// Set the HTMLAudioElement's attributes.
|
||||
el.id = ELEMENT_ID;
|
||||
el.setAttribute('preload', 'metadata');
|
||||
el.muted = false;
|
||||
el.autoplay = false;
|
||||
el.controls = false;
|
||||
|
||||
if ($audio_element_src) {
|
||||
el.src = $audio_element_src.src;
|
||||
el.currentTime = $audio_element_src.start_at;
|
||||
el.playbackRate = $audio_element_src.playback_rate || 1;
|
||||
el.volume = $audio_element_src.volume || 1;
|
||||
el.autoplay = $audio_element_src.autoplay || false;
|
||||
}
|
||||
|
||||
// If a new HTMLAudioElement was created, append it to the body.
|
||||
if (!found) document.body.appendChild(el);
|
||||
|
||||
// Define a function to set the HTMLAudioElement and call it.
|
||||
const handle_update = () => {
|
||||
set(el);
|
||||
$audio_element_src?.autoplay && el.play();
|
||||
announce.info('Updating audio element');
|
||||
};
|
||||
|
||||
handle_update();
|
||||
|
||||
// Add event listeners to the HTMLAudioElement to update the store when it changes.
|
||||
el.addEventListener('loadeddata', handle_update);
|
||||
el.addEventListener('pause', handle_update);
|
||||
el.addEventListener('playing', handle_update);
|
||||
el.addEventListener('timeupdate', handle_update);
|
||||
|
||||
// Return a function to clean up the HTMLAudioElement when the store unsubscribes.
|
||||
return () => {
|
||||
// Remove the event listeners from the HTMLAudioElement.
|
||||
el.removeEventListener('loadeddata', handle_update);
|
||||
el.removeEventListener('pause', handle_update);
|
||||
el.removeEventListener('playing', handle_update);
|
||||
el.removeEventListener('timeupdate', handle_update);
|
||||
|
||||
// Remove the HTMLAudioElement from the DOM.
|
||||
el ? el.remove() : null;
|
||||
};
|
||||
} else {
|
||||
set(undefined);
|
||||
return () => null;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
* Use audio element
|
||||
* @param {string} action - Action being performed
|
||||
* @returns {HTMLAudioElement} - Audio element
|
||||
* @throws {string} - Error message if no audio element exists
|
||||
*/
|
||||
export const use_audio_element = (action) => {
|
||||
const el = get(audio_element);
|
||||
if (!el) {
|
||||
throw announce.error(`could not ${action} :: no audio element found`);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} AudioAttributes
|
||||
* @property {boolean} is_loaded - Whether the audio element is loaded or not
|
||||
* @property {boolean} is_paused - Whether the audio element is paused or not
|
||||
* @property {number} current_time - The current time of the audio element in seconds
|
||||
* @property {number} duration - The duration of the audio element in seconds
|
||||
* @property {string} timestamp_current - The current time of the audio element in timestamp format (hh:mm:ss)
|
||||
* @property {string} timestamp_end - The end time of the audio element in timestamp format (hh:mm:ss)
|
||||
*/
|
||||
|
||||
/** @type {import('svelte/store').Readable<AudioAttributes>} */
|
||||
export const audio_attributes = derived(audio_element, ($el, set) => {
|
||||
if (!$el) {
|
||||
return set({
|
||||
is_loaded: false,
|
||||
is_paused: true,
|
||||
current_time: 0,
|
||||
duration: 0,
|
||||
timestamp_current: seconds_to_timestamp(0),
|
||||
timestamp_end: seconds_to_timestamp(0),
|
||||
});
|
||||
}
|
||||
|
||||
set({
|
||||
is_loaded: Boolean($el.src),
|
||||
is_paused: $el.paused,
|
||||
current_time: $el.currentTime,
|
||||
duration: $el.duration,
|
||||
timestamp_current: seconds_to_timestamp($el.currentTime),
|
||||
timestamp_end: seconds_to_timestamp($el.duration),
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* @typedef {Object.<string, unknown>} AudioMetadata
|
||||
*/
|
||||
|
||||
/**
|
||||
* Audio metadata store.
|
||||
* @type {import('svelte/store').Writable<AudioMetadata | null>}
|
||||
* @description This store holds the metadata for a single audio source.
|
||||
*/
|
||||
export const audio_metadata = writable(null);
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Audio context module.
|
||||
* @module audio
|
||||
*/
|
||||
|
||||
export * from './actions';
|
||||
export * from './audio-element';
|
||||
export * from './audio-element-source';
|
||||
export * from './audio-metadata';
|
||||
export { default as AudioPlayer } from './player.svelte';
|
||||
export { default as AudioProgress } from './progress.svelte';
|
||||
@@ -1,61 +0,0 @@
|
||||
<script>
|
||||
import { AudioProgress, audio_attributes } from '.';
|
||||
import { audio } from '../audio';
|
||||
import { user_preferences } from '../user';
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let src;
|
||||
|
||||
/** @type {import('./audio-metadata').AudioMetadata} */
|
||||
export let metadata = {};
|
||||
|
||||
$: src && 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) => 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) => 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) => audio.seek(value);
|
||||
|
||||
/**
|
||||
* Toggles the audio playback between play and pause.
|
||||
*/
|
||||
const toggle = () => audio.play('toggle');
|
||||
|
||||
/**
|
||||
* Starts playing the audio.
|
||||
*/
|
||||
const play = () => audio.play('set');
|
||||
|
||||
/**
|
||||
* Pauses the audio.
|
||||
*/
|
||||
const pause = () => audio.pause('set');
|
||||
</script>
|
||||
|
||||
<slot
|
||||
Player={{ Progress: 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,
|
||||
}}
|
||||
attributes={$audio_attributes}
|
||||
/>
|
||||
@@ -1,7 +0,0 @@
|
||||
import { default as AudioPlayer } from './audio/player.svelte';
|
||||
|
||||
export * from './audio/actions';
|
||||
export * from './user';
|
||||
|
||||
export { AudioPlayer };
|
||||
export default AudioPlayer;
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as AudioPlayer, default as default } from './Player.svelte';
|
||||
export { default as AudioProgress } from './Progress.svelte';
|
||||
export * from './actions';
|
||||
export * from './attributes';
|
||||
export * from './metadata';
|
||||
export * from './user-preferences';
|
||||
export * from './user-progress';
|
||||
export * from './utility/format-seconds';
|
||||
|
||||
// export type { AudioMetadata } from './audio-metadata';
|
||||
@@ -1,67 +0,0 @@
|
||||
import { DEV } from 'esm-env';
|
||||
|
||||
/**
|
||||
* Object containing logger functions.
|
||||
* @typedef {Object} Announce
|
||||
* @property {function} info - Log info like events and data.
|
||||
* @property {function} warn - Log warnings for developers.
|
||||
* @property {function} error - Log errors for critical failures.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type of logger.
|
||||
* @typedef {('info' | 'error' | 'warn')} Logger
|
||||
*/
|
||||
|
||||
/**
|
||||
* Object containing Logger functions.
|
||||
*
|
||||
* @type {Readonly<{[K in Logger]: Function}>}
|
||||
*/
|
||||
const use_logger = Object.freeze({
|
||||
info: console.info,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
});
|
||||
|
||||
/**
|
||||
* Log function.
|
||||
* @param {Logger} type - Type of logger.
|
||||
* @param {...unknown} content - Content to log.
|
||||
*/
|
||||
function log(type, ...content) {
|
||||
const logger = use_logger[type];
|
||||
|
||||
// If type is info and not in dev mode, return early
|
||||
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.
|
||||
* @type {Announce}
|
||||
* @exports announce
|
||||
*/
|
||||
export const announce = {
|
||||
/**
|
||||
* Log an info message.
|
||||
* @param {...unknown} content - Content to log.
|
||||
*/
|
||||
info: (...content) => log('info', ...content),
|
||||
/**
|
||||
* Log a warning message.
|
||||
* @param {...unknown} content - Content to log.
|
||||
*/
|
||||
warn: (...content) => log('warn', ...content),
|
||||
/**
|
||||
* Log an error message.
|
||||
* @param {...unknown} content - Content to log.
|
||||
*/
|
||||
error: (...content) => log('error', ...content),
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Utility functions module.
|
||||
* @module internal
|
||||
*/
|
||||
|
||||
export * from './announce';
|
||||
export * from './use-url';
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* @typedef {Object} RelativeURL
|
||||
* @property {string} pathname - The pathname of the URL.
|
||||
* @property {string} search - The search string of the URL.
|
||||
* @property {string} hash - The hash string of the URL.
|
||||
* @property {URLSearchParams} searchParams - The search parameters of the URL.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 {string} href - The string to convert to a URL object.
|
||||
* @returns {URL} A URL object.
|
||||
*/
|
||||
const string_to_url = (href) => {
|
||||
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 {string} href - The URL string to parse.
|
||||
* @returns {RelativeURL} An object containing the pathname, search, hash, and searchParams of the URL.
|
||||
*/
|
||||
export const use_url = (href) => {
|
||||
const { pathname, search, hash, searchParams } = string_to_url(href);
|
||||
|
||||
return { pathname, search, hash, searchParams };
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* An object representing the metadata for an audio source.
|
||||
*/
|
||||
export interface AudioMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import clamp from 'just-clamp';
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
|
||||
export interface UserPreferences {
|
||||
playback_rate: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default user preferences
|
||||
*/
|
||||
const DEFAULT_USER_PREFERENCES: UserPreferences = {
|
||||
playback_rate: 1,
|
||||
volume: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* The user preferences store
|
||||
*/
|
||||
export const USER_PREFERENCES_STORE = persisted<UserPreferences>(
|
||||
'USER_PREFERENCE',
|
||||
DEFAULT_USER_PREFERENCES,
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the playback rate for the user
|
||||
* @param value - The playback rate value
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* The user preferences object
|
||||
*/
|
||||
export const user_preferences = {
|
||||
subscribe: USER_PREFERENCES_STORE.subscribe,
|
||||
set_playback_rate,
|
||||
set_volume,
|
||||
clear: clear_store,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
import { get } from 'svelte/store';
|
||||
import { announce } from './_internal_/announce';
|
||||
import { audio_element } from './_internal_/audio-element';
|
||||
import { use_url } from './_internal_/safe-use-url';
|
||||
|
||||
export interface UserProgress {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
const DEFAULT_USER_PROGRESS: UserProgress = {};
|
||||
|
||||
/**
|
||||
* User progress store
|
||||
*/
|
||||
const USER_PROGRESS_STORE = persisted<UserProgress>(
|
||||
'USER_PROGRESS',
|
||||
DEFAULT_USER_PROGRESS,
|
||||
);
|
||||
|
||||
/**
|
||||
* Saves user progress
|
||||
*/
|
||||
const save_user_progress = () => {
|
||||
const audio = get(audio_element);
|
||||
if (!audio?.src) return;
|
||||
|
||||
announce.info('saving progress: ', audio.src);
|
||||
|
||||
const pathname = use_url(audio.src).pathname;
|
||||
const current_time = audio.currentTime;
|
||||
USER_PROGRESS_STORE.update((prev) => ({
|
||||
...prev,
|
||||
[pathname]: current_time,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets user progress for a given audio source
|
||||
* @param src - Audio source
|
||||
*/
|
||||
const get_user_progress = (src: string) => {
|
||||
const pathname = use_url(src).pathname;
|
||||
|
||||
const store: UserProgress = get(USER_PROGRESS_STORE);
|
||||
|
||||
const value = store[pathname];
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears user progress store
|
||||
*/
|
||||
const clear_user_progress = () =>
|
||||
USER_PROGRESS_STORE.set(DEFAULT_USER_PROGRESS);
|
||||
|
||||
export const user_progress = {
|
||||
subscribe: USER_PROGRESS_STORE.subscribe,
|
||||
get: get_user_progress,
|
||||
clear: clear_user_progress,
|
||||
save: save_user_progress,
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* User storage module.
|
||||
* @module user
|
||||
*/
|
||||
|
||||
export * from './user-preferences';
|
||||
export * from './user-progress';
|
||||
@@ -1,88 +0,0 @@
|
||||
import clamp from 'just-clamp';
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserPreferences
|
||||
* @property {number} playback_rate - The audio player playback rate (speed)
|
||||
* @property {number} volume - The audio player volume
|
||||
*/
|
||||
|
||||
/**
|
||||
* The default user preferences
|
||||
* @type {UserPreferences}
|
||||
*/
|
||||
const DEFAULT_USER_PREFERENCES = { playback_rate: 1, volume: 1 };
|
||||
|
||||
/**
|
||||
* The user preferences store
|
||||
* @type {import('svelte/store').Writable<UserPreferences>}
|
||||
*/
|
||||
const USER_PREFERENCES_STORE = persisted(
|
||||
'USER_PREFERENCE',
|
||||
DEFAULT_USER_PREFERENCES,
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the playback rate for the user
|
||||
* @param {number} value - The playback rate value
|
||||
* @returns {void}
|
||||
*/
|
||||
const set_playback_rate = (value) => {
|
||||
const playback_rate = clamp(value, 0.5, 5);
|
||||
return USER_PREFERENCES_STORE.update((prefs) => ({
|
||||
...prefs,
|
||||
playback_rate,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the volume for the user
|
||||
* @param {number} value - The volume value
|
||||
* @returns {void}
|
||||
*/
|
||||
const set_volume = (value) => {
|
||||
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);
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserPreferencesObject
|
||||
* @property {typeof USER_PREFERENCES_STORE.subscribe} subscribe - Subscribes to the user preferences store
|
||||
* @property {typeof set_playback_rate} set_playback_rate - Sets the playback rate for the user
|
||||
* @property {typeof set_volume} set_volume - Sets the volume for the user
|
||||
* @property {typeof clear_store} clear - Clears saved user preferences
|
||||
*/
|
||||
|
||||
/**
|
||||
* The user preferences object
|
||||
* @type {UserPreferencesObject}
|
||||
*/
|
||||
export const user_preferences = {
|
||||
/**
|
||||
* Subscribes to user preferences store
|
||||
*/
|
||||
subscribe: USER_PREFERENCES_STORE.subscribe,
|
||||
|
||||
/**
|
||||
* Sets the playback rate for the user
|
||||
* @type {typeof set_playback_rate}
|
||||
*/
|
||||
set_playback_rate,
|
||||
|
||||
/**
|
||||
* Sets the volume for the user
|
||||
* @type {typeof set_volume}
|
||||
*/
|
||||
set_volume,
|
||||
|
||||
/**
|
||||
* Clears user preferences store
|
||||
* @type {typeof clear_store}
|
||||
*/
|
||||
clear: clear_store,
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
import { get } from 'svelte/store';
|
||||
import { audio_element } from '../audio';
|
||||
import { announce, use_url } from '../internal';
|
||||
|
||||
/**
|
||||
* User progress object type.
|
||||
* This object acts as a dictionary with string keys and number values.
|
||||
* @typedef {Object.<string, number>} UserProgress
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default user progress object
|
||||
* @type {UserProgress}
|
||||
*/
|
||||
const DEFAULT_USER_PROGRESS = {};
|
||||
|
||||
/**
|
||||
* User progress store
|
||||
* @type {import('svelte/store').Writable<UserProgress>}
|
||||
*/
|
||||
const USER_PROGRESS_STORE = persisted('USER_PROGRESS', DEFAULT_USER_PROGRESS);
|
||||
|
||||
/**
|
||||
* Saves user progress
|
||||
* @function
|
||||
* @returns {void}
|
||||
*/
|
||||
const save_user_progress = () => {
|
||||
const audio = get(audio_element);
|
||||
if (!audio?.src) return;
|
||||
|
||||
announce.info('saving progress: ', audio.src);
|
||||
|
||||
const pathname = use_url(audio.src).pathname;
|
||||
const current_time = audio.currentTime;
|
||||
USER_PROGRESS_STORE.update((prev) => ({
|
||||
...prev,
|
||||
[pathname]: current_time,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets user progress for a given audio source
|
||||
* @function
|
||||
* @param {string} src - Audio source
|
||||
* @returns {number|undefined} - User progress for the given audio source, or undefined if not found
|
||||
*/
|
||||
const get_user_progress = (src) => {
|
||||
const pathname = use_url(src).pathname;
|
||||
|
||||
const store = get(USER_PROGRESS_STORE);
|
||||
|
||||
/**@type {number|undefined} */
|
||||
const value = store[pathname];
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears user progress store
|
||||
* @function
|
||||
* @returns {void}
|
||||
*/
|
||||
const clear_user_progress = () =>
|
||||
USER_PROGRESS_STORE.set(DEFAULT_USER_PROGRESS);
|
||||
|
||||
/**
|
||||
* User progress object
|
||||
* @namespace
|
||||
* @property {function} subscribe - Subscribes to user progress store
|
||||
* @property {function} get - Gets user progress for a given audio source
|
||||
* @property {function} clear - Clears user progress store
|
||||
* @property {function} save - Saves user progress
|
||||
*/
|
||||
export const user_progress = {
|
||||
subscribe: USER_PROGRESS_STORE.subscribe,
|
||||
get: get_user_progress,
|
||||
clear: clear_user_progress,
|
||||
save: save_user_progress,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { announce } from '../_internal_/announce';
|
||||
|
||||
/**
|
||||
* Returns a string timestamp in the format of "hh:mm:ss" from a given number of seconds.
|
||||
* @param seconds - The number of seconds to convert to a timestamp.
|
||||
* @param force_hours - Whether to always include hours in the timestamp, even if it's 0.
|
||||
* @param seperator - The seperator to use between the hours, minutes, and seconds.
|
||||
* @returns A string timestamp in the format of "hh:mm:ss".
|
||||
*/
|
||||
function seconds_to_timestamp(
|
||||
seconds: number,
|
||||
force_hours = false,
|
||||
seperator = ':',
|
||||
): string {
|
||||
const safe_seconds = Number(seconds) || 0;
|
||||
|
||||
announce.info({ seconds, force_hours, seperator, safe_seconds });
|
||||
|
||||
const hours = Math.floor(safe_seconds / 3600);
|
||||
const hours_remainder = safe_seconds % 3600;
|
||||
|
||||
const minutes = Math.floor(hours_remainder / 60);
|
||||
const minutes_remainder = hours_remainder % 60;
|
||||
|
||||
const secs = Math.floor(minutes_remainder);
|
||||
|
||||
const show_hours = force_hours || hours > 0;
|
||||
|
||||
const HH = hours.toString().padStart(2, '0');
|
||||
const MM = minutes.toString().padStart(2, '0');
|
||||
const SS = secs.toString().padStart(2, '0');
|
||||
|
||||
const values = show_hours ? [HH, MM, SS] : [MM, SS];
|
||||
|
||||
return values.join(seperator);
|
||||
}
|
||||
|
||||
export const format_seconds = Object.freeze({
|
||||
to_timestamp: seconds_to_timestamp,
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Utility functions module.
|
||||
* @module utility
|
||||
*/
|
||||
|
||||
export * from './seconds-to-timestamp';
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Returns a string timestamp in the format of "hh:mm:ss" from a given number of seconds.
|
||||
* @param {number} seconds - The number of seconds to convert to a timestamp.
|
||||
* @param {boolean} [force_hours=false] - Whether to always include hours in the timestamp, even if it's 0.
|
||||
* @returns {string} A string timestamp in the format of "hh:mm:ss".
|
||||
*/
|
||||
export function seconds_to_timestamp(seconds, 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representation of a number with a leading 0 if it's less than 10.
|
||||
* @param {number} num - The number to format.
|
||||
* @returns {string} A string representation of the number with a leading 0 if it's less than 10.
|
||||
*/
|
||||
function section(num) {
|
||||
if (num < 10) return `0${num}`;
|
||||
return num.toString();
|
||||
}
|
||||
@@ -51,12 +51,9 @@
|
||||
{ label: 'audio', anchor: 'audio' },
|
||||
{ label: 'audio_data', anchor: 'audio_data' },
|
||||
{ label: 'audio.subscribe', anchor: 'audio.subscribe' },
|
||||
{ label: 'audio.load', anchor: 'audio.load' },
|
||||
{ label: 'audio.unload', anchor: 'audio.unload' },
|
||||
{ label: 'audio.src', anchor: 'audio.src' },
|
||||
{ label: 'audio.play', anchor: 'audio.play' },
|
||||
{ label: 'audio.pause', anchor: 'audio.pause' },
|
||||
{ label: 'audio.mute', anchor: 'audio.mute' },
|
||||
{ label: 'audio.unmute', anchor: 'audio.unmute' },
|
||||
{ label: 'audio.seek', anchor: 'audio.seek' },
|
||||
{ label: 'audio.skip', anchor: 'audio.skip' },
|
||||
{ label: 'user_preferences', anchor: 'user_preferences' },
|
||||
@@ -115,6 +112,11 @@
|
||||
href: 'https://syntax.fm/',
|
||||
src: '/syntax.png',
|
||||
},
|
||||
{
|
||||
label: 'PodStack',
|
||||
href: 'https://podstack.club/',
|
||||
src: '/podstack.png',
|
||||
},
|
||||
]);
|
||||
|
||||
let is_menu_open = false;
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
import { PlayerWidget } from '$src/components/player';
|
||||
import { episodes } from '$src/content/episodes';
|
||||
import { DocsPage } from '$src/layout/page';
|
||||
import { audio } from '../lib/actions';
|
||||
|
||||
/** @type { string | undefined} */
|
||||
let audio_src = episodes.knomii.src;
|
||||
audio.src.load(episodes.syntax.src, {
|
||||
title: episodes.syntax.title,
|
||||
artwork: episodes.syntax.artwork,
|
||||
});
|
||||
</script>
|
||||
|
||||
<DocsPage title="Docs" let:Section>
|
||||
@@ -87,7 +90,6 @@
|
||||
class="-m-2 grid grid-cols-1 grid-rows-1 rounded-xl border border-mono-100 bg-white p-1 shadow-2xl shadow-mono-200 sm:p-2 lg:-m-4 lg:rounded-2xl lg:p-4"
|
||||
>
|
||||
<PlayerWidget
|
||||
src={audio_src}
|
||||
class="w-full border border-mono-100"
|
||||
options={{
|
||||
duration: true,
|
||||
|
||||
@@ -5,13 +5,10 @@
|
||||
import {
|
||||
audio_load,
|
||||
audio_mute,
|
||||
audio_pause,
|
||||
audio_play,
|
||||
audio_seek,
|
||||
audio_skip,
|
||||
audio_subscribe,
|
||||
audio_unload,
|
||||
audio_unmute,
|
||||
seconds_to_timestamp,
|
||||
user_preferences_clear,
|
||||
user_preferences_set_playback_rate,
|
||||
@@ -87,23 +84,17 @@
|
||||
</div>
|
||||
</SectionArticle>
|
||||
|
||||
<SectionArticle title="audio.load">
|
||||
<SectionArticle title="audio.src">
|
||||
<p>
|
||||
Load a new audio source. This will stop the current audio source and
|
||||
replace it with the new one.
|
||||
</p>
|
||||
<div class="not-prose codeblock">
|
||||
<Highlight code={audio_load} language={lang_ts} />
|
||||
</div>
|
||||
</SectionArticle>
|
||||
|
||||
<SectionArticle title="audio.unload">
|
||||
<p>
|
||||
Unload the current audio source. This will stop the current audio
|
||||
source and remove it from the audio state.
|
||||
Alternatively, unload the current audio source. This will stop the
|
||||
current audio source and remove it from the audio state.
|
||||
</p>
|
||||
<div class="not-prose codeblock">
|
||||
<Highlight code={audio_unload} language={lang_ts} />
|
||||
<Highlight code={audio_load} language={lang_ts} />
|
||||
</div>
|
||||
</SectionArticle>
|
||||
|
||||
@@ -114,13 +105,6 @@
|
||||
</div>
|
||||
</SectionArticle>
|
||||
|
||||
<SectionArticle title="audio.pause">
|
||||
<p>Set or toggle the pause state.</p>
|
||||
<div class="not-prose codeblock">
|
||||
<Highlight code={audio_pause} language={lang_ts} />
|
||||
</div>
|
||||
</SectionArticle>
|
||||
|
||||
<SectionArticle title="audio.mute">
|
||||
<p>Set or toggle the mute state.</p>
|
||||
<div class="not-prose codeblock">
|
||||
@@ -128,13 +112,6 @@
|
||||
</div>
|
||||
</SectionArticle>
|
||||
|
||||
<SectionArticle title="audio.unmute">
|
||||
<p>Set or toggle the unmute state.</p>
|
||||
<div class="not-prose codeblock">
|
||||
<Highlight code={audio_unmute} language={lang_ts} />
|
||||
</div>
|
||||
</SectionArticle>
|
||||
|
||||
<SectionArticle title="audio.seek">
|
||||
<p>
|
||||
Seek to a specific time in the audio. The `from` parameter
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
// load a new audio source
|
||||
audio.load(
|
||||
// load a new audio source into the player
|
||||
audio.src.load(
|
||||
// path to audio file
|
||||
'/episode-377.mp3',
|
||||
|
||||
// custom metadata
|
||||
// custom metadata defined by you
|
||||
{
|
||||
title: 'Supper Club × Rich Harris, Author of Svelte',
|
||||
artwork:
|
||||
'https://ssl-static.libsyn.com/p/assets/b/3/c/d/b3cdf28da11ad39fe5bbc093207a2619/Syntax_-_499.jpg',
|
||||
guests: ['Rich Harris'],
|
||||
hosts: ['Wes Bos', 'Scott Tolinski'],
|
||||
},
|
||||
|
||||
// autoplay
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
// unload the current audio source
|
||||
audio.src.unload();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
// mute the current audio source
|
||||
// do nothing if it's already muted
|
||||
audio.mute('set');
|
||||
audio.mute(true);
|
||||
|
||||
// mute the current audio if it's unmuted
|
||||
// unmute the current audio if it's muted
|
||||
// unmute the current audio source
|
||||
audio.mute(false);
|
||||
|
||||
// invert the mute state of the current audio source
|
||||
audio.mute('toggle');
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
// pause the current audio source
|
||||
// do nothing if it's already paused
|
||||
audio.pause('set');
|
||||
|
||||
// pause the current audio if it's playing
|
||||
// play the current audio if it's paused
|
||||
audio.pause('toggle');
|
||||
@@ -1,9 +1,10 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
// play the current audio source
|
||||
// do nothing if it's already playing
|
||||
audio.play('set');
|
||||
audio.play(true);
|
||||
|
||||
// play the current audio if it's paused
|
||||
// pause the current audio if it's playing
|
||||
// pause the current audio source
|
||||
audio.play(false);
|
||||
|
||||
// invert the play state of the current audio source
|
||||
audio.play('toggle');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
// go to 200 seconds from the start
|
||||
audio.seek(200);
|
||||
audio.seek_to(200);
|
||||
|
||||
// go to 200 seconds before the end
|
||||
audio.seek(200, 'from-end');
|
||||
audio.seek_to(200, 'from-end');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
// skip forward 30 seconds from the current position
|
||||
audio.skip(30);
|
||||
audio.skip_by(30);
|
||||
|
||||
// skip backward 30 seconds from the current position
|
||||
audio.skip(30, 'backward');
|
||||
audio.skip_by(30, 'backward');
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
// unload the current audio source
|
||||
audio.unload();
|
||||
@@ -1,9 +0,0 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
// unmute the current audio source
|
||||
// do nothing if it's already unmuted
|
||||
audio.unmute('set');
|
||||
|
||||
// unmute the current audio if it's muted
|
||||
// mute the current audio if it's unmuted
|
||||
audio.unmute('toggle');
|
||||
@@ -1,13 +1,10 @@
|
||||
import { format_code } from '../../../layout/helper';
|
||||
import { default as audio_load_src } from './audio-load.js?raw';
|
||||
import { default as audio_mute_src } from './audio-mute.js?raw';
|
||||
import { default as audio_pause_src } from './audio-pause.js?raw';
|
||||
import { default as audio_play_src } from './audio-play.js?raw';
|
||||
import { default as audio_seek_src } from './audio-seek.js?raw';
|
||||
import { default as audio_skip_src } from './audio-skip.js?raw';
|
||||
import { default as audio_load_src } from './audio-load?raw';
|
||||
import { default as audio_mute_src } from './audio-mute?raw';
|
||||
import { default as audio_play_src } from './audio-play?raw';
|
||||
import { default as audio_seek_src } from './audio-seek?raw';
|
||||
import { default as audio_skip_src } from './audio-skip?raw';
|
||||
import { default as audio_subscribe_src } from './audio-subscribe.svelte?raw';
|
||||
import { default as audio_unload_src } from './audio-unload.js?raw';
|
||||
import { default as audio_unmute_src } from './audio-unmute.js?raw';
|
||||
import { default as load_audio_after_click_src } from './load-audio-after-click.svelte?raw';
|
||||
import { default as load_audio_after_mount_src } from './load-audio-after-mount.svelte?raw';
|
||||
import { default as seconds_to_timestamp_src } from './seconds-to-timestamp?raw';
|
||||
@@ -25,12 +22,9 @@ export const load_audio_after_mount = format_code(load_audio_after_mount_src);
|
||||
export const seconds_to_timestamp = format_code(seconds_to_timestamp_src);
|
||||
export const audio_load = format_code(audio_load_src);
|
||||
export const audio_play = format_code(audio_play_src);
|
||||
export const audio_unload = format_code(audio_unload_src);
|
||||
export const audio_pause = format_code(audio_pause_src);
|
||||
export const audio_skip = format_code(audio_skip_src);
|
||||
export const audio_seek = format_code(audio_seek_src);
|
||||
export const audio_mute = format_code(audio_mute_src);
|
||||
export const audio_unmute = format_code(audio_unmute_src);
|
||||
export const user_preferences_clear = format_code(user_preferences_clear_src);
|
||||
export const user_preferences_set_playback_rate = format_code(
|
||||
user_preferences_set_playback_rate_src,
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
<!-- load the episode on click -->
|
||||
<button
|
||||
on:click={() =>
|
||||
audio.load('/episode-audio.mp3', {
|
||||
/* optional metadata */
|
||||
audio.src.load('/episode-audio.mp3', {
|
||||
/* your metadata */
|
||||
title: 'Episode Title',
|
||||
artwork: '/artwork.png',
|
||||
})}
|
||||
>
|
||||
Load Episode
|
||||
</button>
|
||||
|
||||
<!-- unload the episode on click -->
|
||||
<button on:click={() => audio.unload()}>Unload Episode</button>
|
||||
<button on:click={() => audio.src.unload()}>Unload Episode</button>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
|
||||
onMount(() => {
|
||||
// load the episode on mount without any metadata
|
||||
audio.load('/episode-audio.mp3', {
|
||||
/* optional metadata */
|
||||
audio.src.load('/episode-audio.mp3', {
|
||||
/* your metadata */
|
||||
title: 'Episode Title',
|
||||
artwork: '/artwork.png',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { seconds_to_timestamp } from 'svelte-podcast/utility';
|
||||
import { format_seconds } from 'svelte-podcast/utility/format-seconds';
|
||||
|
||||
// Example
|
||||
seconds_to_timestamp(5173); // 01:26:13
|
||||
format_seconds.to_timestamp(5173); // '01:26:13';
|
||||
|
||||
// force hours
|
||||
seconds_to_timestamp(2700, true); // 00:45:00
|
||||
seconds_to_timestamp(2700, false); // 45:00 (default)
|
||||
// force hours to be displayed
|
||||
format_seconds.to_timestamp(2700, true); // '00:45:00';
|
||||
|
||||
// only display hours if there are any
|
||||
format_seconds.to_timestamp(2700, false); // '45:00';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { user_preferences } from '../../../lib/user';
|
||||
import { user_preferences } from 'svelte-podcast';
|
||||
|
||||
// Clears all user preferences
|
||||
user_preferences.clear();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { user_preferences } from '../../../lib/user';
|
||||
import { user_preferences } from 'svelte-podcast';
|
||||
|
||||
// Sets the playback speed to 150%
|
||||
user_preferences.set_playback_rate(1.5);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { user_preferences } from '../../../lib/user';
|
||||
import { user_preferences } from 'svelte-podcast';
|
||||
|
||||
// Sets the volume to 75%
|
||||
user_preferences.set_volume(0.75);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { AudioProgress } from 'svelte-podcast/audio';
|
||||
import { AudioProgress } from 'svelte-podcast';
|
||||
</script>
|
||||
|
||||
<AudioProgress step={10} />
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
Along side your audio source, you can optionally define arbitrary
|
||||
metadata for you to use in your player, and determine if the episode
|
||||
should autoplay once loaded. Learn more in the <a
|
||||
href="/api/#audio.load">api docs</a
|
||||
href="/api/#audio.src">api docs</a
|
||||
>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
audio.load('/episode-audio.mp3');
|
||||
audio.src.load(
|
||||
// Audio file
|
||||
'/episode-audio.mp3',
|
||||
|
||||
audio.load(
|
||||
'https://media.transistor.fm/27a058c9/27b595e2.mp3',
|
||||
// Your custom metadata
|
||||
{
|
||||
title: 'SvelteKit-superforms with Andreas Söderlund',
|
||||
artwork:
|
||||
'https://images.transistor.fm/file/transistor/images/show/12899/medium_1597678946-artwork.jpg',
|
||||
guest_name: 'Andreas Söderlund',
|
||||
title: 'A deep dive into Svelte Podcast',
|
||||
artwork: '/artwork.png',
|
||||
guest_name: 'OllieJT',
|
||||
},
|
||||
|
||||
// Autoplay once loaded
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
audio.load('/episode-audio.mp3');
|
||||
audio.src.load('/episode-audio.mp3', {
|
||||
title: 'Episode Title',
|
||||
artwork: '/artwork.png',
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
audio.load('https://media.transistor.fm/27a058c9/27b595e2.mp3');
|
||||
audio.src.load('https://media.transistor.fm/27a058c9/27b595e2.mp3', {
|
||||
title: 'Episode Title',
|
||||
artwork: '/artwork.png',
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// include this in your /src/app.d.ts file
|
||||
|
||||
declare module 'svelte-podcast' {
|
||||
interface EpisodeDetails {
|
||||
interface AudioMetadata {
|
||||
title: string;
|
||||
artwork: string;
|
||||
guest_name: string;
|
||||
artwork: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user