Headless Improvements (#41)
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { user_preferences } from '../user';
|
||||
import { info, warn } from '../utility/package/log';
|
||||
import { audio_element } from './audio-element';
|
||||
import { announce } from '../utility';
|
||||
import { audio_element } from './stores/audio-element';
|
||||
|
||||
$: $audio_element;
|
||||
/* onMount(() => {
|
||||
@@ -15,14 +15,17 @@
|
||||
onMount(() => {
|
||||
return user_preferences.subscribe((prefs) => {
|
||||
const el = get(audio_element);
|
||||
if (!el) return warn('no audio element found');
|
||||
info('setting preferences :: ', prefs);
|
||||
|
||||
if (!el) return announce.warn('no audio element found');
|
||||
|
||||
announce.info('setting preferences :: ', prefs);
|
||||
el.volume = prefs.volume;
|
||||
el.playbackRate = prefs.playback_rate;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- TODO: move styles to headless -->
|
||||
<svelte:head>
|
||||
<style>
|
||||
:root {
|
||||
@@ -1,138 +0,0 @@
|
||||
import clamp from 'just-clamp';
|
||||
import { derived, get, type Readable } from 'svelte/store';
|
||||
import type { EpisodeAttributes, EpisodeDetails } from '../types';
|
||||
import { user_preferences, user_progress } from '../user';
|
||||
import { warn } from '../utility/package/log';
|
||||
import { audio_element } from './audio-element';
|
||||
import { episode_details } from './episode-details';
|
||||
|
||||
const default_episode_attributes = {
|
||||
will_autoplay: false,
|
||||
is_paused: true,
|
||||
duration: 0,
|
||||
start_at: 0,
|
||||
details: null,
|
||||
} satisfies Omit<EpisodeAttributes, 'src'>;
|
||||
|
||||
type EpisodeAttributesStore = Readable<EpisodeAttributes | null>;
|
||||
|
||||
const episode_attributes = derived([audio_element, episode_details], ([$audio, $details], set) => {
|
||||
if (!$audio) return set(null);
|
||||
|
||||
function set_value() {
|
||||
if (!$audio?.src) return null;
|
||||
set({
|
||||
...default_episode_attributes,
|
||||
src: $audio.src,
|
||||
duration: $audio.duration,
|
||||
details: $details,
|
||||
will_autoplay: $audio.autoplay,
|
||||
is_paused: $audio.paused,
|
||||
start_at: user_progress.get($audio.src) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
$audio.addEventListener('loadeddata', set_value);
|
||||
$audio.addEventListener('pause', set_value);
|
||||
$audio.addEventListener('playing', set_value);
|
||||
|
||||
return () => {
|
||||
$audio.removeEventListener('loadeddata', set_value);
|
||||
$audio.removeEventListener('pause', set_value);
|
||||
$audio.removeEventListener('playing', set_value);
|
||||
};
|
||||
}) satisfies EpisodeAttributesStore;
|
||||
|
||||
type HandleType = 'toggle' | 'set';
|
||||
|
||||
const no_element = (action: string) => warn(`could not ${action} :: no audio element exists yet`);
|
||||
|
||||
export const episode_audio = {
|
||||
subscribe: episode_attributes.subscribe,
|
||||
load: (src: string, details: EpisodeDetails) => {
|
||||
user_progress.save();
|
||||
const el = get(audio_element);
|
||||
if (!el) return no_element('load');
|
||||
el.src = src;
|
||||
el.muted = false;
|
||||
|
||||
// using user_progress
|
||||
const start_at = user_progress.get(src) || 0;
|
||||
el.currentTime = start_at;
|
||||
|
||||
// using user_preferences
|
||||
const preferences = get(user_preferences);
|
||||
el.playbackRate = preferences.playback_rate;
|
||||
el.volume = preferences.volume;
|
||||
|
||||
episode_details.set(details);
|
||||
},
|
||||
unload: () => {
|
||||
user_progress.save();
|
||||
const el = get(audio_element);
|
||||
if (!el) return no_element('unload');
|
||||
el.src = '';
|
||||
episode_details.set(null);
|
||||
},
|
||||
play: (t: HandleType = 'set') => {
|
||||
const el = get(audio_element);
|
||||
if (!el) return no_element('play');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.play();
|
||||
}
|
||||
},
|
||||
pause: (t: HandleType = 'set') => {
|
||||
user_progress.save();
|
||||
const el = get(audio_element);
|
||||
if (!el) return no_element('pause');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
},
|
||||
mute: (t: HandleType = 'set') => {
|
||||
const el = get(audio_element);
|
||||
if (!el) return no_element('mute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = true;
|
||||
}
|
||||
},
|
||||
unmute: (t: HandleType = 'set') => {
|
||||
const el = get(audio_element);
|
||||
if (!el) return no_element('unmute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = false;
|
||||
}
|
||||
},
|
||||
seek(seconds: number, from: 'from-start' | 'from-end' = 'from-start') {
|
||||
const el = get(audio_element);
|
||||
if (!el) return no_element('seek');
|
||||
|
||||
if (from === 'from-end') {
|
||||
el.currentTime = clamp(0, el.duration, el.duration - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, seconds);
|
||||
}
|
||||
},
|
||||
skip(seconds: number, type: 'forward' | 'backward' = 'forward') {
|
||||
const el = get(audio_element);
|
||||
if (!el) return no_element('skip');
|
||||
|
||||
if (type === 'backward') {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { EpisodeDetails } from '../types';
|
||||
|
||||
export const episode_details = writable<EpisodeDetails | null>(null);
|
||||
@@ -1,4 +1,2 @@
|
||||
export { default as AudioLoader } from './audio-loader.svelte';
|
||||
export * from './episode-data';
|
||||
export * from './episode-details';
|
||||
export * from './episode-progress';
|
||||
export { default as Audio } from './component.svelte';
|
||||
export { episode_audio, episode_details, episode_progress } from './stores';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import { get, readable } from 'svelte/store';
|
||||
import { user_preferences } from '../user';
|
||||
import { user_preferences } from '../../user';
|
||||
|
||||
export const audio_element = readable<HTMLAudioElement | null>(null, (set) => {
|
||||
if (!browser) return;
|
||||
@@ -0,0 +1,207 @@
|
||||
import clamp from 'just-clamp';
|
||||
import { derived, get, type Readable } from 'svelte/store';
|
||||
import { user_preferences, user_progress } from '../../user';
|
||||
import { announce } from '../../utility';
|
||||
import { audio_element } from './audio-element';
|
||||
import { episode_details, type EpisodeDetails } from './episode-details';
|
||||
|
||||
export type EpisodeState = {
|
||||
will_autoplay: boolean;
|
||||
is_paused: boolean;
|
||||
duration: number;
|
||||
src: string;
|
||||
start_at: number;
|
||||
details: EpisodeDetails | null;
|
||||
};
|
||||
|
||||
const default_episode_state = {
|
||||
will_autoplay: false,
|
||||
is_paused: true,
|
||||
duration: 0,
|
||||
start_at: 0,
|
||||
details: null,
|
||||
} satisfies Omit<EpisodeState, 'src'>;
|
||||
|
||||
/**
|
||||
* Episode state store
|
||||
*/
|
||||
const episode_state = derived([audio_element, episode_details], ([$audio, $details], set) => {
|
||||
if (!$audio) return set(null);
|
||||
|
||||
function set_value() {
|
||||
if (!$audio?.src) return null;
|
||||
set({
|
||||
...default_episode_state,
|
||||
src: $audio.src,
|
||||
duration: $audio.duration,
|
||||
details: $details,
|
||||
will_autoplay: $audio.autoplay,
|
||||
is_paused: $audio.paused,
|
||||
start_at: user_progress.get($audio.src) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
$audio.addEventListener('loadeddata', set_value);
|
||||
$audio.addEventListener('pause', set_value);
|
||||
$audio.addEventListener('playing', set_value);
|
||||
|
||||
return () => {
|
||||
$audio.removeEventListener('loadeddata', set_value);
|
||||
$audio.removeEventListener('pause', set_value);
|
||||
$audio.removeEventListener('playing', set_value);
|
||||
};
|
||||
}) satisfies Readable<EpisodeState | null>;
|
||||
|
||||
type HANDLE_TYPE = 'toggle' | 'set';
|
||||
|
||||
/**
|
||||
* Use audio element
|
||||
* @param {string} action - Action to perform
|
||||
* @returns {HTMLAudioElement} - Audio element
|
||||
* @throws {string} - Error message if no audio element exists
|
||||
*/
|
||||
const use_element = (action: string): HTMLAudioElement => {
|
||||
const el = get(audio_element);
|
||||
if (!el) throw announce.warn(`could not ${action} :: no audio element exists yet`);
|
||||
return el;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load audio
|
||||
* @param {string} src - Audio source
|
||||
* @param {EpisodeDetails} details - Episode details
|
||||
*/
|
||||
const load_audio = (src: string, details: EpisodeDetails): void => {
|
||||
user_progress.save();
|
||||
const el = use_element('load');
|
||||
el.src = src;
|
||||
el.muted = false;
|
||||
|
||||
// using user_progress
|
||||
const start_at = user_progress.get(src) || 0;
|
||||
el.currentTime = start_at;
|
||||
|
||||
// using user_preferences
|
||||
const preferences = get(user_preferences);
|
||||
el.playbackRate = preferences.playback_rate;
|
||||
el.volume = preferences.volume;
|
||||
|
||||
episode_details.set(details);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unload audio
|
||||
*/
|
||||
const unload_audio = (): void => {
|
||||
user_progress.save();
|
||||
const el = use_element('unload');
|
||||
el.src = '';
|
||||
episode_details.set(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Play audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
*/
|
||||
const play_audio = (t: HANDLE_TYPE = 'set'): void => {
|
||||
const el = use_element('play');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.play();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
*/
|
||||
const pause_audio = (t: HANDLE_TYPE = 'set'): void => {
|
||||
user_progress.save();
|
||||
const el = use_element('pause');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.paused ? el.play() : el.pause();
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mute audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
*/
|
||||
const mute_audio = (t: HANDLE_TYPE = 'set'): void => {
|
||||
const el = use_element('mute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unmute audio
|
||||
* @param {HANDLE_TYPE} t - Handle type
|
||||
*/
|
||||
const unmute_audio = (t: HANDLE_TYPE = 'set'): void => {
|
||||
const el = use_element('unmute');
|
||||
|
||||
if (t === 'toggle') {
|
||||
el.muted = !el.muted;
|
||||
} else {
|
||||
el.muted = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Seek to time audio
|
||||
* @param {number} seconds - Seconds to seek
|
||||
* @param {'from-start' | 'from-end'} from - Seek from start or end
|
||||
*/
|
||||
const seek_audio = (seconds: number, from: 'from-start' | 'from-end' = 'from-start'): void => {
|
||||
const el = use_element('seek');
|
||||
|
||||
if (from === 'from-end') {
|
||||
el.currentTime = clamp(0, el.duration, el.duration - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip by about audio
|
||||
* @param {number} seconds - Seconds to skip
|
||||
* @param {'forward' | 'backward'} type - Skip forward or backward
|
||||
*/
|
||||
const skip_audio = (seconds: number, type: 'forward' | 'backward' = 'forward'): void => {
|
||||
const el = use_element('skip');
|
||||
|
||||
if (type === 'backward') {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
|
||||
} else {
|
||||
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Episode audio object
|
||||
*/
|
||||
export const episode_audio = {
|
||||
/**
|
||||
* Subscribes to episode audio store
|
||||
*/
|
||||
subscribe: episode_state.subscribe,
|
||||
|
||||
load: load_audio,
|
||||
unload: unload_audio,
|
||||
|
||||
play: play_audio,
|
||||
pause: pause_audio,
|
||||
mute: mute_audio,
|
||||
unmute: unmute_audio,
|
||||
seek: seek_audio,
|
||||
skip: skip_audio,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface EpisodeDetails {
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode details store.
|
||||
*
|
||||
* @returns {Writable<EpisodeDetails>} A Svelte writable store that contains metadata for the current episode.
|
||||
*/
|
||||
export const episode_details = writable<EpisodeDetails | null>(null);
|
||||
@@ -1,28 +1,43 @@
|
||||
import { derived, type Readable } from 'svelte/store';
|
||||
import type { EpisodeProgress } from '../types';
|
||||
import { secondsToTimestamp } from '../utility';
|
||||
import { seconds_to_timestamp } from '../../utility';
|
||||
import { audio_element } from './audio-element';
|
||||
|
||||
export type EpisodeProgress = {
|
||||
current_time: number;
|
||||
timestamp: string;
|
||||
has_ended: boolean;
|
||||
};
|
||||
|
||||
const default_episode_progress = {
|
||||
current_time: 0,
|
||||
timestamp: '00:00',
|
||||
has_ended: false,
|
||||
} satisfies EpisodeProgress;
|
||||
|
||||
type ProgressStore = Readable<EpisodeProgress>;
|
||||
|
||||
/**
|
||||
* Episode progress store.
|
||||
*
|
||||
* @returns {Readable<EpisodeProgress>} A Svelte readable store that contains the current progress of the audio episode.
|
||||
*/
|
||||
export const episode_progress = derived(audio_element, ($audio, set) => {
|
||||
if (!$audio) return set(default_episode_progress);
|
||||
|
||||
/**
|
||||
* Set episode progress value.
|
||||
*/
|
||||
function set_value() {
|
||||
if (!$audio) return;
|
||||
set({
|
||||
current_time: $audio.currentTime,
|
||||
timestamp: secondsToTimestamp($audio.currentTime),
|
||||
timestamp: seconds_to_timestamp($audio.currentTime),
|
||||
has_ended: $audio.ended,
|
||||
});
|
||||
}
|
||||
|
||||
$audio.addEventListener('timeupdate', () => set_value());
|
||||
|
||||
/**
|
||||
* Remove event listener.
|
||||
*/
|
||||
return () => $audio.removeEventListener('timeupdate', () => set_value());
|
||||
}) satisfies ProgressStore;
|
||||
}) satisfies Readable<EpisodeProgress>;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './audio-element';
|
||||
export * from './episode-data';
|
||||
export * from './episode-details';
|
||||
export * from './episode-progress';
|
||||
@@ -1,145 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import { info } from '../utility/package/log';
|
||||
|
||||
export let step = 10;
|
||||
|
||||
let was_paused = true;
|
||||
|
||||
function handle_drag_start(t: string) {
|
||||
info('drag_start :: ', t);
|
||||
was_paused = $episode_audio?.is_paused || true;
|
||||
episode_audio.pause();
|
||||
}
|
||||
|
||||
function handle_drag_end(t: string) {
|
||||
info('drag_end :: ', t);
|
||||
if (was_paused) return;
|
||||
else episode_audio.play();
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
class={$$props.class}
|
||||
style="display:block; width:100%;"
|
||||
type="range"
|
||||
data-paused={$episode_audio?.is_paused ? 'true' : 'false'}
|
||||
min={0}
|
||||
{step}
|
||||
max={$episode_audio?.duration || step}
|
||||
value={$episode_progress.current_time}
|
||||
on:change={(e) => episode_audio.seek(e.currentTarget.valueAsNumber)}
|
||||
on:touchstart={() => handle_drag_start('touchstart')}
|
||||
on:mousedown={() => handle_drag_start('mousedown')}
|
||||
on:touchend={() => handle_drag_end('touchend')}
|
||||
on:mouseup={() => handle_drag_end('mouseup')}
|
||||
/>
|
||||
|
||||
<style>
|
||||
input[type='range'] {
|
||||
--track--shape--height: calc(var(--svpod--timeline-track--shape--height));
|
||||
--thumb-border-offset: calc(var(--svpod--timeline-thumb--shape--border) * 2);
|
||||
--thumb--shape--height: calc(
|
||||
var(--svpod--timeline-thumb--shape--height) - var(--thumb-border-offset)
|
||||
);
|
||||
|
||||
--svpod--timeline-track--bg: var(--svpod--surface--base);
|
||||
--svpod--timeline-track--border: var(--bg);
|
||||
|
||||
--svpod--timeline-thumb--bg: var(--svpod--content--base);
|
||||
--svpod--timeline-thumb--border: var(--bg);
|
||||
|
||||
font-size: 1em;
|
||||
|
||||
--borders: calc(var(--svpod--timeline-thumb--shape--border) * 2);
|
||||
--no-shadow: 0px 0px 0px transparent;
|
||||
|
||||
height: calc(var(--thumb--shape--height) + var(--borders));
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
input[type='range']:hover {
|
||||
--svpod--timeline-track--bg: var(--svpod--accent--darker);
|
||||
--svpod--timeline-track--border: var(--svpod--accent--base);
|
||||
--svpod--timeline-thumb--bg: var(--svpod--accent--lighter);
|
||||
}
|
||||
input[type='range']:focus {
|
||||
outline: none;
|
||||
}
|
||||
input[type='range']::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: var(--track--shape--height);
|
||||
cursor: pointer;
|
||||
animate: 0.2s;
|
||||
box-shadow: var(--no-shadow);
|
||||
background: var(--svpod--timeline-track--bg);
|
||||
border-radius: var(--svpod--timeline-track--shape--radius);
|
||||
border: var(--svpod--timeline-track--shape--border) solid var(--svpod--timeline-track--border);
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
--offset: calc(var(--svpod--timeline-track--shape--border) * -1);
|
||||
box-shadow: var(--no-shadow);
|
||||
border: var(--svpod--timeline-thumb--shape--border) solid var(--svpod--timeline-thumb--border);
|
||||
height: var(--thumb--shape--height);
|
||||
width: var(--svpod--timeline-thumb--shape--width);
|
||||
border-radius: var(--svpod--timeline-thumb--shape--radius);
|
||||
background: var(--svpod--timeline-thumb--bg);
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
margin-top: var(--offset);
|
||||
}
|
||||
input[type='range']:focus::-webkit-slider-runnable-track {
|
||||
background: var(--svpod--timeline-track--bg);
|
||||
}
|
||||
input[type='range']::-moz-range-track {
|
||||
width: 100%;
|
||||
height: var(--track--shape--height);
|
||||
cursor: pointer;
|
||||
animate: 0.2s;
|
||||
box-shadow: var(--no-shadow);
|
||||
background: var(--svpod--timeline-track--bg);
|
||||
border-radius: var(--svpod--timeline-track--shape--radius);
|
||||
border: var(--svpod--timeline-track--shape--border) solid var(--svpod--timeline-track--border);
|
||||
}
|
||||
input[type='range']::-moz-range-thumb {
|
||||
box-shadow: var(--no-shadow);
|
||||
border: var(--svpod--timeline-thumb--shape--border) solid var(--svpod--timeline-thumb--border);
|
||||
height: var(--thumb--shape--height);
|
||||
width: var(--svpod--timeline-thumb--shape--width);
|
||||
border-radius: var(--svpod--timeline-thumb--shape--radius);
|
||||
background: var(--svpod--timeline-thumb--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type='range']::-ms-track {
|
||||
width: 100%;
|
||||
height: var(--track--shape--height);
|
||||
cursor: pointer;
|
||||
animate: 0.2s;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
input[type='range']::-ms-fill-lower,
|
||||
input[type='range']::-ms-fill-upper {
|
||||
background: var(--svpod--timeline-track--bg);
|
||||
border: var(--svpod--timeline-track--shape--border) solid var(--svpod--timeline-track--border);
|
||||
border-radius: calc(var(--svpod--timeline-track--shape--radius) * 2);
|
||||
box-shadow: var(--no-shadow);
|
||||
}
|
||||
input[type='range']::-ms-thumb {
|
||||
margin-top: 1px;
|
||||
box-shadow: var(--no-shadow);
|
||||
border: var(--svpod--timeline-thumb--shape--border) solid var(--svpod--timeline-thumb--border);
|
||||
height: var(--thumb--shape--height);
|
||||
width: var(--svpod--timeline-thumb--shape--width);
|
||||
border-radius: var(--svpod--timeline-thumb--shape--radius);
|
||||
background: var(--svpod--timeline-thumb--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type='range']:focus::-ms-fill-lower,
|
||||
input[type='range']:focus::-ms-fill-upper {
|
||||
background: var(--svpod--timeline-track--bg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,2 @@
|
||||
export { default as HeadlessTimeline } from './headless-timeline.svelte';
|
||||
export { default as PlayerStack } from './player-stack.svelte';
|
||||
export { default as PlayerWidget } from './player-widget.svelte';
|
||||
export { default as AudioPlayer } from './player.svelte';
|
||||
export { default as AudioProgress } from './progress.svelte';
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Pause, Play, SpeakerWave } from '@inqling/svelte-icons/heroicon-20-solid';
|
||||
import { clsx } from 'clsx';
|
||||
import { HeadlessTimeline } from '.';
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import { user_preferences } from '../user';
|
||||
import { A11yIcon, Skip, Spinner, Timestamp } from './utility';
|
||||
|
||||
type WithElement = {
|
||||
timestamps: boolean;
|
||||
playback_rate: boolean;
|
||||
skip_back: number;
|
||||
skip_forward: number;
|
||||
};
|
||||
|
||||
const default_options = {
|
||||
timestamps: false,
|
||||
playback_rate: false,
|
||||
skip_back: 10,
|
||||
skip_forward: 10,
|
||||
} satisfies WithElement;
|
||||
|
||||
export let include: Partial<WithElement> = {};
|
||||
$: options = { ...default_options, ...include };
|
||||
|
||||
$: is_playing = $episode_audio?.is_paused === false;
|
||||
|
||||
$: timestamp_hours = Boolean($episode_audio?.duration && $episode_audio.duration >= 3600);
|
||||
|
||||
export let playback_rate_values: number[] = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
|
||||
|
||||
$: is_loaded = Boolean($episode_audio?.src);
|
||||
|
||||
const { class: ClassName, ...rest } = $$restProps;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx('svpod--container svpod--reset', ClassName)}
|
||||
data-loaded={is_loaded ? 'true' : 'false'}
|
||||
{...rest}
|
||||
>
|
||||
<div class="svpod--container--row svpod--row--controls">
|
||||
<button
|
||||
on:click={() => options.skip_back && episode_audio.skip(options.skip_back, 'backward')}
|
||||
class="svpod--reset svpod--toggle-pause"
|
||||
type="button"
|
||||
>
|
||||
<Skip value={options.skip_back} type="backward" />
|
||||
</button>
|
||||
|
||||
<!-- toggle play -->
|
||||
{#if is_loaded}
|
||||
<button
|
||||
on:click={() => is_loaded && episode_audio.play('toggle')}
|
||||
class="svpod--reset svpod--toggle-pause"
|
||||
type="button"
|
||||
>
|
||||
<A11yIcon icon={is_playing ? Pause : Play} label={is_playing ? 'Pause' : 'Play'} />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="svpod--reset svpod--toggle-pause">
|
||||
<A11yIcon icon={Spinner} label="Waiting for audio..." />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
on:click={() =>
|
||||
options.skip_forward && episode_audio.skip(options.skip_forward, 'forward')}
|
||||
class="svpod--reset svpod--toggle-pause"
|
||||
type="button"
|
||||
>
|
||||
<Skip value={options.skip_forward} type="forward" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="svpod--artwork svpod--row--artwork">
|
||||
<span class="svpod--aspect--square" />
|
||||
<slot>
|
||||
<div class="svpod--artwork--placeholder">
|
||||
<span><SpeakerWave style="width:1em; height: 1em;" /></span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="svpod--timeline svpod--row--timeline">
|
||||
<HeadlessTimeline />
|
||||
</div>
|
||||
|
||||
{#if options.timestamps || options.playback_rate}
|
||||
<div class="svpod--container--row svpod--row--timestamps">
|
||||
{#if options.timestamps}
|
||||
<div class="svpod--timestamp">
|
||||
<Timestamp
|
||||
value={$episode_progress?.current_time || 0}
|
||||
force_hours={timestamp_hours}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="flex-grow: 1" />
|
||||
|
||||
<div class="svpod--timestamp">
|
||||
<Timestamp value={$episode_audio?.duration || 0} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if options.playback_rate}
|
||||
<select
|
||||
class="svpod--playback-rate"
|
||||
value={$user_preferences.playback_rate}
|
||||
on:change={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value);
|
||||
user_preferences.set.playback_rate(value);
|
||||
}}
|
||||
>
|
||||
{#each playback_rate_values as value}
|
||||
<option {value}>
|
||||
{#if Number.isInteger(value)}
|
||||
{value}.0
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.svpod--container {
|
||||
--fg: var(--svpod--content--base);
|
||||
--bg: var(--svpod--surface--darker);
|
||||
|
||||
--padding: 2px;
|
||||
--radius: 0.75em;
|
||||
--inner-radius: calc(var(--radius) - var(--padding));
|
||||
|
||||
--svpod--timeline-track--shape--height: 3em;
|
||||
--svpod--timeline-track--shape--radius: var(--inner-radius);
|
||||
--svpod--timeline-track--shape--border: 2px;
|
||||
|
||||
--svpod--timeline-thumb--shape--height: var(--svpod--timeline-track--shape--height);
|
||||
--svpod--timeline-thumb--shape--width: 3px;
|
||||
--svpod--timeline-thumb--shape--radius: 1px;
|
||||
--svpod--timeline-thumb--shape--border: 0px;
|
||||
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
font-size: 16px;
|
||||
line-height: 1em;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
padding: var(--padding);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.svpod--container--row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
min-width: max-content;
|
||||
border-radius: var(--inner-radius);
|
||||
gap: var(--padding);
|
||||
}
|
||||
.svpod--container--row:last-child {
|
||||
border-top: 1px solid var(--svpod--surface--base);
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.svpod--row--artwork {
|
||||
order: 0;
|
||||
}
|
||||
.svpod--row--timeline {
|
||||
order: 1;
|
||||
}
|
||||
.svpod--row--controls {
|
||||
order: 2;
|
||||
justify-content: center;
|
||||
}
|
||||
.svpod--row--timestamps {
|
||||
order: 3;
|
||||
min-height: 32px;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
div.svpod--timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
font-size: 1em;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.svpod--toggle-pause {
|
||||
align-items: center;
|
||||
border: none;
|
||||
display: flex;
|
||||
font-size: 1em;
|
||||
height: 3em;
|
||||
justify-content: center;
|
||||
width: 3em;
|
||||
border-radius: var(--inner-radius);
|
||||
}
|
||||
button.svpod--toggle-pause {
|
||||
cursor: pointer;
|
||||
}
|
||||
.svpod--toggle-pause :global(svg) {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
div.svpod--timestamp {
|
||||
background-color: var(--bg);
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
pointer-events: none;
|
||||
border-radius: var(--inner-radius);
|
||||
width: max-content;
|
||||
flex-grow: 0;
|
||||
text-align: center;
|
||||
padding: 0 0.1em;
|
||||
}
|
||||
|
||||
select.svpod--playback-rate {
|
||||
background-color: var(--bg);
|
||||
appearance: none;
|
||||
padding: 0 0.75em;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
min-width: 48px;
|
||||
outline-color: var(--svpod--accent--lighter);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
border-radius: var(--inner-radius);
|
||||
}
|
||||
select.svpod--playback-rate,
|
||||
select.svpod--playback-rate:hover,
|
||||
select.svpod--playback-rate:focus {
|
||||
border: none;
|
||||
shadow: none;
|
||||
}
|
||||
select.svpod--playback-rate:focus {
|
||||
--fg: var(--svpod--accent--lighter);
|
||||
}
|
||||
|
||||
button.svpod--toggle-pause,
|
||||
select.svpod--playback-rate {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
button.svpod--toggle-pause:hover,
|
||||
select.svpod--playback-rate:hover {
|
||||
--bg: var(--svpod--surface--base);
|
||||
--fg: var(--svpod--content--lighter);
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
div.svpod--artwork {
|
||||
position: relative;
|
||||
border-radius: var(--inner-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
div.svpod--artwork > :global(div),
|
||||
div.svpod--artwork > :global(img) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.svpod--artwork--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--svpod--accent--lighter);
|
||||
font-size: 2.5em;
|
||||
}
|
||||
.svpod--artwork--placeholder :global(svg) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.svpod--artwork--placeholder::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
opacity: 0.5;
|
||||
background: linear-gradient(45deg, var(--svpod--accent--base), transparent);
|
||||
}
|
||||
|
||||
span.svpod--aspect--square {
|
||||
display: block;
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,235 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Pause, Play } from '@inqling/svelte-icons/heroicon-20-solid';
|
||||
import { clsx } from 'clsx';
|
||||
import { HeadlessTimeline } from '.';
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import { user_preferences } from '../user';
|
||||
import { A11yIcon, Skip, Spinner, Timestamp } from './utility';
|
||||
|
||||
type WithElement = {
|
||||
duration: boolean;
|
||||
current_time: boolean;
|
||||
playback_rate: boolean;
|
||||
skip_back: number | null;
|
||||
skip_forward: number | null;
|
||||
};
|
||||
|
||||
const default_options = {
|
||||
duration: false,
|
||||
current_time: false,
|
||||
playback_rate: false,
|
||||
skip_back: null,
|
||||
skip_forward: null,
|
||||
} satisfies WithElement;
|
||||
|
||||
export let include: Partial<WithElement> = {};
|
||||
$: options = { ...default_options, ...include };
|
||||
|
||||
$: is_playing = $episode_audio?.is_paused === false;
|
||||
|
||||
$: current_time = options.current_time && ($episode_progress?.current_time || 0);
|
||||
$: duration = options.duration && ($episode_audio?.duration || 0);
|
||||
$: timestamp_hours = Boolean($episode_audio?.duration && $episode_audio.duration >= 3600);
|
||||
|
||||
export let playback_rate_values: number[] = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
|
||||
|
||||
$: is_loaded = Boolean($episode_audio?.src);
|
||||
|
||||
const { class: ClassName, ...rest } = $$restProps;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx('svpod--container svpod--reset', ClassName)}
|
||||
data-loaded={is_loaded ? 'true' : 'false'}
|
||||
{...rest}
|
||||
>
|
||||
{#if options.skip_back}
|
||||
<button
|
||||
on:click={() => options.skip_back && episode_audio.skip(options.skip_back, 'backward')}
|
||||
class="svpod--reset svpod--toggle-pause"
|
||||
type="button"
|
||||
>
|
||||
<Skip value={options.skip_back} type="backward" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- toggle play -->
|
||||
{#if is_loaded}
|
||||
<button
|
||||
on:click={() => is_loaded && episode_audio.play('toggle')}
|
||||
class="svpod--reset svpod--toggle-pause"
|
||||
type="button"
|
||||
>
|
||||
<A11yIcon icon={is_playing ? Pause : Play} label={is_playing ? 'Pause' : 'Play'} />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="svpod--reset svpod--toggle-pause">
|
||||
<A11yIcon icon={Spinner} label="Waiting for audio..." />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if options.skip_forward}
|
||||
<button
|
||||
on:click={() =>
|
||||
options.skip_forward && episode_audio.skip(options.skip_forward, 'forward')}
|
||||
class="svpod--reset svpod--toggle-pause"
|
||||
type="button"
|
||||
>
|
||||
<Skip value={options.skip_forward} type="forward" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if current_time !== false}
|
||||
<div class="svpod--timestamp">
|
||||
<Timestamp value={current_time} force_hours={timestamp_hours} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="svpod--timeline">
|
||||
<HeadlessTimeline />
|
||||
</div>
|
||||
{#if duration !== false}
|
||||
<div class="svpod--timestamp">
|
||||
<Timestamp value={duration} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if options.playback_rate}
|
||||
<select
|
||||
class="svpod--playback-rate"
|
||||
value={$user_preferences.playback_rate}
|
||||
on:change={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value);
|
||||
user_preferences.set.playback_rate(value);
|
||||
}}
|
||||
>
|
||||
{#each playback_rate_values as value}
|
||||
<option {value}>
|
||||
{#if Number.isInteger(value)}
|
||||
{value}.0
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.svpod--container {
|
||||
--fg: var(--svpod--content--base);
|
||||
--bg: var(--svpod--surface--darker);
|
||||
|
||||
--padding: 2px;
|
||||
--radius: 0.5em;
|
||||
--inner-radius: calc(var(--radius) - var(--padding));
|
||||
|
||||
--svpod--timeline-track--shape--height: 3em;
|
||||
--svpod--timeline-track--shape--radius: var(--inner-radius);
|
||||
--svpod--timeline-track--shape--border: 2px;
|
||||
|
||||
--svpod--timeline-thumb--shape--height: var(--svpod--timeline-track--shape--height);
|
||||
--svpod--timeline-thumb--shape--width: 3px;
|
||||
--svpod--timeline-thumb--shape--radius: 1px;
|
||||
--svpod--timeline-thumb--shape--border: 0px;
|
||||
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
font-size: 16px;
|
||||
line-height: 1em;
|
||||
min-width: 320px;
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
padding: var(--padding);
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
div.svpod--timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
font-size: 1em;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.svpod--toggle-pause {
|
||||
align-items: center;
|
||||
border: none;
|
||||
display: flex;
|
||||
font-size: 1em;
|
||||
height: 3em;
|
||||
justify-content: center;
|
||||
width: 3em;
|
||||
border-radius: var(--inner-radius);
|
||||
}
|
||||
button.svpod--toggle-pause {
|
||||
cursor: pointer;
|
||||
}
|
||||
.svpod--toggle-pause :global(svg) {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
div.svpod--timestamp {
|
||||
background-color: var(--bg);
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
pointer-events: none;
|
||||
border-radius: var(--inner-radius);
|
||||
width: max-content;
|
||||
flex-grow: 0;
|
||||
text-align: center;
|
||||
padding: 0 0.1em;
|
||||
}
|
||||
|
||||
select.svpod--playback-rate {
|
||||
background-color: var(--bg);
|
||||
appearance: none;
|
||||
padding: 0 0.75em;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
min-width: 48px;
|
||||
outline-color: var(--svpod--accent--lighter);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
border-radius: var(--inner-radius);
|
||||
}
|
||||
select.svpod--playback-rate,
|
||||
select.svpod--playback-rate:hover,
|
||||
select.svpod--playback-rate:focus {
|
||||
border: none;
|
||||
shadow: none;
|
||||
}
|
||||
select.svpod--playback-rate:focus {
|
||||
--fg: var(--svpod--accent--lighter);
|
||||
}
|
||||
|
||||
button.svpod--toggle-pause,
|
||||
select.svpod--playback-rate {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
button.svpod--toggle-pause:hover,
|
||||
select.svpod--playback-rate:hover {
|
||||
--bg: var(--svpod--surface--base);
|
||||
--fg: var(--svpod--content--lighter);
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { AudioProgress } from '.';
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import type { EpisodeDetails } from '../audio/stores';
|
||||
import { user_preferences } from '../user';
|
||||
import { seconds_to_timestamp } from '../utility';
|
||||
|
||||
export let src: string | undefined;
|
||||
export let metadata: EpisodeDetails = {};
|
||||
let mounted = false;
|
||||
|
||||
$: is_loaded = Boolean($episode_audio?.src);
|
||||
$: is_playing = $episode_audio?.is_paused === false;
|
||||
$: current_time = $episode_progress?.current_time || 0;
|
||||
$: duration = $episode_audio?.duration || 0;
|
||||
$: timestamp_hours = Boolean($episode_audio?.duration && $episode_audio.duration >= 3600);
|
||||
$: timestamp = duration && seconds_to_timestamp(duration);
|
||||
|
||||
onMount(() => {
|
||||
if (!src) {
|
||||
mounted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
episode_audio.load(src, metadata || {});
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
$: mounted && src && episode_audio.load(src, metadata || {});
|
||||
|
||||
/**
|
||||
* Skips the audio forward by a specified number of seconds.
|
||||
* @param {number} seconds - The number of seconds to skip forward.
|
||||
*/
|
||||
const skip_forward = (seconds: number) => episode_audio.skip(seconds, 'forward');
|
||||
|
||||
/**
|
||||
* Skips the audio backward by a specified number of seconds.
|
||||
* @param {number} seconds - The number of seconds to skip backward.
|
||||
*/
|
||||
const skip_back = (seconds: number) => episode_audio.skip(seconds, 'backward');
|
||||
|
||||
/**
|
||||
* Seeks to a specific time in the audio.
|
||||
* @param {number} value - The time to seek to, in seconds.
|
||||
*/
|
||||
const seek_to = (value: number) => episode_audio.seek(value);
|
||||
|
||||
/**
|
||||
* Toggles the audio playback between play and pause.
|
||||
*/
|
||||
const toggle = () => episode_audio.play('toggle');
|
||||
|
||||
/**
|
||||
* Starts playing the audio.
|
||||
*/
|
||||
const play = () => episode_audio.play('set');
|
||||
|
||||
/**
|
||||
* Pauses the audio.
|
||||
*/
|
||||
const pause = () => episode_audio.pause('set');
|
||||
</script>
|
||||
|
||||
<slot
|
||||
Player={{ AudioProgress }}
|
||||
action={{
|
||||
set_playback_rate: user_preferences.set_playback_rate,
|
||||
set_volume: user_preferences.set_volume,
|
||||
skip_forward,
|
||||
skip_back,
|
||||
seek_to,
|
||||
toggle,
|
||||
play,
|
||||
pause,
|
||||
}}
|
||||
episode={{
|
||||
is_loaded,
|
||||
is_playing,
|
||||
current_time,
|
||||
duration,
|
||||
timestamp_hours,
|
||||
timestamp,
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import { announce } from '../utility';
|
||||
|
||||
export let step = 10;
|
||||
|
||||
let was_paused = true;
|
||||
|
||||
function handle_drag_start(t: string) {
|
||||
announce.info('drag_start :: ', t);
|
||||
was_paused = $episode_audio?.is_paused || true;
|
||||
episode_audio.pause();
|
||||
}
|
||||
|
||||
function handle_drag_end(t: string) {
|
||||
announce.info('drag_end :: ', t);
|
||||
if (was_paused) return;
|
||||
else episode_audio.play();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $episode_audio && $episode_progress}
|
||||
<input
|
||||
class={$$restProps.class}
|
||||
style="display:block; width:100%;"
|
||||
type="range"
|
||||
data-paused={$episode_audio.is_paused ? 'true' : 'false'}
|
||||
min={0}
|
||||
{step}
|
||||
max={$episode_audio.duration || step}
|
||||
value={$episode_progress.current_time}
|
||||
on:change={(e) => episode_audio.seek(e.currentTarget.valueAsNumber)}
|
||||
on:touchstart={() => handle_drag_start('touchstart')}
|
||||
on:mousedown={() => handle_drag_start('mousedown')}
|
||||
on:touchend={() => handle_drag_end('touchend')}
|
||||
on:mouseup={() => handle_drag_end('mouseup')}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class={$$restProps.class}
|
||||
style="display:block; width:100%;"
|
||||
type="range"
|
||||
data-paused={true}
|
||||
min={0}
|
||||
step={1}
|
||||
max={1}
|
||||
value={0}
|
||||
disabled
|
||||
readonly
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,9 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteIcon } from '@inqling/svelte-icons';
|
||||
|
||||
export let icon: SvelteIcon;
|
||||
export let label: string;
|
||||
</script>
|
||||
|
||||
<svelte:component this={icon} />
|
||||
<span class="svpod--a11y-hidden">{label}</span>
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as A11yIcon } from './a11y-icon.svelte';
|
||||
export { default as Skip } from './skip.svelte';
|
||||
export { default as Spinner } from './spinner.svelte';
|
||||
export { default as Timestamp } from './timestamp.svelte';
|
||||
@@ -1,155 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let size = 24;
|
||||
export let value: number;
|
||||
export let type: 'forward' | 'backward';
|
||||
</script>
|
||||
|
||||
<div class="svpod--skip-container" style="font-size:{size}px">
|
||||
<span class="svpod--skip-label">
|
||||
<span class="svpod--a11y-hidden">skip {type}</span>
|
||||
{value}
|
||||
<span class="svpod--a11y-hidden">seconds</span>
|
||||
</span>
|
||||
<span
|
||||
class="svpod--skip-mask"
|
||||
class:svpod--skip-mask--right={type === 'backward'}
|
||||
class:svpod--skip-mask--left={type === 'backward'}
|
||||
/>
|
||||
<div class="svpod--skip-icon">
|
||||
{#if type === 'forward'}
|
||||
<!-- forward -->
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="font-size:{size}px"
|
||||
>
|
||||
<path
|
||||
d="M15.5922 0.913446C14.1363 0.310389 12.5759 -1.8792e-08 11 0L11 2C12.3132 2 13.6136 2.25866 14.8268 2.7612C16.0401 3.26375 17.1425 4.00035 18.0711 4.92893C18.9997 5.85752 19.7362 6.95991 20.2388 8.17317C20.6133 9.07731 20.8524 10.0298 20.9499 11H22.9583C22.8551 9.76677 22.5617 8.55484 22.0866 7.4078C21.4835 5.95189 20.5996 4.62902 19.4853 3.51472C18.371 2.40042 17.0481 1.5165 15.5922 0.913446Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M22.9583 13C22.9338 13.2923 22.8987 13.5835 22.853 13.8728L20.9082 13.3517C20.9242 13.2348 20.9381 13.1175 20.9499 13H22.9583Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20.4408 15.297L22.3771 15.8159C22.2894 16.0775 22.1925 16.3365 22.0866 16.5922C22.078 16.6128 22.0694 16.6334 22.0608 16.6539L20.3123 15.6444C20.3573 15.5293 20.4002 15.4135 20.4408 15.297Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M19.3994 17.4268L21.1333 18.4278C20.9675 18.6892 20.7916 18.944 20.6061 19.1919L19.1744 17.7602C19.2516 17.6505 19.3267 17.5394 19.3994 17.4268Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.8609 19.2751L19.2757 20.6899C19.0399 20.9144 18.7954 21.1289 18.5429 21.333L17.5281 19.5752C17.6412 19.4778 17.7521 19.3777 17.8609 19.2751Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M15.8834 20.7265L16.8835 22.4587C16.5838 22.6273 16.2766 22.7831 15.9628 22.9257L15.4366 20.9619C15.5876 20.8872 15.7366 20.8087 15.8834 20.7265Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.5553 21.668L14.0729 23.5999C13.7189 23.6937 13.3609 23.7712 13 23.8322V21.798C13.1862 21.7599 13.3714 21.7166 13.5553 21.668Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- back -->
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="font-size:{size}px"
|
||||
>
|
||||
<path
|
||||
d="M11.0003 21.9499V23.9583C10.6215 23.9266 10.2447 23.877 9.87115 23.8096L10.391 21.8697C10.5931 21.9026 10.7963 21.9294 11.0003 21.9499Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.8915 21.1169C7.98478 21.159 8.07876 21.1996 8.17342 21.2388C8.26813 21.278 8.36337 21.3158 8.45911 21.352L7.9393 23.292C7.7608 23.2278 7.58367 23.1593 7.40805 23.0866C7.23249 23.0138 7.05886 22.937 6.8873 22.8562L7.8915 21.1169Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M5.67173 19.7427C5.8305 19.8725 5.99315 19.9973 6.15943 20.1169L5.15524 21.8563C4.84345 21.6397 4.54188 21.4084 4.25159 21.1629L5.67173 19.7427Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3.88341 17.841C4.00303 18.0072 4.12778 18.1698 4.25752 18.3285L2.83737 19.7487C2.59192 19.4584 2.36059 19.1569 2.14409 18.8452L3.88341 17.841Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2.64825 15.5412C2.68449 15.6369 2.72223 15.7321 2.76146 15.8268C2.80069 15.9215 2.84134 16.0156 2.88341 16.1089L1.14409 17.1131C1.06327 16.9415 0.986441 16.7678 0.913699 16.5922C0.840963 16.4166 0.772485 16.2395 0.708295 16.061L2.64825 15.5412Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2.05038 13C2.07088 13.204 2.09764 13.4072 2.1306 13.6093L0.19065 14.1291C0.123306 13.7556 0.0736704 13.3788 0.0419922 13H2.05038Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2.05038 11C2.14788 10.0298 2.38695 9.07731 2.76146 8.17317C3.26401 6.95991 4.0006 5.85752 4.92919 4.92893C5.85777 4.00035 6.96016 3.26375 8.17342 2.7612C9.38667 2.25866 10.687 2 12.0003 2V0C10.4244 1.8792e-08 8.86396 0.310389 7.40805 0.913446C5.95215 1.5165 4.62928 2.40042 3.51497 3.51472C2.40067 4.62902 1.51676 5.95189 0.913699 7.4078C0.438578 8.55484 0.145121 9.76677 0.0419922 11H2.05038Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div.svpod--skip-container {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
div.svpod--skip-icon,
|
||||
span.svpod--skip-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
span.svpod--skip-mask {
|
||||
z-index: 1;
|
||||
background: linear-gradient(0deg, var(--bg), transparent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
div.svpod--skip-icon {
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--svpod--content--base);
|
||||
}
|
||||
|
||||
span.svpod--skip-label {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: 0.5em;
|
||||
line-height: 1em;
|
||||
color: var(--svpod--content--lighter);
|
||||
}
|
||||
|
||||
@keyframes Spin {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
/* animation: Spin 1s cubic-bezier(0.5, 0.1, 0.1, 0.8) infinite; */
|
||||
}
|
||||
</style>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let size = 24;
|
||||
</script>
|
||||
|
||||
<div class="svpod--spinner-container" style="width:{size}px; height:{size}px">
|
||||
{#each [0, 0.075, 0.15] as delay}
|
||||
<div class="svpod--item">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="animation-delay: {delay}s;"
|
||||
>
|
||||
<path
|
||||
d="M24 12C24 14.3734 23.2962 16.6935 21.9776 18.6668C20.6591 20.6402 18.7849 22.1783 16.5922 23.0866C14.3995 23.9948 11.9867 24.2324 9.65892 23.7694C7.33114 23.3064 5.19295 22.1635 3.51472 20.4853C1.83649 18.8071 0.693599 16.6689 0.230577 14.3411C-0.232446 12.0133 0.00519403 9.60051 0.913446 7.4078C1.8217 5.21509 3.35977 3.34094 5.33316 2.02236C7.30655 0.703788 9.62662 -2.83022e-08 12 0L12 3C10.22 3 8.47991 3.52784 6.99987 4.51677C5.51983 5.50571 4.36627 6.91131 3.68508 8.55585C3.0039 10.2004 2.82567 12.01 3.17293 13.7558C3.5202 15.5016 4.37737 17.1053 5.63604 18.364C6.89471 19.6226 8.49836 20.4798 10.2442 20.8271C11.99 21.1743 13.7996 20.9961 15.4442 20.3149C17.0887 19.6337 18.4943 18.4802 19.4832 17.0001C20.4722 15.5201 21 13.78 21 12H24Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div.svpod--spinner-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div.svpod--item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
color: var(--svpod--accent--lighter);
|
||||
}
|
||||
div.svpod--item:nth-child(1) {
|
||||
opacity: 1;
|
||||
color: var(--svpod--content--lighter);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes Spin {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
animation: Spin 1s cubic-bezier(0.5, 0.1, 0.1, 0.8) infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { secondsToTimestamp } from '../../utility';
|
||||
|
||||
export let value: number;
|
||||
export let force_hours = false;
|
||||
|
||||
$: timestamp = secondsToTimestamp(value, force_hours);
|
||||
</script>
|
||||
|
||||
<span style="width:{timestamp.length}ch">{timestamp}</span>
|
||||
|
||||
<style>
|
||||
span {
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.8em;
|
||||
display: block;
|
||||
width: 8ch;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './audio';
|
||||
export * from './components';
|
||||
export { AudioPlayer as default } from './components';
|
||||
export * from './user';
|
||||
export * from './utility';
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export interface EpisodeDetails {
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
export type EpisodeProgress = { current_time: number; timestamp: string; has_ended: boolean };
|
||||
|
||||
export type EpisodeAttributes = {
|
||||
will_autoplay: boolean;
|
||||
is_paused: boolean;
|
||||
duration: number;
|
||||
src: string;
|
||||
start_at: number;
|
||||
details: EpisodeDetails | null;
|
||||
};
|
||||
|
||||
export type AudioLoadOptions = {
|
||||
autoplay: boolean;
|
||||
start_at?: number;
|
||||
};
|
||||
|
||||
export interface UserPreferences {
|
||||
playback_rate: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export type UserProgress = {
|
||||
[key: string]: number;
|
||||
};
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './preferences';
|
||||
export * from './progress';
|
||||
export * from './user-preferences';
|
||||
export * from './user-progress';
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import clamp from 'just-clamp';
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
import type { UserPreferences } from '../types';
|
||||
|
||||
const _default_user_preferences = { playback_rate: 1, volume: 1 } satisfies UserPreferences;
|
||||
const _user_preferences = persisted<UserPreferences>('USER_PREFERENCE', _default_user_preferences);
|
||||
|
||||
export const user_preferences = {
|
||||
subscribe: _user_preferences.subscribe,
|
||||
set: {
|
||||
playback_rate: (value: number) => {
|
||||
const playback_rate = clamp(value, 0.5, 5);
|
||||
return _user_preferences.update((prefs) => ({ ...prefs, playback_rate }));
|
||||
},
|
||||
volume: (value: number) => {
|
||||
const volume = clamp(value, 0, 1);
|
||||
return _user_preferences.update((prefs) => ({ ...prefs, volume }));
|
||||
},
|
||||
},
|
||||
clear: () => _user_preferences.set(_default_user_preferences),
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
import { get } from 'svelte/store';
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import type { UserProgress } from '../types';
|
||||
import { get_pathname_from_url } from '../utility';
|
||||
import { info } from '../utility/package/log';
|
||||
|
||||
const _default_user_progress = {} satisfies UserProgress;
|
||||
const _user_progress = persisted<UserProgress>('USER_PROGRESS', _default_user_progress);
|
||||
|
||||
const save = () => {
|
||||
const audio = get(episode_audio);
|
||||
if (!audio?.src) return;
|
||||
|
||||
info('saving progress: ', audio.src);
|
||||
|
||||
const pathname = get_pathname_from_url(audio.src);
|
||||
const current_time = get(episode_progress).current_time;
|
||||
_user_progress.update((prev) => ({ ...prev, [pathname]: current_time }));
|
||||
};
|
||||
|
||||
export const user_progress = {
|
||||
subscribe: _user_progress.subscribe,
|
||||
get: (src: string) => get(_user_progress)[get_pathname_from_url(src)],
|
||||
save,
|
||||
clear: () => _user_progress.set(_default_user_progress),
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import clamp from 'just-clamp';
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
|
||||
export interface UserPreferences {
|
||||
playback_rate: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
const default_user_preferences = { playback_rate: 1, volume: 1 };
|
||||
const user_preferences_store = persisted<UserPreferences>(
|
||||
'USER_PREFERENCE',
|
||||
default_user_preferences,
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the playback rate for the user
|
||||
* @param value - The playback rate value
|
||||
* @returns A Promise that resolves with the updated user preferences
|
||||
*/
|
||||
const set_playback_rate = (value: number) => {
|
||||
const playback_rate = clamp(value, 0.5, 5);
|
||||
return user_preferences_store.update((prefs) => ({ ...prefs, playback_rate }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the volume for the user
|
||||
* @param value - The volume value
|
||||
* @returns A Promise that resolves with the updated user preferences
|
||||
*/
|
||||
const set_volume = (value: number) => {
|
||||
const volume = clamp(value, 0, 1);
|
||||
return user_preferences_store.update((prefs) => ({ ...prefs, volume }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears user preferences store
|
||||
*/
|
||||
const clear_store = () => user_preferences_store.set(default_user_preferences);
|
||||
|
||||
export const user_preferences = {
|
||||
/**
|
||||
* Subscribes to user preferences store
|
||||
*/
|
||||
subscribe: user_preferences_store.subscribe,
|
||||
|
||||
set_playback_rate,
|
||||
set_volume,
|
||||
clear: clear_store,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
import { get } from 'svelte/store';
|
||||
import { episode_audio, episode_progress } from '../audio';
|
||||
import { announce, use_url } from '../utility';
|
||||
|
||||
export type UserProgress = {
|
||||
[key: string]: number;
|
||||
};
|
||||
|
||||
const default_user_progress = {};
|
||||
const user_progress_store = persisted<UserProgress>('USER_PROGRESS', default_user_progress);
|
||||
|
||||
/**
|
||||
* Saves user progress
|
||||
*/
|
||||
const save = () => {
|
||||
const audio = get(episode_audio);
|
||||
if (!audio?.src) return;
|
||||
|
||||
announce.info('saving progress: ', audio.src);
|
||||
|
||||
const pathname = use_url(audio.src).pathname;
|
||||
const current_time = get(episode_progress).current_time;
|
||||
user_progress_store.update((prev) => ({ ...prev, [pathname]: current_time }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets user progress for a given audio source
|
||||
* @param {string} src - Audio source
|
||||
* @returns {number} - User progress for the given audio source
|
||||
*/
|
||||
const get_user_progress = (src: string) => {
|
||||
const pathname = use_url(src).pathname;
|
||||
const store = get(user_progress_store) as UserProgress;
|
||||
|
||||
return store[pathname];
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears user progress store
|
||||
*/
|
||||
const clear_user_progress = () => user_progress_store.set(default_user_progress);
|
||||
|
||||
export const user_progress = {
|
||||
/**
|
||||
* Subscribes to user preferences store
|
||||
*/
|
||||
subscribe: user_progress_store.subscribe,
|
||||
|
||||
get: get_user_progress,
|
||||
clear: clear_user_progress,
|
||||
save,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
const use_logger = {
|
||||
info: console.info,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
};
|
||||
|
||||
type Logger = keyof typeof use_logger;
|
||||
|
||||
/**
|
||||
* Log function.
|
||||
* @param type - Type of logger.
|
||||
* @param content - Content to log.
|
||||
*/
|
||||
function log(type: Logger, ...content: unknown[]) {
|
||||
const logger = use_logger[type];
|
||||
|
||||
// If type is info and not in dev mode, return
|
||||
if (type === 'info' && !dev) return;
|
||||
|
||||
// Log message
|
||||
logger(
|
||||
'%c🔊 svelte-podcast:',
|
||||
'color: #FF3E00; background-color: rgba(255, 62, 0, 0.15); padding: 4px 8px; border-radius:4px;',
|
||||
...content,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Object containing logger functions.
|
||||
*/
|
||||
export const announce = {
|
||||
/**
|
||||
* Log info message.
|
||||
* @param content - Content to log.
|
||||
*/
|
||||
info: (...content: unknown[]) => log('info', ...content),
|
||||
/**
|
||||
* Log warning message.
|
||||
* @param content - Content to log.
|
||||
*/
|
||||
warn: (...content: unknown[]) => log('warn', ...content),
|
||||
/**
|
||||
* Log error message.
|
||||
* @param content - Content to log.
|
||||
*/
|
||||
error: (...content: unknown[]) => log('error', ...content),
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export const get_pathname_from_url = (src: string) => {
|
||||
if (src.startsWith('http')) return new URL(src).pathname;
|
||||
else return new URL(src, 'https://svelte.dev').pathname;
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './get-pathname-from-url';
|
||||
export * from './announce';
|
||||
export * from './seconds-to-timestamp';
|
||||
export * from './use-url';
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
const useLogger = {
|
||||
info: console.info,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
};
|
||||
|
||||
type Logger = keyof typeof useLogger;
|
||||
|
||||
export function log(type: Logger, ...content: unknown[]) {
|
||||
const logger = useLogger[type];
|
||||
|
||||
if (type === 'info' && !dev) return;
|
||||
|
||||
logger(
|
||||
'%c🔊 svelte-podcast:',
|
||||
'color: #FF3E00; background-color: rgba(255, 62, 0, 0.15); padding: 4px 8px; border-radius:4px;',
|
||||
...content,
|
||||
);
|
||||
}
|
||||
|
||||
export const info = (...content: unknown[]) => log('info', ...content);
|
||||
export const warn = (...content: unknown[]) => log('warn', ...content);
|
||||
export const error = (...content: unknown[]) => log('error', ...content);
|
||||
@@ -1,7 +0,0 @@
|
||||
export function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
export function isNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
function section(t: number) {
|
||||
if (t < 10) return `0${t}`;
|
||||
return t;
|
||||
/**
|
||||
* Returns a string timestamp in the format of "hh:mm:ss" from a given number of seconds.
|
||||
* @param seconds - The number of seconds to convert to a timestamp.
|
||||
* @param force_hours - Whether to always include hours in the timestamp, even if it's 0.
|
||||
* @returns A string timestamp in the format of "hh:mm:ss".
|
||||
*/
|
||||
export function seconds_to_timestamp(seconds: number, force_hours = false) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const hours_remainder = seconds % 3600;
|
||||
|
||||
const minutes = Math.floor(hours_remainder / 60);
|
||||
const minutes_remainder = hours_remainder % 60;
|
||||
|
||||
const secs = Math.floor(minutes_remainder);
|
||||
|
||||
const hours_str = force_hours || hours > 0 ? `${section(hours)}:` : '';
|
||||
const minutes_str = minutes ? `${section(minutes)}:` : '00:';
|
||||
const seconds_str = secs ? `${section(secs)}` : '00';
|
||||
|
||||
return `${hours_str}${minutes_str}${seconds_str}`;
|
||||
}
|
||||
|
||||
export function secondsToTimestamp(seconds: number, force_hours = false) {
|
||||
const hh = Math.floor(seconds / 3600);
|
||||
const hh_remainder = seconds % 3600;
|
||||
const mm = Math.floor(hh_remainder / 60);
|
||||
const mm_remainder = hh_remainder % 60;
|
||||
const ss = Math.floor(mm_remainder);
|
||||
|
||||
const hrs = force_hours || hh > 0 ? `${section(hh)}:` : '';
|
||||
const mins = mm ? `${section(mm)}:` : '00:';
|
||||
const secs = ss ? `${section(ss)}` : '00';
|
||||
|
||||
return `${hrs}${mins}${secs}`;
|
||||
/**
|
||||
* Returns a string representation of a number with a leading 0 if it's less than 10.
|
||||
* @param num - The number to format.
|
||||
* @returns A string representation of the number with a leading 0 if it's less than 10.
|
||||
*/
|
||||
function section(num: number) {
|
||||
if (num < 10) return `0${num}`;
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Converts a string to a URL object. If the string is not a valid URL, it will be appended to the Svelte website URL.
|
||||
* @param href - The string to convert to a URL object.
|
||||
* @returns A URL object.
|
||||
*/
|
||||
const string_to_url = (href: string) => {
|
||||
try {
|
||||
return new URL(href);
|
||||
} catch (e) {
|
||||
return new URL(href, 'https://svelte.dev');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a URL string and returns an object containing its pathname, search, hash, and searchParams.
|
||||
* @param href - The URL string to parse.
|
||||
* @returns An object containing the pathname, search, hash, and searchParams of the URL.
|
||||
*/
|
||||
export const use_url = (href: string) => {
|
||||
const { pathname, search, hash, searchParams } = string_to_url(href);
|
||||
|
||||
return { pathname, search, hash, searchParams } as const;
|
||||
};
|
||||
Reference in New Issue
Block a user