Simplify setup (#46)
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { BROWSER } from 'esm-env';
|
||||
import clamp from 'just-clamp';
|
||||
|
||||
import { get } from 'svelte/store';
|
||||
import { user_preferences, user_progress } from '../user';
|
||||
import { audio_attributes, use_audio_element } from './audio-element';
|
||||
import { audio_element_source } from './audio-element-source';
|
||||
import { audio_metadata } from './audio-metadata';
|
||||
|
||||
/**
|
||||
* Load audio
|
||||
* @param {string} src - Audio source
|
||||
* @param {import('./audio-metadata').AudioMetadata } metadata - Audio metadata
|
||||
* @returns {void}
|
||||
*/
|
||||
const load = (src, metadata = {}, autoplay = false) => {
|
||||
if (!BROWSER) return;
|
||||
|
||||
// Save the current progress if audio is already loaded
|
||||
user_progress.save();
|
||||
|
||||
// Get user preferences
|
||||
const preferences = get(user_preferences);
|
||||
|
||||
// Set audio element source
|
||||
audio_element_source.set({
|
||||
src,
|
||||
start_at: user_progress.get(src) || 0,
|
||||
playback_rate: preferences.playback_rate,
|
||||
volume: preferences.volume,
|
||||
autoplay: autoplay,
|
||||
});
|
||||
|
||||
// Set metadata for current audio
|
||||
audio_metadata.set(metadata);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unload audio
|
||||
* @returns {void}
|
||||
*/
|
||||
const unload = () => {
|
||||
if (!BROWSER) return;
|
||||
|
||||
// Save the current progress if audio is already loaded
|
||||
user_progress.save();
|
||||
|
||||
// Unset audio element source
|
||||
audio_element_source.set(null);
|
||||
|
||||
// Unset metadata
|
||||
audio_metadata.set(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {'toggle' | 'set'} HANDLE_TYPE
|
||||
*/
|
||||
|
||||
/**
|
||||
* Play audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const play = (t = 'set') => {
|
||||
const el = use_audio_element('play');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.play();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const pause = (t = 'set') => {
|
||||
user_progress.save();
|
||||
const el = use_audio_element('pause');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mute audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const mute = (t = 'set') => {
|
||||
const el = use_audio_element('mute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unmute audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const unmute = (t = 'set') => {
|
||||
const el = use_audio_element('unmute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Seek to time audio
|
||||
* @param {number} seconds - Seconds to seek
|
||||
* @param {'from-start' | 'from-end'} [from="from-start"] - Seek from start or end
|
||||
* @returns {void}
|
||||
*/
|
||||
const seek = (seconds, from = 'from-start') => {
|
||||
const el = use_audio_element('seek');
|
||||
|
||||
if (from === 'from-end') {
|
||||
el.currentTime = clamp(0, el.duration, el.duration - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip by about audio
|
||||
* @param {number} seconds - Seconds to skip
|
||||
* @param {'forward' | 'backward'} [type='forward'] - Skip forward or backward
|
||||
* @returns {void}
|
||||
*/
|
||||
const skip = (seconds, type = 'forward') => {
|
||||
const el = use_audio_element('skip');
|
||||
|
||||
if (type === 'backward') {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Audio controls
|
||||
*/
|
||||
export const audio = {
|
||||
subscribe: audio_attributes.subscribe,
|
||||
load,
|
||||
unload,
|
||||
play,
|
||||
pause,
|
||||
mute,
|
||||
unmute,
|
||||
seek,
|
||||
skip,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// TODO: implement autoplay
|
||||
|
||||
/**
|
||||
* @typedef {Object} AudioSource
|
||||
* @property {string} src - A path or URL to the audio file
|
||||
* @property {number} start_at - The starting point of the audio
|
||||
* @property {number} [playback_rate=1] - The playback speed of the audio
|
||||
* @property {number} [volume=1] - The volume of the audio
|
||||
* @property {boolean} [autoplay=false] - Whether the audio should play as soon as it's loaded
|
||||
*/
|
||||
|
||||
/** @type {import('svelte/store').Writable<AudioSource | null>} */
|
||||
export const audio_element_source = writable(null);
|
||||
@@ -0,0 +1,132 @@
|
||||
import { BROWSER } from 'esm-env';
|
||||
import { derived, get } from 'svelte/store';
|
||||
import { announce } from '../internal';
|
||||
import { seconds_to_timestamp } from '../utility';
|
||||
import { audio_element_source } from './audio-element-source';
|
||||
|
||||
/**
|
||||
* @typedef {import('svelte/store').Readable<HTMLAudioElement | undefined>} ReadableAudioElement
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('../user').UserPreferences} Preferences
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {(value: HTMLAudioElement | undefined) => void} SetAudioElement
|
||||
*/
|
||||
|
||||
const ELEMENT_ID = 'svpod--generated-audio-element';
|
||||
|
||||
/** @type {ReadableAudioElement} */
|
||||
export const audio_element = derived(
|
||||
[audio_element_source],
|
||||
|
||||
([$audio_element_src], /** @type {SetAudioElement} */ set) => {
|
||||
// If not in a browser environment, return early.
|
||||
if (BROWSER) {
|
||||
// Find an existing HTMLAudioElement or create a new one.
|
||||
const found = document.getElementById(ELEMENT_ID);
|
||||
const el =
|
||||
found instanceof HTMLAudioElement
|
||||
? found
|
||||
: document.createElement('audio');
|
||||
|
||||
// Set the HTMLAudioElement's attributes.
|
||||
el.id = ELEMENT_ID;
|
||||
el.setAttribute('preload', 'metadata');
|
||||
el.muted = false;
|
||||
el.autoplay = false;
|
||||
el.controls = false;
|
||||
|
||||
if ($audio_element_src) {
|
||||
el.src = $audio_element_src.src;
|
||||
el.currentTime = $audio_element_src.start_at;
|
||||
el.playbackRate = $audio_element_src.playback_rate || 1;
|
||||
el.volume = $audio_element_src.volume || 1;
|
||||
el.autoplay = $audio_element_src.autoplay || false;
|
||||
}
|
||||
|
||||
// If a new HTMLAudioElement was created, append it to the body.
|
||||
if (!found) document.body.appendChild(el);
|
||||
|
||||
// Define a function to set the HTMLAudioElement and call it.
|
||||
const handle_update = () => {
|
||||
set(el);
|
||||
$audio_element_src?.autoplay && el.play();
|
||||
announce.info('Updating audio element');
|
||||
};
|
||||
|
||||
handle_update();
|
||||
|
||||
// Add event listeners to the HTMLAudioElement to update the store when it changes.
|
||||
el.addEventListener('loadeddata', handle_update);
|
||||
el.addEventListener('pause', handle_update);
|
||||
el.addEventListener('playing', handle_update);
|
||||
el.addEventListener('timeupdate', handle_update);
|
||||
|
||||
// Return a function to clean up the HTMLAudioElement when the store unsubscribes.
|
||||
return () => {
|
||||
// Remove the event listeners from the HTMLAudioElement.
|
||||
el.removeEventListener('loadeddata', handle_update);
|
||||
el.removeEventListener('pause', handle_update);
|
||||
el.removeEventListener('playing', handle_update);
|
||||
el.removeEventListener('timeupdate', handle_update);
|
||||
|
||||
// Remove the HTMLAudioElement from the DOM.
|
||||
el ? el.remove() : null;
|
||||
};
|
||||
} else {
|
||||
set(undefined);
|
||||
return () => null;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
* Use audio element
|
||||
* @param {string} action - Action being performed
|
||||
* @returns {HTMLAudioElement} - Audio element
|
||||
* @throws {string} - Error message if no audio element exists
|
||||
*/
|
||||
export const use_audio_element = (action) => {
|
||||
const el = get(audio_element);
|
||||
if (!el) {
|
||||
throw announce.error(`could not ${action} :: no audio element found`);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} AudioAttributes
|
||||
* @property {boolean} is_loaded - Whether the audio element is loaded or not
|
||||
* @property {boolean} is_paused - Whether the audio element is paused or not
|
||||
* @property {number} current_time - The current time of the audio element in seconds
|
||||
* @property {number} duration - The duration of the audio element in seconds
|
||||
* @property {string} timestamp_current - The current time of the audio element in timestamp format (hh:mm:ss)
|
||||
* @property {string} timestamp_end - The end time of the audio element in timestamp format (hh:mm:ss)
|
||||
*/
|
||||
|
||||
/** @type {import('svelte/store').Readable<AudioAttributes>} */
|
||||
export const audio_attributes = derived(audio_element, ($el, set) => {
|
||||
if (!$el) {
|
||||
return set({
|
||||
is_loaded: false,
|
||||
is_paused: true,
|
||||
current_time: 0,
|
||||
duration: 0,
|
||||
timestamp_current: seconds_to_timestamp(0),
|
||||
timestamp_end: seconds_to_timestamp(0),
|
||||
});
|
||||
}
|
||||
|
||||
set({
|
||||
is_loaded: Boolean($el.src),
|
||||
is_paused: $el.paused,
|
||||
current_time: $el.currentTime,
|
||||
duration: $el.duration,
|
||||
timestamp_current: seconds_to_timestamp($el.currentTime),
|
||||
timestamp_end: seconds_to_timestamp($el.duration),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* @typedef {Object.<string, unknown>} AudioMetadata
|
||||
*/
|
||||
|
||||
/**
|
||||
* Audio metadata store.
|
||||
* @type {import('svelte/store').Writable<AudioMetadata | null>}
|
||||
* @description This store holds the metadata for a single audio source.
|
||||
*/
|
||||
export const audio_metadata = writable(null);
|
||||
@@ -1,116 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { announce } from '../internal';
|
||||
import { user_preferences } from '../user';
|
||||
import { audio_element } from './stores/audio-element';
|
||||
|
||||
$: $audio_element;
|
||||
|
||||
onMount(() => {
|
||||
return user_preferences.subscribe((prefs) => {
|
||||
const el = get(audio_element);
|
||||
|
||||
if (!el) return announce.warn('no audio element found');
|
||||
|
||||
announce.info('setting preferences :: ', prefs);
|
||||
el.volume = prefs.volume;
|
||||
el.playbackRate = prefs.playback_rate;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- TODO: move styles to headless -->
|
||||
<svelte:head>
|
||||
<style>
|
||||
:root {
|
||||
--svpod--font: ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--svpod--surface--darker: rgb(0, 0, 0);
|
||||
--svpod--surface--base: rgb(40, 40, 40);
|
||||
--svpod--surface--lighter: rgb(60, 60, 60);
|
||||
|
||||
--svpod--content--darker: rgb(150, 150, 150);
|
||||
--svpod--content--base: rgb(200, 200, 200);
|
||||
--svpod--content--lighter: rgb(255, 255, 255);
|
||||
|
||||
--svpod--accent--darker: rgb(75, 15, 0);
|
||||
--svpod--accent--base: rgb(180, 40, 0);
|
||||
--svpod--accent--lighter: rgb(255, 55, 25);
|
||||
|
||||
--bg: var(--svpod--surface--darker);
|
||||
--fg: var(--svpod--content--darker);
|
||||
|
||||
--svpod--radius--rounded: 9999px;
|
||||
|
||||
/* element : timeline */
|
||||
|
||||
--svpod--timeline-track--shape--height: 20px;
|
||||
--svpod--timeline-track--shape--radius: 0;
|
||||
--svpod--timeline-track--shape--border: 0;
|
||||
|
||||
--svpod--timeline-thumb--shape--width: 8px;
|
||||
--svpod--timeline-thumb--shape--height: 32px;
|
||||
--svpod--timeline-thumb--shape--radius: 6px;
|
||||
--svpod--timeline-thumb--shape--border: 2px;
|
||||
}
|
||||
|
||||
.svpod--reset {
|
||||
text-transform: none;
|
||||
font-style: normal;
|
||||
text-indent: 0;
|
||||
text-shadow: none;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.svpod--a11y-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.svpod--button,
|
||||
.svpod--icon-button {
|
||||
--fg: var(--svpod--content--base);
|
||||
--bg: var(--svpod--surface--base);
|
||||
position: relative;
|
||||
line-height: 1em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
border: 0px solid transparent;
|
||||
border-radius: var(--svpod--radius--rounded);
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.svpod--button:hover,
|
||||
.svpod--icon-button:hover {
|
||||
--fg: var(--svpod--content--lighter);
|
||||
--bg: var(--svpod--surface--lighter);
|
||||
}
|
||||
|
||||
.svpod--button {
|
||||
padding: 4px calc(1em);
|
||||
}
|
||||
|
||||
.svpod--icon-button {
|
||||
padding: 8px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
@@ -3,7 +3,9 @@
|
||||
* @module audio
|
||||
*/
|
||||
|
||||
export { default as AudioContext } from './context.svelte';
|
||||
export * from './actions';
|
||||
export * from './audio-element';
|
||||
export * from './audio-element-source';
|
||||
export * from './audio-metadata';
|
||||
export { default as AudioPlayer } from './player.svelte';
|
||||
export { default as AudioProgress } from './progress.svelte';
|
||||
export { episode_audio, episode_details, episode_progress } from './stores';
|
||||
|
||||
+11
-41
@@ -1,71 +1,48 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { AudioProgress } from '.';
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import { AudioProgress, audio_attributes } from '.';
|
||||
import { audio } from '../audio';
|
||||
import { user_preferences } from '../user';
|
||||
import { seconds_to_timestamp } from '../utility';
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let src;
|
||||
|
||||
/** @type {import('../audio/stores').EpisodeDetails} */
|
||||
/** @type {import('./audio-metadata').AudioMetadata} */
|
||||
export let metadata = {};
|
||||
|
||||
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 || {});
|
||||
$: 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) => episode_audio.skip(seconds, '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) => episode_audio.skip(seconds, '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) => episode_audio.seek(value);
|
||||
const seek_to = (value) => audio.seek(value);
|
||||
|
||||
/**
|
||||
* Toggles the audio playback between play and pause.
|
||||
*/
|
||||
const toggle = () => episode_audio.play('toggle');
|
||||
const toggle = () => audio.play('toggle');
|
||||
|
||||
/**
|
||||
* Starts playing the audio.
|
||||
*/
|
||||
const play = () => episode_audio.play('set');
|
||||
const play = () => audio.play('set');
|
||||
|
||||
/**
|
||||
* Pauses the audio.
|
||||
*/
|
||||
const pause = () => episode_audio.pause('set');
|
||||
const pause = () => audio.pause('set');
|
||||
</script>
|
||||
|
||||
<slot
|
||||
@@ -80,12 +57,5 @@
|
||||
play,
|
||||
pause,
|
||||
}}
|
||||
episode={{
|
||||
is_loaded,
|
||||
is_playing,
|
||||
current_time,
|
||||
duration,
|
||||
timestamp_hours,
|
||||
timestamp,
|
||||
}}
|
||||
attributes={$audio_attributes}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import clamp from 'just-clamp';
|
||||
import { announce } from '../internal';
|
||||
import { audio_attributes, audio_element } from './audio-element';
|
||||
|
||||
export let step = 10;
|
||||
|
||||
@@ -11,8 +12,8 @@
|
||||
*/
|
||||
function handle_drag_start(t) {
|
||||
announce.info('drag_start :: ', t);
|
||||
was_paused = $episode_audio?.is_paused || true;
|
||||
episode_audio.pause();
|
||||
was_paused = $audio_attributes.is_paused;
|
||||
$audio_element?.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,21 +22,33 @@
|
||||
function handle_drag_end(t) {
|
||||
announce.info('drag_end :: ', t);
|
||||
if (was_paused) return;
|
||||
else episode_audio.play();
|
||||
else $audio_element?.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to time audio
|
||||
* @param {number} seconds - Seconds to seek
|
||||
* @returns {void}
|
||||
*/
|
||||
const handle_seek_to = (seconds) => {
|
||||
const el = $audio_element;
|
||||
if (!el) return;
|
||||
|
||||
el.currentTime = clamp(0, el.duration, seconds);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if $episode_audio && $episode_progress}
|
||||
{#if $audio_attributes.is_loaded}
|
||||
<input
|
||||
class={$$restProps.class}
|
||||
style="display:block; width:100%;"
|
||||
type="range"
|
||||
data-paused={$episode_audio.is_paused ? 'true' : 'false'}
|
||||
data-paused={$audio_attributes.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)}
|
||||
max={$audio_attributes.duration}
|
||||
value={$audio_attributes.current_time}
|
||||
on:change={(e) => handle_seek_to(e.currentTarget.valueAsNumber)}
|
||||
on:touchstart={() => handle_drag_start('touchstart')}
|
||||
on:mousedown={() => handle_drag_start('mousedown')}
|
||||
on:touchend={() => handle_drag_end('touchend')}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* @typedef {Object} UserPreferences
|
||||
* @property {number} playback_rate - The playback rate of the audio element.
|
||||
* @property {number} volume - The volume of the audio element.
|
||||
*/
|
||||
import { BROWSER } from 'esm-env';
|
||||
import { onMount } from 'svelte';
|
||||
import { get, readable } from 'svelte/store';
|
||||
import { user_preferences } from '../../user';
|
||||
|
||||
/**
|
||||
* A readable store that provides an HTMLAudioElement instance.
|
||||
* @type {import('svelte/store').Readable<HTMLAudioElement | undefined>}
|
||||
* @exports audio_element
|
||||
*/
|
||||
export const audio_element = readable(undefined, (set) => {
|
||||
if (!BROWSER) return;
|
||||
const ID = 'svpod--generated-audio-element';
|
||||
|
||||
onMount(() => {
|
||||
const existing_element = document.getElementById(ID);
|
||||
|
||||
/** @type {HTMLAudioElement} */
|
||||
const el =
|
||||
existing_element instanceof HTMLAudioElement
|
||||
? existing_element
|
||||
: document.createElement('audio');
|
||||
|
||||
el.id = ID;
|
||||
el.setAttribute('preload', 'metadata');
|
||||
el.muted = false;
|
||||
el.autoplay = false;
|
||||
el.controls = false;
|
||||
|
||||
/** @type {UserPreferences} */
|
||||
const preferences = get(user_preferences);
|
||||
el.playbackRate = preferences.playback_rate;
|
||||
el.volume = preferences.volume;
|
||||
|
||||
if (!existing_element) document.body.appendChild(el);
|
||||
|
||||
/*
|
||||
BUG: This is a hack to get around poor types.
|
||||
The input and output types are correct, so this is safe for now.
|
||||
*/
|
||||
// @ts-expect-error - I'm not sure how to fix this using JSDOC
|
||||
set(el);
|
||||
});
|
||||
|
||||
return () => {
|
||||
const element = document.getElementById(ID);
|
||||
element ? element.remove() : null;
|
||||
};
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
import clamp from 'just-clamp';
|
||||
import { derived, get } from 'svelte/store';
|
||||
import { user_preferences, user_progress } from '../../user';
|
||||
import { announce } from '../../internal';
|
||||
import { audio_element } from './audio-element';
|
||||
import { episode_details } from './episode-details';
|
||||
|
||||
/**
|
||||
* Episode state object
|
||||
* @typedef {Object} EpisodeState
|
||||
* @property {boolean} will_autoplay - Whether the episode will autoplay
|
||||
* @property {boolean} is_paused - Whether the episode is paused
|
||||
* @property {number} duration - The duration of the episode
|
||||
* @property {string} src - The source of the episode
|
||||
* @property {number} start_at - The starting point of the episode
|
||||
* @property {import('./episode-details').EpisodeDetails | null} details - The details of the episode or null if there are none
|
||||
*/
|
||||
|
||||
/** @type {Omit<EpisodeState, 'src'>} */
|
||||
const default_episode_state = {
|
||||
will_autoplay: false,
|
||||
is_paused: true,
|
||||
duration: 0,
|
||||
start_at: 0,
|
||||
details: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Episode state store
|
||||
* @type {import('svelte/store').Readable<EpisodeState | null>}
|
||||
*/
|
||||
const episode_state = derived(
|
||||
[audio_element, episode_details],
|
||||
([$audio, $details], set) => {
|
||||
if (!$audio) return set(null);
|
||||
|
||||
function set_value() {
|
||||
if (!$audio?.src) return null;
|
||||
set({
|
||||
...default_episode_state,
|
||||
src: $audio.src,
|
||||
duration: $audio.duration,
|
||||
details: $details,
|
||||
will_autoplay: $audio.autoplay,
|
||||
is_paused: $audio.paused,
|
||||
start_at: user_progress.get($audio.src) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
$audio.addEventListener('loadeddata', set_value);
|
||||
$audio.addEventListener('pause', set_value);
|
||||
$audio.addEventListener('playing', set_value);
|
||||
|
||||
return () => {
|
||||
$audio.removeEventListener('loadeddata', set_value);
|
||||
$audio.removeEventListener('pause', set_value);
|
||||
$audio.removeEventListener('playing', set_value);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @typedef {'toggle' | 'set'} HANDLE_TYPE
|
||||
*/
|
||||
|
||||
/**
|
||||
* Use audio element
|
||||
* @param {string} action - Action to perform
|
||||
* @returns {HTMLAudioElement} - Audio element
|
||||
* @throws {string} - Error message if no audio element exists
|
||||
*/
|
||||
const use_element = (action) => {
|
||||
const el = get(audio_element);
|
||||
if (!el)
|
||||
throw announce.warn(`could not ${action} :: no audio element exists yet`);
|
||||
return el;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load audio
|
||||
* @param {string} src - Audio source
|
||||
* @param {import('./episode-details').EpisodeDetails } details - Episode details
|
||||
* @returns {void}
|
||||
*/
|
||||
const load_audio = (src, details) => {
|
||||
user_progress.save();
|
||||
const el = use_element('load');
|
||||
el.src = src;
|
||||
el.muted = false;
|
||||
|
||||
// using user_progress
|
||||
const start_at = user_progress.get(src) || 0;
|
||||
el.currentTime = start_at;
|
||||
|
||||
// using user_preferences
|
||||
const preferences = get(user_preferences);
|
||||
el.playbackRate = preferences.playback_rate;
|
||||
el.volume = preferences.volume;
|
||||
|
||||
episode_details.set(details);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unload audio
|
||||
* @returns {void}
|
||||
*/
|
||||
const unload_audio = () => {
|
||||
user_progress.save();
|
||||
const el = use_element('unload');
|
||||
el.src = '';
|
||||
episode_details.set(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Play audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const play_audio = (t = 'set') => {
|
||||
const el = use_element('play');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.play();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const pause_audio = (t = 'set') => {
|
||||
user_progress.save();
|
||||
const el = use_element('pause');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mute audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const mute_audio = (t = 'set') => {
|
||||
const el = use_element('mute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unmute audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
* @returns {void}
|
||||
*/
|
||||
const unmute_audio = (t = 'set') => {
|
||||
const el = use_element('unmute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Seek to time audio
|
||||
* @param {number} seconds - Seconds to seek
|
||||
* @param {'from-start' | 'from-end'} [from="from-start"] - Seek from start or end
|
||||
* @returns {void}
|
||||
*/
|
||||
const seek_audio = (seconds, from = 'from-start') => {
|
||||
const el = use_element('seek');
|
||||
|
||||
if (from === 'from-end') {
|
||||
el.currentTime = clamp(0, el.duration, el.duration - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip by about audio
|
||||
* @param {number} seconds - Seconds to skip
|
||||
* @param {'forward' | 'backward'} [type='forward'] - Skip forward or backward
|
||||
* @returns {void}
|
||||
*/
|
||||
const skip_audio = (seconds, type = 'forward') => {
|
||||
const el = use_element('skip');
|
||||
|
||||
if (type === 'backward') {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Episode audio object
|
||||
*/
|
||||
export const episode_audio = {
|
||||
/**
|
||||
* Subscribes to episode audio store
|
||||
*/
|
||||
subscribe: episode_state.subscribe,
|
||||
|
||||
load: load_audio,
|
||||
unload: unload_audio,
|
||||
|
||||
play: play_audio,
|
||||
pause: pause_audio,
|
||||
mute: mute_audio,
|
||||
unmute: unmute_audio,
|
||||
seek: seek_audio,
|
||||
skip: skip_audio,
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* @typedef {Object.<string, unknown>} EpisodeDetails
|
||||
*/
|
||||
|
||||
/**
|
||||
* Episode details store.
|
||||
* @type {import('svelte/store').Writable<EpisodeDetails | null>}
|
||||
* @description This store holds the metadata for a single episode.
|
||||
*/
|
||||
export const episode_details = writable(null);
|
||||
@@ -1,48 +0,0 @@
|
||||
import { seconds_to_timestamp } from 'svelte-podcast/utility';
|
||||
import { audio_element } from './audio-element';
|
||||
import { derived } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Episode progress object.
|
||||
* @typedef {Object} EpisodeProgress
|
||||
* @property {number} current_time - The current time of the episode in seconds.
|
||||
* @property {string} timestamp - The current time of the episode in the format 'mm:ss'.
|
||||
* @property {boolean} has_ended - Whether the episode has ended or not.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The default episode progress object.
|
||||
* @type {EpisodeProgress}
|
||||
*/
|
||||
const default_episode_progress = {
|
||||
current_time: 0,
|
||||
timestamp: '00:00',
|
||||
has_ended: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The default episode progress object.
|
||||
* @type {import('svelte/store').Readable<EpisodeProgress>}
|
||||
*/
|
||||
export const episode_progress = derived(audio_element, ($audio, set) => {
|
||||
if (!$audio) return set(default_episode_progress);
|
||||
|
||||
/**
|
||||
* Set episode progress value.
|
||||
*/
|
||||
function set_value() {
|
||||
if (!$audio) return;
|
||||
set({
|
||||
current_time: $audio.currentTime,
|
||||
timestamp: seconds_to_timestamp($audio.currentTime),
|
||||
has_ended: $audio.ended,
|
||||
});
|
||||
}
|
||||
|
||||
$audio.addEventListener('timeupdate', () => set_value());
|
||||
|
||||
/**
|
||||
* Remove event listener.
|
||||
*/
|
||||
return () => $audio.removeEventListener('timeupdate', () => set_value());
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './audio-element';
|
||||
export * from './episode-data';
|
||||
export * from './episode-details';
|
||||
export * from './episode-progress';
|
||||
+6
-3
@@ -1,4 +1,7 @@
|
||||
export * from './audio';
|
||||
export { AudioPlayer as default } from './audio';
|
||||
import { default as AudioPlayer } from './audio/player.svelte';
|
||||
|
||||
export * from './audio/actions';
|
||||
export * from './user';
|
||||
export * from './utility';
|
||||
|
||||
export { AudioPlayer };
|
||||
export default AudioPlayer;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
import { get } from 'svelte/store';
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import { use_url, announce } from '../internal';
|
||||
import { audio_element } from '../audio';
|
||||
import { announce, use_url } from '../internal';
|
||||
|
||||
/**
|
||||
* User progress object type.
|
||||
@@ -14,7 +14,7 @@ import { use_url, announce } from '../internal';
|
||||
* @type {UserProgress}
|
||||
*/
|
||||
const DEFAULT_USER_PROGRESS = {};
|
||||
import {} from 'svelte-local-storage-store';
|
||||
|
||||
/**
|
||||
* User progress store
|
||||
* @type {import('svelte/store').Writable<UserProgress>}
|
||||
@@ -27,13 +27,13 @@ const USER_PROGRESS_STORE = persisted('USER_PROGRESS', DEFAULT_USER_PROGRESS);
|
||||
* @returns {void}
|
||||
*/
|
||||
const save_user_progress = () => {
|
||||
const audio = get(episode_audio);
|
||||
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 = get(episode_progress).current_time;
|
||||
const current_time = audio.currentTime;
|
||||
USER_PROGRESS_STORE.update((prev) => ({
|
||||
...prev,
|
||||
[pathname]: current_time,
|
||||
|
||||
Reference in New Issue
Block a user