From d18b0c78d05a9794b0d41b0e23825644f4c2d6cb Mon Sep 17 00:00:00 2001 From: Ollie Taylor <13766232+OllieJT@users.noreply.github.com> Date: Thu, 13 Jul 2023 00:27:14 +0100 Subject: [PATCH] Simplify setup (#46) --- .changeset/loud-grapes-brush.md | 5 + .changeset/wise-pugs-build.md | 5 + .vscode/settings.json | 10 +- src/app.postcss | 11 +- src/components/example-player/index.js | 1 - .../example-player/player-stack.svelte | 444 -------------- .../example-player/player-widget.svelte | 112 +++- .../example-player/utility/skip.svelte | 4 - .../example-player/utility/timestamp.svelte | 2 +- src/content/episodes.js | 6 + src/layout/helper.js | 18 + src/layout/metadata.svelte | 4 +- src/layout/page/component.svelte | 14 +- src/layout/page/container.svelte | 2 +- src/layout/page/table-module.svelte | 33 -- src/layout/page/table-schema.svelte | 6 +- src/layout/sidebar.svelte | 46 +- src/lib/audio/actions.js | 165 ++++++ src/lib/audio/audio-element-source.js | 15 + src/lib/audio/audio-element.js | 132 +++++ src/lib/audio/audio-metadata.js | 12 + src/lib/audio/context.svelte | 116 ---- src/lib/audio/index.js | 6 +- src/lib/audio/player.svelte | 52 +- src/lib/audio/progress.svelte | 31 +- src/lib/audio/stores/audio-element.js | 54 -- src/lib/audio/stores/episode-data.js | 225 ------- src/lib/audio/stores/episode-details.js | 12 - src/lib/audio/stores/episode-progress.js | 48 -- src/lib/audio/stores/index.js | 4 - src/lib/index.js | 9 +- src/lib/user/user-progress.js | 10 +- src/routes/+layout.svelte | 71 ++- src/routes/api/+page.svelte | 556 ++++++++---------- src/routes/api/code/audio-load.js | 17 + src/routes/api/code/audio-mute.js | 9 + src/routes/api/code/audio-pause.js | 9 + src/routes/api/code/audio-play.js | 9 + src/routes/api/code/audio-seek.js | 7 + src/routes/api/code/audio-skip.js | 7 + src/routes/api/code/audio-subscribe.svelte | 16 + src/routes/api/code/audio-unload.js | 4 + src/routes/api/code/audio-unmute.js | 9 + src/routes/api/code/index.js | 49 +- .../api/code/load-audio-after-click.svelte | 6 +- .../api/code/load-audio-after-mount.svelte | 4 +- src/routes/api/code/load-audio-local.js | 9 - src/routes/api/code/load-audio-remote.js | 9 - src/routes/api/code/seconds-to-timestamp.js | 2 +- src/routes/api/code/user_preferences-clear.js | 4 + .../user_preferences-set_playback_rate.js | 4 + .../api/code/user_preferences-set_volume.js | 4 + .../code/user_preferences-subscribe.svelte | 16 + src/routes/api/code/user_progress-clear.js | 4 + src/routes/api/code/user_progress-get.js | 4 + src/routes/api/code/user_progress-save.js | 4 + .../api/code/user_progress-subscribe.svelte | 16 + src/routes/examples/+page.svelte | 61 +- src/routes/setup/+page.svelte | 101 +++- src/routes/setup/code/add-audio-loader.svelte | 3 - src/routes/setup/code/index.js | 8 + src/routes/setup/code/load-audio-advanced.js | 14 + src/routes/setup/code/load-audio-local.js | 3 + src/routes/setup/code/load-audio-remote.js | 3 + .../code/override-episode-state.ts | 4 +- static/svelte-radio.png | Bin 0 -> 8508 bytes static/syntax.png | Bin 0 -> 19038 bytes tsconfig.json | 46 +- 68 files changed, 1236 insertions(+), 1470 deletions(-) create mode 100644 .changeset/loud-grapes-brush.md create mode 100644 .changeset/wise-pugs-build.md delete mode 100644 src/components/example-player/player-stack.svelte delete mode 100644 src/layout/page/table-module.svelte create mode 100644 src/lib/audio/actions.js create mode 100644 src/lib/audio/audio-element-source.js create mode 100644 src/lib/audio/audio-element.js create mode 100644 src/lib/audio/audio-metadata.js delete mode 100644 src/lib/audio/context.svelte delete mode 100644 src/lib/audio/stores/audio-element.js delete mode 100644 src/lib/audio/stores/episode-data.js delete mode 100644 src/lib/audio/stores/episode-details.js delete mode 100644 src/lib/audio/stores/episode-progress.js delete mode 100644 src/lib/audio/stores/index.js create mode 100644 src/routes/api/code/audio-load.js create mode 100644 src/routes/api/code/audio-mute.js create mode 100644 src/routes/api/code/audio-pause.js create mode 100644 src/routes/api/code/audio-play.js create mode 100644 src/routes/api/code/audio-seek.js create mode 100644 src/routes/api/code/audio-skip.js create mode 100644 src/routes/api/code/audio-subscribe.svelte create mode 100644 src/routes/api/code/audio-unload.js create mode 100644 src/routes/api/code/audio-unmute.js delete mode 100644 src/routes/api/code/load-audio-local.js delete mode 100644 src/routes/api/code/load-audio-remote.js create mode 100644 src/routes/api/code/user_preferences-clear.js create mode 100644 src/routes/api/code/user_preferences-set_playback_rate.js create mode 100644 src/routes/api/code/user_preferences-set_volume.js create mode 100644 src/routes/api/code/user_preferences-subscribe.svelte create mode 100644 src/routes/api/code/user_progress-clear.js create mode 100644 src/routes/api/code/user_progress-get.js create mode 100644 src/routes/api/code/user_progress-save.js create mode 100644 src/routes/api/code/user_progress-subscribe.svelte create mode 100644 src/routes/setup/code/load-audio-advanced.js create mode 100644 src/routes/setup/code/load-audio-local.js create mode 100644 src/routes/setup/code/load-audio-remote.js rename src/routes/{api => setup}/code/override-episode-state.ts (71%) create mode 100644 static/svelte-radio.png create mode 100644 static/syntax.png diff --git a/.changeset/loud-grapes-brush.md b/.changeset/loud-grapes-brush.md new file mode 100644 index 0000000..ca115f9 --- /dev/null +++ b/.changeset/loud-grapes-brush.md @@ -0,0 +1,5 @@ +--- +'svelte-podcast': minor +--- + +refactor audio element logic diff --git a/.changeset/wise-pugs-build.md b/.changeset/wise-pugs-build.md new file mode 100644 index 0000000..1500aa7 --- /dev/null +++ b/.changeset/wise-pugs-build.md @@ -0,0 +1,5 @@ +--- +'svelte-podcast': minor +--- + +rewrite docs with code samples diff --git a/.vscode/settings.json b/.vscode/settings.json index e6dca7c..da25a27 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,11 @@ "source.organizeImports": true } }, + "[javascript]": { + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + }, "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], "typescript.tsdk": "node_modules/typescript/lib", "editor.defaultFormatter": "esbenp.prettier-vscode", @@ -33,5 +38,8 @@ "**/node_modules": true, "**/.svelte-kit": true }, - "problems.sortOrder": "severity" + "problems.sortOrder": "severity", + "javascript.preferences.importModuleSpecifier": "relative", + "typescript.preferences.importModuleSpecifier": "relative", + "todo-tree.tree.scanMode": "workspace only" } diff --git a/src/app.postcss b/src/app.postcss index ae71f23..e311cc3 100644 --- a/src/app.postcss +++ b/src/app.postcss @@ -22,7 +22,7 @@ .richtext h4 a, .richtext h5 a, .richtext h6 a { - @apply relative underline decoration-transparent decoration-solid underline-offset-2; + @apply relative break-all underline decoration-transparent decoration-solid underline-offset-2; &::before { @apply absolute -left-[1.25ch] inline-block font-light leading-normal text-mono-400; @@ -37,16 +37,19 @@ } .richtext code.hljs { - @apply p-0 text-sm; + @apply overflow-x-auto p-0 text-sm; overflow-wrap: anywhere; } .richtext .codeblock code.hljs { - @apply overflow-hidden rounded-xl border border-mono-200 p-4 font-mono text-base leading-normal shadow-2xl shadow-mono-200; + @apply overflow-auto rounded-xl border border-mono-200 p-4 font-mono text-base leading-normal shadow-2xl shadow-mono-200; } .richtext { - @apply prose prose-lg prose-slate max-w-none prose-h5:mb-1 prose-h5:mt-10 prose-h5:text-sm prose-h5:font-medium prose-h5:leading-normal prose-h5:text-primary-800 prose-code:before:content-none prose-code:after:content-none; + @apply prose prose-slate max-w-none sm:prose-lg prose-headings:px-2 prose-h5:mb-1 prose-h5:mt-10 prose-h5:text-sm prose-h5:font-medium prose-h5:leading-normal prose-h5:text-primary-800 prose-p:px-2 prose-code:before:content-none prose-code:after:content-none; + } + .richtext code:not(.hljs) { + @apply inline whitespace-pre-wrap break-all rounded-md border border-mono-200 bg-white px-1.5 py-0.5 font-mono font-normal tracking-wide text-mono-950; } .richtext td, diff --git a/src/components/example-player/index.js b/src/components/example-player/index.js index 5d2f019..ca912fc 100644 --- a/src/components/example-player/index.js +++ b/src/components/example-player/index.js @@ -1,2 +1 @@ -export { default as PlayerStack } from './player-stack.svelte'; export { default as PlayerWidget } from './player-widget.svelte'; diff --git a/src/components/example-player/player-stack.svelte b/src/components/example-player/player-stack.svelte deleted file mode 100644 index d63a491..0000000 --- a/src/components/example-player/player-stack.svelte +++ /dev/null @@ -1,444 +0,0 @@ - - - -
-
- - - - {#if episode.is_loaded} - - {:else} -
- -
- {/if} - - -
- -
- - -
- -
-
-
- -
- -
- - {#if !hide_timestamps || !hide_playback_rate} -
- {#if !hide_timestamps} -
- -
- -
- -
- -
- {/if} - - {#if !hide_playback_rate} - - {/if} -
- {/if} -
- - - diff --git a/src/components/example-player/player-widget.svelte b/src/components/example-player/player-widget.svelte index c5c78ed..b544e2b 100644 --- a/src/components/example-player/player-widget.svelte +++ b/src/components/example-player/player-widget.svelte @@ -6,7 +6,7 @@ /** @type {string | undefined} */ export let src; - /** @type {import('svelte-podcast/audio/stores').EpisodeDetails} */ + /** @type {import('svelte-podcast/audio').AudioMetadata} */ export let metadata = {}; export let hide_duration = false; @@ -21,11 +21,105 @@ export let playback_rate_values = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4]; - + + + + +
{#if !hide_skip_back} {:else} @@ -68,8 +162,8 @@ {#if !hide_current_time}
= 3600} />
{/if} @@ -80,7 +174,7 @@ {#if !hide_duration}
- +
{/if} diff --git a/src/components/example-player/utility/skip.svelte b/src/components/example-player/utility/skip.svelte index 5b503d0..b7607f4 100644 --- a/src/components/example-player/utility/skip.svelte +++ b/src/components/example-player/utility/skip.svelte @@ -153,8 +153,4 @@ transform: rotate(360deg); } } - - svg { - /* animation: Spin 1s cubic-bezier(0.5, 0.1, 0.1, 0.8) infinite; */ - } diff --git a/src/components/example-player/utility/timestamp.svelte b/src/components/example-player/utility/timestamp.svelte index f8fbcaa..afd72f9 100644 --- a/src/components/example-player/utility/timestamp.svelte +++ b/src/components/example-player/utility/timestamp.svelte @@ -1,5 +1,5 @@ -
+

{title}

- + diff --git a/src/layout/page/container.svelte b/src/layout/page/container.svelte index 4ace9a6..aeac839 100644 --- a/src/layout/page/container.svelte +++ b/src/layout/page/container.svelte @@ -6,7 +6,7 @@ diff --git a/src/layout/page/table-module.svelte b/src/layout/page/table-module.svelte deleted file mode 100644 index bb06238..0000000 --- a/src/layout/page/table-module.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -
- - - - - - - {#each rows as { method, description }} - - - - - {/each} - -
MethodDescription
- - {description}
-
diff --git a/src/layout/page/table-schema.svelte b/src/layout/page/table-schema.svelte index 66e95dc..a5315fb 100644 --- a/src/layout/page/table-schema.svelte +++ b/src/layout/page/table-schema.svelte @@ -14,9 +14,9 @@ export let rows; -
- - +
+
+ diff --git a/src/layout/sidebar.svelte b/src/layout/sidebar.svelte index ff66268..d29024b 100644 --- a/src/layout/sidebar.svelte +++ b/src/layout/sidebar.svelte @@ -36,6 +36,16 @@ /** @type {Readonly} */ export let resources; + /** + * @typedef {Object} PodcastLink + * @property {string} label - The label of the page link. + * @property {string} href - The URL of the page link. + * @property {string} src - The URL of the page link. + */ + + /** @type {Readonly} */ + export let podcasts; + $: use_is_current = (/** @type {string } */ href) => { let href_pathname = href; @@ -57,11 +67,6 @@ ) => { if (!is_current_page) return false; - if (typeof document !== 'undefined') { - const el = document.querySelector(`[id="${anchor}"]`); - if (!el) throw new Error(`No element found with id "${anchor}"`); - } - return $page.url.hash.includes(anchor); }; @@ -174,6 +179,37 @@ {/each} +
  • +
    + Svelte Podcasts +
    +
      + {#each podcasts as link} + {@const is_current = use_is_current(link.href)} +
    • + + + {link.label} + +
    • + {/each} +
    +
  • diff --git a/src/lib/audio/actions.js b/src/lib/audio/actions.js new file mode 100644 index 0000000..3f21b18 --- /dev/null +++ b/src/lib/audio/actions.js @@ -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, +}; diff --git a/src/lib/audio/audio-element-source.js b/src/lib/audio/audio-element-source.js new file mode 100644 index 0000000..704eb71 --- /dev/null +++ b/src/lib/audio/audio-element-source.js @@ -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} */ +export const audio_element_source = writable(null); diff --git a/src/lib/audio/audio-element.js b/src/lib/audio/audio-element.js new file mode 100644 index 0000000..660a666 --- /dev/null +++ b/src/lib/audio/audio-element.js @@ -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} 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} */ +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), + }); +}); diff --git a/src/lib/audio/audio-metadata.js b/src/lib/audio/audio-metadata.js new file mode 100644 index 0000000..3cf3d13 --- /dev/null +++ b/src/lib/audio/audio-metadata.js @@ -0,0 +1,12 @@ +import { writable } from 'svelte/store'; + +/** + * @typedef {Object.} AudioMetadata + */ + +/** + * Audio metadata store. + * @type {import('svelte/store').Writable} + * @description This store holds the metadata for a single audio source. + */ +export const audio_metadata = writable(null); diff --git a/src/lib/audio/context.svelte b/src/lib/audio/context.svelte deleted file mode 100644 index d0f902d..0000000 --- a/src/lib/audio/context.svelte +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - diff --git a/src/lib/audio/index.js b/src/lib/audio/index.js index d6e4354..ec1bb42 100644 --- a/src/lib/audio/index.js +++ b/src/lib/audio/index.js @@ -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'; diff --git a/src/lib/audio/player.svelte b/src/lib/audio/player.svelte index 881928c..755b4db 100644 --- a/src/lib/audio/player.svelte +++ b/src/lib/audio/player.svelte @@ -1,71 +1,48 @@ diff --git a/src/lib/audio/progress.svelte b/src/lib/audio/progress.svelte index 38eaa73..eccec57 100644 --- a/src/lib/audio/progress.svelte +++ b/src/lib/audio/progress.svelte @@ -1,6 +1,7 @@ -{#if $episode_audio && $episode_progress} +{#if $audio_attributes.is_loaded} 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')} diff --git a/src/lib/audio/stores/audio-element.js b/src/lib/audio/stores/audio-element.js deleted file mode 100644 index 64d8d22..0000000 --- a/src/lib/audio/stores/audio-element.js +++ /dev/null @@ -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} - * @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; - }; -}); diff --git a/src/lib/audio/stores/episode-data.js b/src/lib/audio/stores/episode-data.js deleted file mode 100644 index 42036a1..0000000 --- a/src/lib/audio/stores/episode-data.js +++ /dev/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} */ -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} - */ -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, -}; diff --git a/src/lib/audio/stores/episode-details.js b/src/lib/audio/stores/episode-details.js deleted file mode 100644 index 677bfe5..0000000 --- a/src/lib/audio/stores/episode-details.js +++ /dev/null @@ -1,12 +0,0 @@ -import { writable } from 'svelte/store'; - -/** - * @typedef {Object.} EpisodeDetails - */ - -/** - * Episode details store. - * @type {import('svelte/store').Writable} - * @description This store holds the metadata for a single episode. - */ -export const episode_details = writable(null); diff --git a/src/lib/audio/stores/episode-progress.js b/src/lib/audio/stores/episode-progress.js deleted file mode 100644 index 61a1ed7..0000000 --- a/src/lib/audio/stores/episode-progress.js +++ /dev/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} - */ -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()); -}); diff --git a/src/lib/audio/stores/index.js b/src/lib/audio/stores/index.js deleted file mode 100644 index cb0148e..0000000 --- a/src/lib/audio/stores/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export * from './audio-element'; -export * from './episode-data'; -export * from './episode-details'; -export * from './episode-progress'; diff --git a/src/lib/index.js b/src/lib/index.js index 05b5d68..13dd3f3 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -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; diff --git a/src/lib/user/user-progress.js b/src/lib/user/user-progress.js index 13ce01b..8aefee8 100644 --- a/src/lib/user/user-progress.js +++ b/src/lib/user/user-progress.js @@ -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} @@ -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, diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ae6838f..6a4deb9 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,7 +3,6 @@ import GitHubIcon from '@inqling/svelte-icons/simple-icons/github.svelte'; import NPMIcon from '@inqling/svelte-icons/simple-icons/npm.svelte'; import github from 'svelte-highlight/styles/github'; - import { AudioContext } from 'svelte-podcast'; import { circIn, circOut } from 'svelte/easing'; import { fade } from 'svelte/transition'; import '../app.postcss'; @@ -18,21 +17,54 @@ label: 'Setup', href: '/setup', sections: [ - { label: 'installation', anchor: 'installation' }, - { label: 'Loader', anchor: 'add_loader' }, + { label: 'Installation', anchor: 'installation' }, + { label: 'Audio Sources', anchor: 'audio_sources' }, + { label: 'Type Safety', anchor: 'type_safety' }, ], }, { label: 'API', href: '/api', sections: [ - { label: 'Episode Audio', anchor: 'episode_audio' }, - { label: 'Load Audio Source', anchor: 'load_audio_source' }, - { label: 'Episode Details', anchor: 'episode_details' }, - { label: 'Episode Progress', anchor: 'episode_progress' }, - { label: 'Seconds To Timestamp', anchor: 'seconds_to_timestamp' }, - { label: 'User Preferences', anchor: 'user_preferences' }, - { label: 'User Progress', anchor: 'user_progress' }, + { label: 'audio', anchor: 'audio' }, + { label: 'audio_data', anchor: 'audio_data' }, + { label: 'audio.subscribe', anchor: 'audio.subscribe' }, + { label: 'audio.load', anchor: 'audio.load' }, + { label: 'audio.unload', anchor: 'audio.unload' }, + { label: 'audio.play', anchor: 'audio.play' }, + { label: 'audio.pause', anchor: 'audio.pause' }, + { label: 'audio.mute', anchor: 'audio.mute' }, + { label: 'audio.unmute', anchor: 'audio.unmute' }, + { label: 'audio.seek', anchor: 'audio.seek' }, + { label: 'audio.skip', anchor: 'audio.skip' }, + { label: 'user_preferences', anchor: 'user_preferences' }, + { label: 'user_preferences_data', anchor: 'user_preferences_data' }, + { + label: 'user_preferences.subscribe', + anchor: 'user_preferences.subscribe', + }, + { + label: 'user_preferences.set_playback_rate', + anchor: 'user_preferences.set_playback_rate', + }, + { + label: 'user_preferences.set_volume', + anchor: 'user_preferences.set_volume', + }, + { + label: 'user_preferences.clear', + anchor: 'user_preferences.clear', + }, + { label: 'user_progress', anchor: 'user_progress' }, + { label: 'user_progress_data', anchor: 'user_progress_data' }, + { + label: 'user_progress.subscribe', + anchor: 'user_progress.subscribe', + }, + { label: 'user_progress.get', anchor: 'user_progress.get' }, + { label: 'user_progress.clear', anchor: 'user_progress.clear' }, + { label: 'user_progress.save', anchor: 'user_progress.save' }, + { label: 'seconds_to_timestamp', anchor: 'seconds_to_timestamp' }, ], }, { @@ -55,6 +87,19 @@ }, ]); + const podcast_links = Object.freeze([ + { + label: 'Svelte Radio', + href: 'https://www.svelteradio.com/', + src: '/svelte-radio.png', + }, + { + label: 'Syntax', + href: 'https://syntax.fm/', + src: '/syntax.png', + }, + ]); + let is_menu_open = false; const open_menu = () => (is_menu_open = true); @@ -65,8 +110,6 @@ {@html github} - -
    {/if} @@ -134,6 +178,7 @@ class="border-r border-mono-200" pages={page_links} resources={resource_links} + podcasts={podcast_links} /> @@ -179,7 +224,7 @@
    -
    +
    diff --git a/src/routes/api/+page.svelte b/src/routes/api/+page.svelte index 040a497..4891973 100644 --- a/src/routes/api/+page.svelte +++ b/src/routes/api/+page.svelte @@ -1,242 +1,289 @@ - -
    - -

    Methods

    + +
    + + + - - - -

    EpisodeState

    - - - - + +

    - All you need to load an episode is a URL to an audio file. - svelte-podcast uses a html audio element under the hood, so any - audio file compatible with the autio element is also compatible with - this package. + Subscribe to specific changes in the audio attributes. This will + return a readable store that you can use to access the attributes.

    - -

    Using a remote file (URL)

    - -

    - An audio url could be a URL to an MP3 file from an RSS - feed, like this: - https://media.transistor.fm/27a058c9/27b595e2.mp3. It - could also be a path to a static file on your server. -

    - -
    - +
    +
    + -

    Using a local file (relative path)

    - +

    - If you're using SvelteKit, you can store static files in - the /static directory. When your site is built, everything in the - static directory will be at the root of your site. If you have a - file in /static/episides/episode-01.mp3 you could load - it as /episides/episode-01.mp3 + Load a new audio source. This will stop the current audio source and + replace it with the new one.

    +
    + +
    +
    -
    - + +

    + Unload the current audio source. This will stop the current audio + source and remove it from the audio state. +

    +
    + +
    +
    + + +

    Set or toggle the play state.

    +
    + +
    +
    + + +

    Set or toggle the pause state.

    +
    + +
    +
    + + +

    Set or toggle the mute state.

    +
    + +
    +
    + + +

    Set or toggle the unmute state.

    +
    + +
    +
    + + +

    + Seek to a specific time in the audio. The `from` parameter + determines whether the time is relative to the start or end of the + audio. +

    +
    + +
    +
    + + +

    + Skip forward or backward in the audio by a specific number of + seconds. +

    +
    +
    -
    -

    Methods

    +
    + + + - EpisodeDetails\n)', - description: - 'You can update the audio state. This is useful if you want to update the state based on the previous state.', - }, - ]} - /> + + +

    + Subscribe to changes in the user preferences. This will return a + readable store that you can use to access the user preferences. +

    +
    + +
    +
    - -

    EpisodeState

    -
    - `} language={lang_ts} /> -
    -

    - Episode state holds the metadata for the current episode. You derermine - the shape of the this data. -

    + +

    + Set the playback rate. This will update the user preferences and + apply the new playback rate to the audio. +

    +
    + +
    +
    -

    - For complete type safety, we recommend defining this type yourself in a - .d.ts file. In SvelteKit, you should do this in - /src/app.d.ts - . -

    + +

    + Set the volume. This will update the user preferences and apply the + new volume to the audio. +

    +
    + +
    +
    -

    Override EpisodeState

    -
    - -
    + +

    + Clear the user preferences. This will reset the user preferences to + the default values. +

    +
    + +
    +
    - -
    - -

    Methods

    - EpisodeProgress\n)', - description: - 'You can update the audio state. This is useful if you want to update the state based on the previous state.', - }, - ]} - /> + +
    + +

    + User progress is a record where the keys are audio sources, and the + values are timestamps in seconds. Progress is retrieved by matching + the source, and returning the timestamp. +

    - -

    EpisodeProgress

    - +
    + `} language={lang_ts} /> +
    +
    + + + +

    + Subscribe to changes in the user progress. This will return a + readable store that you can use to access the user progress. +

    +
    + +
    +
    + + +

    + Get the user progress for a given audio source. This will return the + timestamp in seconds. +

    +
    + +
    +
    + + +

    + Clear the user progress. This will reset the user progress to the + default values. +

    +
    + +
    +
    + + +

    + Save the user progress for a given audio source. This will update + the user progress with the new timestamp. +

    +
    + +
    +
    @@ -261,89 +308,4 @@
    - - -
    - -

    Methods

    - - - -

    UserPreferences

    - -
    - - -
    - -

    Methods

    - - - -

    UserProgress

    -

    - User progress is a record where the keys are audio sources, and the - values are timestamps in seconds. Progress is retrieved by matching the - source, and returning the timestamp. -

    - -
    - `} language={lang_ts} /> -
    -
    diff --git a/src/routes/api/code/audio-load.js b/src/routes/api/code/audio-load.js new file mode 100644 index 0000000..d48cd37 --- /dev/null +++ b/src/routes/api/code/audio-load.js @@ -0,0 +1,17 @@ +import { audio } from 'svelte-podcast'; + +// load a new audio source +audio.load( + // path to audio file + '/episode-377.mp3', + + // custom metadata + { + title: 'Supper Club × Rich Harris, Author of Svelte', + guests: ['Rich Harris'], + hosts: ['Wes Bos', 'Scott Tolinski'], + }, + + // autoplay + true, +); diff --git a/src/routes/api/code/audio-mute.js b/src/routes/api/code/audio-mute.js new file mode 100644 index 0000000..789fae9 --- /dev/null +++ b/src/routes/api/code/audio-mute.js @@ -0,0 +1,9 @@ +import { audio } from 'svelte-podcast'; + +// mute the current audio source +// do nothing if it's already muted +audio.mute('set'); + +// mute the current audio if it's unmuted +// unmute the current audio if it's muted +audio.mute('toggle'); diff --git a/src/routes/api/code/audio-pause.js b/src/routes/api/code/audio-pause.js new file mode 100644 index 0000000..bfa3dd8 --- /dev/null +++ b/src/routes/api/code/audio-pause.js @@ -0,0 +1,9 @@ +import { audio } from 'svelte-podcast'; + +// pause the current audio source +// do nothing if it's already paused +audio.pause('set'); + +// pause the current audio if it's playing +// play the current audio if it's paused +audio.pause('toggle'); diff --git a/src/routes/api/code/audio-play.js b/src/routes/api/code/audio-play.js new file mode 100644 index 0000000..0fd33c2 --- /dev/null +++ b/src/routes/api/code/audio-play.js @@ -0,0 +1,9 @@ +import { audio } from 'svelte-podcast'; + +// play the current audio source +// do nothing if it's already playing +audio.play('set'); + +// play the current audio if it's paused +// pause the current audio if it's playing +audio.play('toggle'); diff --git a/src/routes/api/code/audio-seek.js b/src/routes/api/code/audio-seek.js new file mode 100644 index 0000000..17a2e2a --- /dev/null +++ b/src/routes/api/code/audio-seek.js @@ -0,0 +1,7 @@ +import { audio } from 'svelte-podcast'; + +// go to 200 seconds from the start +audio.seek(200); + +// go to 200 seconds before the end +audio.seek(200, 'from-end'); diff --git a/src/routes/api/code/audio-skip.js b/src/routes/api/code/audio-skip.js new file mode 100644 index 0000000..0b89e7e --- /dev/null +++ b/src/routes/api/code/audio-skip.js @@ -0,0 +1,7 @@ +import { audio } from 'svelte-podcast'; + +// skip forward 30 seconds from the current position +audio.skip(30); + +// skip backward 30 seconds from the current position +audio.skip(30, 'backward'); diff --git a/src/routes/api/code/audio-subscribe.svelte b/src/routes/api/code/audio-subscribe.svelte new file mode 100644 index 0000000..36a9d2f --- /dev/null +++ b/src/routes/api/code/audio-subscribe.svelte @@ -0,0 +1,16 @@ + + +

    Current timestamp {attributes.timestamp_current}.

    + +

    {attributes.is_paused ? 'Paused...' : 'Playing!'}.

    diff --git a/src/routes/api/code/audio-unload.js b/src/routes/api/code/audio-unload.js new file mode 100644 index 0000000..af801f9 --- /dev/null +++ b/src/routes/api/code/audio-unload.js @@ -0,0 +1,4 @@ +import { audio } from 'svelte-podcast'; + +// unload the current audio source +audio.unload(); diff --git a/src/routes/api/code/audio-unmute.js b/src/routes/api/code/audio-unmute.js new file mode 100644 index 0000000..51e1789 --- /dev/null +++ b/src/routes/api/code/audio-unmute.js @@ -0,0 +1,9 @@ +import { audio } from 'svelte-podcast'; + +// unmute the current audio source +// do nothing if it's already unmuted +audio.unmute('set'); + +// unmute the current audio if it's muted +// mute the current audio if it's unmuted +audio.unmute('toggle'); diff --git a/src/routes/api/code/index.js b/src/routes/api/code/index.js index 009ae3b..716e32a 100644 --- a/src/routes/api/code/index.js +++ b/src/routes/api/code/index.js @@ -1,15 +1,48 @@ +import { format_code } from '../../../layout/helper'; +import { default as audio_load_src } from './audio-load.js?raw'; +import { default as audio_mute_src } from './audio-mute.js?raw'; +import { default as audio_pause_src } from './audio-pause.js?raw'; +import { default as audio_play_src } from './audio-play.js?raw'; +import { default as audio_seek_src } from './audio-seek.js?raw'; +import { default as audio_skip_src } from './audio-skip.js?raw'; +import { default as audio_subscribe_src } from './audio-subscribe.svelte?raw'; +import { default as audio_unload_src } from './audio-unload.js?raw'; +import { default as audio_unmute_src } from './audio-unmute.js?raw'; import { default as load_audio_after_click_src } from './load-audio-after-click.svelte?raw'; import { default as load_audio_after_mount_src } from './load-audio-after-mount.svelte?raw'; -import { default as load_audio_local_src } from './load-audio-local?raw'; -import { default as load_audio_remote_src } from './load-audio-remote?raw'; -import { default as override_episode_state_src } from './override-episode-state?raw'; import { default as seconds_to_timestamp_src } from './seconds-to-timestamp?raw'; - -const format_code = (/** @type {string} */ code) => code.replaceAll('\t', ' '); +import { default as user_preferences_clear_src } from './user_preferences-clear?raw'; +import { default as user_preferences_set_playback_rate_src } from './user_preferences-set_playback_rate?raw'; +import { default as user_preferences_set_volume_src } from './user_preferences-set_volume?raw'; +import { default as user_preferences_subscribe_src } from './user_preferences-subscribe.svelte?raw'; +import { default as user_progress_clear_src } from './user_progress-clear?raw'; +import { default as user_progress_get_src } from './user_progress-get?raw'; +import { default as user_progress_save_src } from './user_progress-save?raw'; +import { default as user_progress_subscribe_src } from './user_progress-subscribe.svelte?raw'; export const load_audio_after_click = format_code(load_audio_after_click_src); export const load_audio_after_mount = format_code(load_audio_after_mount_src); -export const load_audio_local = format_code(load_audio_local_src); -export const load_audio_remote = format_code(load_audio_remote_src); -export const override_episode_state = format_code(override_episode_state_src); export const seconds_to_timestamp = format_code(seconds_to_timestamp_src); +export const audio_load = format_code(audio_load_src); +export const audio_play = format_code(audio_play_src); +export const audio_unload = format_code(audio_unload_src); +export const audio_pause = format_code(audio_pause_src); +export const audio_skip = format_code(audio_skip_src); +export const audio_seek = format_code(audio_seek_src); +export const audio_mute = format_code(audio_mute_src); +export const audio_unmute = format_code(audio_unmute_src); +export const user_preferences_clear = format_code(user_preferences_clear_src); +export const user_preferences_set_playback_rate = format_code( + user_preferences_set_playback_rate_src, +); +export const user_preferences_set_volume = format_code( + user_preferences_set_volume_src, +); +export const user_preferences_subscribe = format_code( + user_preferences_subscribe_src, +); +export const audio_subscribe = format_code(audio_subscribe_src); +export const user_progress_clear = format_code(user_progress_clear_src); +export const user_progress_get = format_code(user_progress_get_src); +export const user_progress_save = format_code(user_progress_save_src); +export const user_progress_subscribe = format_code(user_progress_subscribe_src); diff --git a/src/routes/api/code/load-audio-after-click.svelte b/src/routes/api/code/load-audio-after-click.svelte index f20855e..1fa0fde 100644 --- a/src/routes/api/code/load-audio-after-click.svelte +++ b/src/routes/api/code/load-audio-after-click.svelte @@ -1,11 +1,11 @@ - + diff --git a/src/routes/api/code/load-audio-after-mount.svelte b/src/routes/api/code/load-audio-after-mount.svelte index 63cfadb..28230f3 100644 --- a/src/routes/api/code/load-audio-after-mount.svelte +++ b/src/routes/api/code/load-audio-after-mount.svelte @@ -1,10 +1,10 @@ + +

    Listening at {preferences.playback_rate * 100}% speed.

    + +

    Volume set to {preferences.volume * 100}%.

    diff --git a/src/routes/api/code/user_progress-clear.js b/src/routes/api/code/user_progress-clear.js new file mode 100644 index 0000000..b4180d8 --- /dev/null +++ b/src/routes/api/code/user_progress-clear.js @@ -0,0 +1,4 @@ +import { user_progress } from 'svelte-podcast'; + +// Clear the user's progress for ALL episodes. +user_progress.clear(); diff --git a/src/routes/api/code/user_progress-get.js b/src/routes/api/code/user_progress-get.js new file mode 100644 index 0000000..93990fa --- /dev/null +++ b/src/routes/api/code/user_progress-get.js @@ -0,0 +1,4 @@ +import { user_progress } from 'svelte-podcast'; + +// Get the user's progress for a specific episode. +user_progress.get('https://example.com/episodes/1'); diff --git a/src/routes/api/code/user_progress-save.js b/src/routes/api/code/user_progress-save.js new file mode 100644 index 0000000..bf34982 --- /dev/null +++ b/src/routes/api/code/user_progress-save.js @@ -0,0 +1,4 @@ +import { user_progress } from 'svelte-podcast'; + +// Save the user's progress for the current episode. +user_progress.save(); diff --git a/src/routes/api/code/user_progress-subscribe.svelte b/src/routes/api/code/user_progress-subscribe.svelte new file mode 100644 index 0000000..f5ce5fa --- /dev/null +++ b/src/routes/api/code/user_progress-subscribe.svelte @@ -0,0 +1,16 @@ + + +

    + Listened to {progress['https://example.com/episodes/1']} seconds of episode 1. +

    diff --git a/src/routes/examples/+page.svelte b/src/routes/examples/+page.svelte index 4412096..714dc05 100644 --- a/src/routes/examples/+page.svelte +++ b/src/routes/examples/+page.svelte @@ -1,12 +1,8 @@ - + +

    Work in progress!

    @@ -82,45 +66,4 @@
    - -
    - - -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte index d7b4245..af58fe4 100644 --- a/src/routes/setup/+page.svelte +++ b/src/routes/setup/+page.svelte @@ -1,9 +1,15 @@ @@ -17,37 +23,82 @@
    -
    -

    - Important: The AudioLoader component does - not - render any UI. However it is required to use svelte-podcast. + +

    +

    + To get started, you'll need an audio file. velte-podcast uses a html {' element under the hood, so any file compatible with the audio element + will work.

    - This component is responsible for loading the audio sources and - persisting the audio state between page loads. This means that your - audio will continue playing even if the user refreshes the page, or you - use different UI for the media player. + You can provide your audio files via absolute URL's - hosted online, or + relative paths - hosted within your project.

    +

    Using a remote file (URL)

    +

    - Add the AudioLoader component to your app. It should be as close to the - root of your app as possible to ensure the audio state behaves as - expected in nested routes. + An audio url could be a URL to an MP3 file from an RSS feed, + like this: + https://media.transistor.fm/27a058c9/27b595e2.mp3. It + could also be a path to a static file on your server.

    -
      -
    • You must have one of these for svelte-podcast to work.
    • -
    • You should also only load one instance of this at a time
    • -
    • - We recommend you loading it at the base of your app in your - layout.svelte file. -
    • -
    -
    - + +
    + +

    Using a local file (relative path)

    + +

    + If you're using SvelteKit, you can store static files in the + /static directory. When your site is built, everything in the static + directory will be at the root of your site. If you have a file in + /static/episides/episode-01.mp3 + you could load it as /episides/episode-01.mp3 +

    + +
    + +
    + +

    Additional options

    + +

    + Along side your audio source, you can optionally define arbitrary + metadata for you to use in your player, and determine if the episode + should autoplay once loaded. Learn more in the api docs +

    + +
    +
    + +
    +

    + Svelte podast makes it easy for you to associate arbitrary metadata + with each audio source. We recommend defining the shape of your + metadata in a .d.ts file. +

    + +

    + In this example, we'll assume that your metadata consists of an episode + title, episode artwork, and an optional episode guest name. +

    + +
    + +
    + +

    + If you're using SvelteKit, we recommend keeping this in src/app.d.ts. +

    +
    diff --git a/src/routes/setup/code/add-audio-loader.svelte b/src/routes/setup/code/add-audio-loader.svelte index cdba189..9cc1fe5 100644 --- a/src/routes/setup/code/add-audio-loader.svelte +++ b/src/routes/setup/code/add-audio-loader.svelte @@ -1,7 +1,4 @@ - - diff --git a/src/routes/setup/code/index.js b/src/routes/setup/code/index.js index bcdc2a2..571484e 100644 --- a/src/routes/setup/code/index.js +++ b/src/routes/setup/code/index.js @@ -1,6 +1,14 @@ import { format_code } from '$src/layout/helper'; import { default as add_audio_loader_src } from './add-audio-loader.svelte?raw'; import { default as install_src } from './install.sh?raw'; +import { default as load_audio_advanced_src } from './load-audio-advanced?raw'; +import { default as load_audio_local_src } from './load-audio-local?raw'; +import { default as load_audio_remote_src } from './load-audio-remote?raw'; +import { default as override_episode_state_src } from './override-episode-state?raw'; export const add_audio_loader = format_code(add_audio_loader_src); export const install = format_code(install_src); +export const override_episode_state = format_code(override_episode_state_src); +export const load_audio_local = format_code(load_audio_local_src); +export const load_audio_remote = format_code(load_audio_remote_src); +export const load_audio_advanced = format_code(load_audio_advanced_src); diff --git a/src/routes/setup/code/load-audio-advanced.js b/src/routes/setup/code/load-audio-advanced.js new file mode 100644 index 0000000..f3ffd95 --- /dev/null +++ b/src/routes/setup/code/load-audio-advanced.js @@ -0,0 +1,14 @@ +import { audio } from 'svelte-podcast'; + +audio.load('/episode-audio.mp3'); + +audio.load( + 'https://media.transistor.fm/27a058c9/27b595e2.mp3', + { + title: 'SvelteKit-superforms with Andreas Söderlund', + artwork: + 'https://images.transistor.fm/file/transistor/images/show/12899/medium_1597678946-artwork.jpg', + guest_name: 'Andreas Söderlund', + }, + false, +); diff --git a/src/routes/setup/code/load-audio-local.js b/src/routes/setup/code/load-audio-local.js new file mode 100644 index 0000000..3a15bae --- /dev/null +++ b/src/routes/setup/code/load-audio-local.js @@ -0,0 +1,3 @@ +import { audio } from 'svelte-podcast'; + +audio.load('/episode-audio.mp3'); diff --git a/src/routes/setup/code/load-audio-remote.js b/src/routes/setup/code/load-audio-remote.js new file mode 100644 index 0000000..1de7c79 --- /dev/null +++ b/src/routes/setup/code/load-audio-remote.js @@ -0,0 +1,3 @@ +import { audio } from 'svelte-podcast'; + +audio.load('https://media.transistor.fm/27a058c9/27b595e2.mp3'); diff --git a/src/routes/api/code/override-episode-state.ts b/src/routes/setup/code/override-episode-state.ts similarity index 71% rename from src/routes/api/code/override-episode-state.ts rename to src/routes/setup/code/override-episode-state.ts index e61e7cf..0da531e 100644 --- a/src/routes/api/code/override-episode-state.ts +++ b/src/routes/setup/code/override-episode-state.ts @@ -2,9 +2,9 @@ declare module 'svelte-podcast' { interface EpisodeDetails { - // define your own properties here title: string; - artwork?: string; + artwork: string; + guest_name: string; } } diff --git a/static/svelte-radio.png b/static/svelte-radio.png new file mode 100644 index 0000000000000000000000000000000000000000..7bbd4dca6287770af6dc0a6f72692674eb11dc8c GIT binary patch literal 8508 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 V`Vi7ux(xsT002ovPDHLkV1mM)OMw6Y literal 0 HcmV?d00001 diff --git a/tsconfig.json b/tsconfig.json index 1dc0a8c..bf98482 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,16 +3,48 @@ "compilerOptions": { "allowJs": true, "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, "sourceMap": true, - "strict": true, - // Additional Rules + // Enable top-level await, and other modern ESM features. + "target": "ESNext", + "module": "ESNext", + // Enable node-style module resolution, for things like npm package imports. + "moduleResolution": "node", + // Enable JSON imports. + "resolveJsonModule": true, + // Enable stricter transpilation for better output. + "isolatedModules": true, + // Astro directly run TypeScript code, no transpilation needed. + "noEmit": true, + // Report an error when importing a file using a casing different from the casing on disk. + "forceConsistentCasingInFileNames": true, + // Properly support importing CJS modules in ESM + "esModuleInterop": true, + // Skip typechecking libraries and .d.ts files + "skipLibCheck": true, + + "strict": true, + // Error when a value import is only used as a type. + "importsNotUsedAsValues": "error", + + // Report errors for fallthrough cases in switch statements + "noFallthroughCasesInSwitch": true, + // Force functions designed to override their parent class to be specified as `override`. + "noImplicitOverride": true, + // Force functions to specify that they can return `undefined` if a possible code path does not return a value. + "noImplicitReturns": true, + // Report an error when a variable is declared but never used. + "noUnusedLocals": true, + // Report an error when a parameter is declared but never used. + "noUnusedParameters": true, + // Force the usage of the indexed syntax to access fields declared using an index signature. "noUncheckedIndexedAccess": true, - "noEmit": true + // Report an error when the value `undefined` is given to an optional property that doesn't specify `undefined` as a valid value. + "exactOptionalPropertyTypes": false, + // Report an error for unreachable code instead of just a warning. + "allowUnreachableCode": false, + // Report an error for unused labels instead of just a warning. + "allowUnusedLabels": false } // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias //
    Property Type Description