This commit is contained in:
Ollie Taylor
2023-03-06 16:16:52 +00:00
53 changed files with 4980 additions and 3485 deletions
+12
View File
@@ -0,0 +1,12 @@
---
'svelte-podcast': minor
---
Improves theming with css variables
Improves developer logs
Fixes various package export issues
Adds player components:
- Player: `<PlayerStack />`
- Player: `<PlayerWidget />`
- Utility: `<HeadlessTimeline />`
+2
View File
@@ -12,3 +12,5 @@ node_modules
pnpm-lock.yaml
package-lock.json
yarn.lock
**/components/_*
+3
View File
@@ -17,4 +17,7 @@ module.exports = {
es2017: true,
node: true,
},
rules: {
'no-restricted-imports': ['error', { patterns: ['$lib', '$lib/*'] }],
},
};
+24 -22
View File
@@ -1,30 +1,32 @@
name: 'CI'
on:
pull_request:
push:
branches:
- '**'
- main
pull_request:
env:
# we call `pnpm playwright install` instead
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
# cancel in-progress runs on new commits to same PR (gitub.event.number)
concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
build:
Lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: use node v16.x
uses: actions/setup-node@v3
- uses: pnpm/action-setup@v2.2.4
- uses: actions/setup-node@v3
with:
node-version: 16.x
- name: yarn install
uses: borales/actions-yarn@v4
with:
cmd: install --frozen-lockfile
- name: prettier lint
uses: borales/actions-yarn@v4
with:
cmd: lint
- name: svelte check
uses: borales/actions-yarn@v4
with:
cmd: check
node-version: '16.x'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run check
+20 -10
View File
@@ -7,26 +7,36 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
build:
release:
# prevents this action from running on forks
if: github.repository == 'OllieJT/svelte-podcast'
permissions:
contents: write # to create release (changesets/action)
pull-requests: write # to create pull request (changesets/action)
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: use node v16.x
- name: Checkout Repo
uses: actions/checkout@v3
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- uses: pnpm/action-setup@v2.2.4
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 16.x
cache: pnpm
- name: yarn install
uses: borales/actions-yarn@v4
with:
cmd: install --frozen-lockfile
- run: pnpm install --frozen-lockfile
- name: Create Release Pull Request or Publish
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
publish: yarn run release
# This expects you to have a script called release which does a build for your packages and calls changeset publish
publish: pnpm release
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
UPDATE_TEMPLATE_SSH_KEY: ${{ secrets.UPDATE_TEMPLATE_SSH_KEY }}
+123
View File
@@ -0,0 +1,123 @@
name: 'CI'
on:
push:
branches:
- todo
env:
# we call `pnpm playwright install` instead
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
# cancel in-progress runs on new commits to same PR (gitub.event.number)
concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
Lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4
- uses: actions/setup-node@v3
with:
node-version: '16.x'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run check
Tests:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- node-version: 16
os: ubuntu-latest
e2e-browser: 'chromium'
- node-version: 18
os: ubuntu-latest
e2e-browser: 'chromium'
env:
KIT_E2E_BROWSER: ${{matrix.e2e-browser}}
steps:
- run: git config --global core.autocrlf false
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm playwright install ${{ matrix.e2e-browser }}
- run: pnpm test
- name: Archive test results
if: failure()
shell: bash
run: find packages -type d -name test-results -not -empty | tar -czf test-results.tar.gz --files-from=-
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v3
with:
retention-days: 3
name: test-failure-${{ github.run_id }}-${{ matrix.os }}-${{ matrix.node-version }}-${{ matrix.e2e-browser }}
path: test-results.tar.gz
Cross-browser-test:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- node-version: 16
os: windows-2019 # slowness reported on newer versions https://github.com/actions/runner-images/issues/5166
e2e-browser: 'chromium'
mode: 'dev'
- node-version: 16
os: ubuntu-latest
e2e-browser: 'firefox'
mode: 'dev'
- node-version: 16
os: macOS-latest
e2e-browser: 'webkit'
mode: 'dev'
- node-version: 16
os: windows-2019 # slowness reported on newer versions https://github.com/actions/runner-images/issues/5166
e2e-browser: 'chromium'
mode: 'build'
- node-version: 16
os: ubuntu-latest
e2e-browser: 'firefox'
mode: 'build'
- node-version: 16
os: macOS-latest
e2e-browser: 'webkit'
mode: 'build'
env:
KIT_E2E_BROWSER: ${{matrix.e2e-browser}}
steps:
- run: git config --global core.autocrlf false
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm playwright install ${{ matrix.e2e-browser }}
- run: pnpm test:cross-platform:${{ matrix.mode }}
- name: Archive test results
if: failure()
shell: bash
run: find packages -type d -name test-results -not -empty | tar -czf test-results-cross-platform-${{ matrix.mode }}.tar.gz --files-from=-
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v3
with:
retention-days: 3
name: test-failure-cross-platform-${{ matrix.mode }}-${{ github.run_id }}-${{ matrix.os }}-${{ matrix.node-version }}-${{ matrix.e2e-browser }}
path: test-results-cross-platform-${{ matrix.mode }}.tar.gz
+1
View File
@@ -9,3 +9,4 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.eslintcache
+3
View File
@@ -4,6 +4,9 @@
"trailingComma": "all",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"svelteSortOrder": "options-scripts-styles-markup",
"svelteBracketNewLine": true,
"svelteIndentScriptAndStyle": true,
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+1
View File
@@ -2,6 +2,7 @@
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"dbaeumer.vscode-eslint",
"aaron-bond.better-comments",
"esbenp.prettier-vscode",
"gruntfuggly.todo-tree"
+5 -2
View File
@@ -6,7 +6,8 @@
"source.fixAll.eslint": true,
"source.organizeImports": true
},
"eslint.packageManager": "yarn",
"eslint.packageManager": "pnpm",
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "svelte"],
"files.exclude": {
"**/.DS_Store": true,
"**/*-lock.*": true,
@@ -21,5 +22,7 @@
"**/.svelte-kit": true
},
"problems.sortOrder": "severity",
"todo-tree.tree.scanMode": "workspace only"
"todo-tree.tree.scanMode": "workspace only",
"javascript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.importModuleSpecifier": "relative"
}
+10
View File
@@ -1,5 +1,15 @@
# svelte-podcast
## 0.5.0
### Minor Changes
- feba0d4: Improvements to audio stores:
- Adds missing `is_paused` value.
- Fixes to `start_at` value
- Refactors localStorage stores & user preferences
## 0.4.0
### Minor Changes
+33 -24
View File
@@ -1,6 +1,6 @@
{
"name": "svelte-podcast",
"version": "0.4.0",
"version": "0.5.0",
"license": "MIT",
"author": "Ollie Taylor",
"homepage": "https://github.com/OllieJT/svelte-podcast/blob/main/README.md",
@@ -13,16 +13,18 @@
},
"scripts": {
"dev": "vite dev",
"build": "vite build && npm run package",
"build": "vite build && pnpm package",
"preview": "vite preview",
"package": "svelte-kit sync && svelte-package && publint",
"prepublishOnly": "npm run package",
"test": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"release": "yarn run build && changeset publish"
"lint:prettier": "prettier --check --plugin-search-dir=. .",
"lint:eslint": "TIMING=1 eslint . --ext .ts,.tsx,.svelte --cache",
"lint": "pnpm lint:prettier && pnpm lint:eslint",
"format": "prettier --write --plugin-search-dir=. .",
"release": "pnpm package && changeset publish",
"ts": "tsc --pretty --noImplicitAny --noEmit"
},
"exports": {
".": {
@@ -34,32 +36,39 @@
"dist"
],
"peerDependencies": {
"svelte": "^3.54.0"
"svelte": "^3.55.1"
},
"dependencies": {
"just-clamp": "^4.2.0"
"@inqling/svelte-icons": "^3.0.2",
"clsx": "^1.2.1",
"just-clamp": "^4.2.0",
"svelte-local-storage-store": "^0.4.0"
},
"devDependencies": {
"@changesets/cli": "^2.26.0",
"@playwright/test": "^1.28.1",
"@playwright/test": "^1.31.2",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.5.0",
"@sveltejs/package": "^2.0.0",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"eslint-config-prettier": "^8.5.0",
"@sveltejs/kit": "^1.10.0",
"@sveltejs/package": "^2.0.2",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"eslint": "^8.35.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte3": "^4.0.0",
"eslint": "^8.28.0",
"prettier-plugin-svelte": "^2.8.1",
"prettier": "^2.8.0",
"publint": "^0.1.9",
"svelte-check": "^3.0.1",
"svelte": "^3.54.0",
"tslib": "^2.4.1",
"typescript": "^4.9.3",
"vite": "^4.0.0"
"prettier": "^2.8.4",
"prettier-plugin-svelte": "^2.9.0",
"publint": "^0.1.10",
"svelte": "^3.55.1",
"svelte-check": "^3.0.4",
"tslib": "^2.5.0",
"typescript": "^4.9.5",
"vite": "^4.1.4"
},
"svelte": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module"
"type": "module",
"packageManager": "pnpm@7.28.0",
"engines": {
"pnpm": "^7.0.0"
}
}
+3373
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@
declare module './lib/types' {
interface EpisodeDetails {
title: string;
artwork: string;
}
}
+6 -1
View File
@@ -6,7 +6,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body>
<body
style="
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
"
>
<div>%sveltekit.body%</div>
</body>
</html>
+14 -7
View File
@@ -1,18 +1,27 @@
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { readable } from 'svelte/store';
import { get, readable } from 'svelte/store';
import { user_preferences } from '../user';
export const audio_element = readable<HTMLAudioElement | null>(null, (set) => {
if (!browser) return;
const ID = 'svelte-podcast-generated-audio-element';
const ID = 'svpod--generated-audio-element';
onMount(() => {
const el = document.createElement('audio');
const existing_element = document.getElementById(ID) as HTMLAudioElement | null;
const el = existing_element || document.createElement('audio');
el.id = ID;
el.setAttribute('preload', 'metadata');
el.muted = false;
el.autoplay = true;
el.controls = true;
document.body.appendChild(el);
el.controls = false;
const preferences = get(user_preferences);
el.playbackRate = preferences.playback_rate;
el.volume = preferences.volume;
if (!existing_element) document.body.appendChild(el);
set(el);
});
@@ -21,5 +30,3 @@ export const audio_element = readable<HTMLAudioElement | null>(null, (set) => {
element ? element.remove() : null;
};
});
export type AudioElementStore = typeof audio_element;
+110 -6
View File
@@ -1,13 +1,117 @@
<script lang="ts">
import { audio_element } from '$lib/audio/audio-element';
import { load_podcast_state } from '$lib/utility';
import { info } from '$lib/utility/package/log';
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';
$: $audio_element;
/* onMount(() => {
return audio_element.subscribe((el) => {
if (el) info('mounted audio element :: ', el);
});
}); */
onMount(() => {
load_podcast_state();
return audio_element.subscribe((el) => {
info('loaded audio :: ', el);
return user_preferences.subscribe((prefs) => {
const el = get(audio_element);
if (!el) return warn('no audio element found');
info('setting preferences :: ', prefs);
el.volume = prefs.volume;
el.playbackRate = prefs.playback_rate;
});
});
</script>
<svelte:head>
<style>
:root {
--svpod--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--svpod--surface--darker: rgb(0, 0, 0);
--svpod--surface--base: rgb(40, 40, 40);
--svpod--surface--lighter: rgb(60, 60, 60);
--svpod--content--darker: rgb(150, 150, 150);
--svpod--content--base: rgb(200, 200, 200);
--svpod--content--lighter: rgb(255, 255, 255);
--svpod--accent--darker: rgb(75, 15, 0);
--svpod--accent--base: rgb(180, 40, 0);
--svpod--accent--lighter: rgb(255, 55, 25);
--bg: var(--svpod--surface--darker);
--fg: var(--svpod--content--darker);
--svpod--radius--rounded: 9999px;
/* element : timeline */
--svpod--timeline-track--shape--height: 20px;
--svpod--timeline-track--shape--radius: 0;
--svpod--timeline-track--shape--border: 0;
--svpod--timeline-thumb--shape--width: 8px;
--svpod--timeline-thumb--shape--height: 32px;
--svpod--timeline-thumb--shape--radius: 6px;
--svpod--timeline-thumb--shape--border: 2px;
}
.svpod--reset {
text-transform: none;
font-style: normal;
text-indent: 0;
text-shadow: none;
text-align: left;
margin: 0;
padding: 0;
}
.svpod--a11y-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.svpod--button,
.svpod--icon-button {
--fg: var(--svpod--content--base);
--bg: var(--svpod--surface--base);
position: relative;
line-height: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 16px;
border: 0px solid transparent;
border-radius: var(--svpod--radius--rounded);
background-color: var(--bg);
color: var(--fg);
cursor: pointer;
}
.svpod--button:hover,
.svpod--icon-button:hover {
--fg: var(--svpod--content--lighter);
--bg: var(--svpod--surface--lighter);
}
.svpod--button {
padding: 4px calc(1em);
}
.svpod--icon-button {
padding: 8px;
min-width: 48px;
min-height: 48px;
}
</style>
</svelte:head>
+32 -20
View File
@@ -1,24 +1,22 @@
import { audio_element, type AudioElementStore } from '$lib/audio/audio-element';
import { episode_details, type EpisodeDetailsStore } from '$lib/audio/episode-details';
import { user_preferences } from '$lib/preferences';
import { podcast_progress } from '$lib/progress';
import type { EpisodeAttributes, EpisodeDetails } from '$lib/types';
import { warn } from '$lib/utility/package/log';
import clamp from 'just-clamp';
import { derived, get } from 'svelte/store';
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_muted: false,
is_paused: true,
duration: 0,
start_at: 0,
details: null,
} satisfies Omit<EpisodeAttributes, 'src'>;
const episode_attributes = derived<
[AudioElementStore, EpisodeDetailsStore],
EpisodeAttributes | null
>([audio_element, episode_details], ([$audio, $details], set) => {
type EpisodeAttributesStore = Readable<EpisodeAttributes | null>;
const episode_attributes = derived([audio_element, episode_details], ([$audio, $details], set) => {
if (!$audio) return set(null);
function set_value() {
@@ -28,12 +26,22 @@ const episode_attributes = derived<
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());
return () => $audio.removeEventListener('loadeddata', () => set_value());
});
$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';
@@ -42,13 +50,17 @@ const no_element = (action: string) => warn(`could not ${action} :: no audio ele
export const episode_audio = {
subscribe: episode_attributes.subscribe,
load: (src: string, details: EpisodeDetails) => {
podcast_progress.stash_episode();
user_progress.save();
const el = get(audio_element);
if (!el) return no_element('load');
const progress = podcast_progress.get_episode(src);
el.src = src;
el.currentTime = progress?.start_at || 0;
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;
@@ -56,7 +68,7 @@ export const episode_audio = {
episode_details.set(details);
},
unload: () => {
podcast_progress.stash_episode();
user_progress.save();
const el = get(audio_element);
if (!el) return no_element('unload');
el.src = '';
@@ -73,7 +85,7 @@ export const episode_audio = {
}
},
pause: (t?: HandleType) => {
podcast_progress.stash_episode();
user_progress.save();
const el = get(audio_element);
if (!el) return no_element('pause');
+1 -2
View File
@@ -1,5 +1,4 @@
import type { EpisodeDetails } from '$lib/types';
import { writable } from 'svelte/store';
import type { EpisodeDetails } from '../types';
export const episode_details = writable<EpisodeDetails | null>(null);
export type EpisodeDetailsStore = typeof episode_details;
+8 -9
View File
@@ -1,7 +1,7 @@
import { audio_element, type AudioElementStore } from '$lib/audio/audio-element';
import type { EpisodeProgress } from '$lib/types';
import { secondsToTimestamp } from '$lib/utility';
import { derived } from 'svelte/store';
import { derived, type Readable } from 'svelte/store';
import type { EpisodeProgress } from '../types';
import { secondsToTimestamp } from '../utility';
import { audio_element } from './audio-element';
const default_episode_progress = {
current_time: 0,
@@ -9,9 +9,9 @@ const default_episode_progress = {
has_ended: false,
} satisfies EpisodeProgress;
export const episode_progress = derived<AudioElementStore, EpisodeProgress>(
audio_element,
($audio, set) => {
type ProgressStore = Readable<EpisodeProgress>;
export const episode_progress = derived(audio_element, ($audio, set) => {
if (!$audio) return set(default_episode_progress);
function set_value() {
@@ -25,5 +25,4 @@ export const episode_progress = derived<AudioElementStore, EpisodeProgress>(
$audio.addEventListener('timeupdate', () => set_value());
return () => $audio.removeEventListener('timeupdate', () => set_value());
},
);
}) satisfies ProgressStore;
+4 -4
View File
@@ -1,4 +1,4 @@
export { default as AudioLoader } from '$lib/audio/audio-loader.svelte';
export * from '$lib/audio/episode-data';
export * from '$lib/audio/episode-details';
export * from '$lib/audio/episode-progress';
export { default as AudioLoader } from './audio-loader.svelte';
export * from './episode-data';
export * from './episode-details';
export * from './episode-progress';
-21
View File
@@ -1,21 +0,0 @@
<script lang="ts">
export let currentTime: number;
export let paused: boolean;
export let duration: number;
</script>
<progress data-paused={paused ? 'true' : 'false'} max={duration} value={currentTime} />
<style>
progress {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
+145
View File
@@ -0,0 +1,145 @@
<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>
<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>
<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')}
/>
+3
View File
@@ -0,0 +1,3 @@
export { default as HeadlessTimeline } from './headless-timeline.svelte';
export { default as PlayerStack } from './player-stack.svelte';
export { default as PlayerWidget } from './player-widget.svelte';
+318
View File
@@ -0,0 +1,318 @@
<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>
<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>
<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>
+234
View File
@@ -0,0 +1,234 @@
<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>
<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>
<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>
@@ -0,0 +1,9 @@
<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>
+4
View File
@@ -0,0 +1,4 @@
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';
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
export let size = 24;
export let value: number;
export let type: 'forward' | 'backward';
</script>
<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>
<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>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
export let size = 24;
</script>
<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>
<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>
@@ -0,0 +1,19 @@
<script lang="ts">
import { secondsToTimestamp } from '../../utility';
export let value: number;
export let force_hours = false;
$: timestamp = secondsToTimestamp(value, force_hours);
</script>
<style>
span {
letter-spacing: 0.5px;
font-size: 0.8em;
display: block;
width: 8ch;
}
</style>
<span style="width:{timestamp.length}ch">{timestamp}</span>
+4 -4
View File
@@ -1,4 +1,4 @@
export * from '$lib/audio';
export * from '$lib/preferences';
export * from '$lib/progress';
export * from '$lib/utility';
export * from './audio';
export * from './components';
export * from './user';
export * from './utility';
-37
View File
@@ -1,37 +0,0 @@
import { browser } from '$app/environment';
import { user_preferences } from '$lib/preferences/db-state';
import type { UserPreferences } from '$lib/types';
import { info, warn } from '$lib/utility/package/log';
import { get } from 'svelte/store';
const USER_PREFERENCE_KEY = 'USER_PREFERENCE' as const;
export function save_preferences() {
if (!browser || !localStorage) {
warn('localStorage not available, skipping save');
return;
}
const preferences = get(user_preferences);
info(`Saving user preferences to localStorage`, preferences);
localStorage.setItem(USER_PREFERENCE_KEY, JSON.stringify(preferences));
}
export function load_preferences() {
if (!browser || !localStorage) {
warn('localStorage not available, skipping load');
return;
}
const data = localStorage.getItem(USER_PREFERENCE_KEY);
if (!data) {
info('No saved user preferences found');
return;
}
const preferences = JSON.parse(data) as UserPreferences;
user_preferences.edit(preferences);
info(`Loaded user preferences from localStorage`, preferences);
}
-29
View File
@@ -1,29 +0,0 @@
import type { UserPreferences } from '$lib/types';
import clamp from 'just-clamp';
import { writable } from 'svelte/store';
import { load_preferences, save_preferences } from './db-local-storage';
const _default_user_preferences = { playback_rate: 1, volume: 1 } satisfies UserPreferences;
const _user_preferences = writable<UserPreferences>(_default_user_preferences);
const clamp_preferences = (prefs: UserPreferences) => ({
playback_rate: clamp(prefs.playback_rate, 0.5, 5),
volume: clamp(prefs.volume, 0, 1),
});
const edit = (prefs: Partial<UserPreferences>) => {
_user_preferences.update((prev) => clamp_preferences({ ...prev, ...prefs }));
save_preferences();
};
const clear = () => {
_user_preferences.set(_default_user_preferences);
save_preferences();
};
export const user_preferences = {
subscribe: _user_preferences.subscribe,
edit,
clear,
load: load_preferences,
};
-1
View File
@@ -1 +0,0 @@
export * from './db-state';
-1
View File
@@ -1 +0,0 @@
export const _episode_progress_map = new Map<string, number>();
-48
View File
@@ -1,48 +0,0 @@
import { browser } from '$app/environment';
import { _episode_progress_map } from '$lib/progress/_private';
import { error, info, warn } from '$lib/utility/package/log';
type SavedEpisodeProgress = { src: string; current_time: number };
const EPISODE_PROGRESS_KEY = 'EPISODE_PROGRESS' as const;
export function save_podcast_progress() {
if (!browser || !localStorage) {
warn('localStorage not available, skipping save');
return;
}
const items = [..._episode_progress_map];
console.log('items', items);
const episodes = items.reduce((prev, [src, current_time]) => {
return [...prev, { src, current_time }];
}, [] as SavedEpisodeProgress[]);
info(`Saving progress for ${episodes.length} episodes to localStorage`, episodes);
localStorage.setItem(EPISODE_PROGRESS_KEY, JSON.stringify(episodes));
}
export function load_podcast_progress() {
if (!browser || !localStorage) {
warn('localStorage not available, skipping load');
return;
}
const data = localStorage.getItem(EPISODE_PROGRESS_KEY);
if (!data) {
info('No saved episode progress found');
return;
}
const episodes = JSON.parse(data) as SavedEpisodeProgress[];
try {
episodes.forEach(({ src, current_time }) => {
_episode_progress_map.set(src, current_time);
});
info(`Loaded ${_episode_progress_map.size} episodes progress from localStorage`);
} catch (e) {
error('Error loading episode progress', e);
return;
}
}
-43
View File
@@ -1,43 +0,0 @@
import { episode_audio, episode_progress } from '$lib/audio';
import { _episode_progress_map } from '$lib/progress/_private';
import type { AudioLoadOptions } from '$lib/types';
import { info } from '$lib/utility/package/log';
import { get } from 'svelte/store';
import { load_podcast_progress, save_podcast_progress } from './db-local-storage';
const get_src_pathname = (src: string) => {
if (src.startsWith('http')) return new URL(src).pathname;
else return new URL(src, 'https://svelte.dev').pathname;
};
const stash_episode_progress = () => {
const audio = get(episode_audio);
console.log('audio', audio);
if (!audio?.src) return;
info('saving progress: ', audio.src);
const pathname = get_src_pathname(audio.src);
_episode_progress_map.set(pathname, get(episode_progress).current_time);
save_podcast_progress();
};
const get_episode_progress = (src: string): Pick<AudioLoadOptions, 'start_at'> | null => {
const start_at = _episode_progress_map.get(src);
if (!start_at) return null;
info('found saved progress: ', src, start_at);
return { start_at: start_at };
};
const clear = () => {
_episode_progress_map.clear();
save_podcast_progress();
};
export const podcast_progress = {
stash_episode: stash_episode_progress,
get_episode: get_episode_progress,
load: load_podcast_progress,
clear,
};
-1
View File
@@ -1 +0,0 @@
export * from './db-state';
+5 -1
View File
@@ -6,7 +6,7 @@ export type EpisodeProgress = { current_time: number; timestamp: string; has_end
export type EpisodeAttributes = {
will_autoplay: boolean;
is_muted: boolean;
is_paused: boolean;
duration: number;
src: string;
start_at: number;
@@ -22,3 +22,7 @@ export interface UserPreferences {
playback_rate: number;
volume: number;
}
export type UserProgress = {
[key: string]: number;
};
+2
View File
@@ -0,0 +1,2 @@
export * from './preferences';
export * from './progress';
+21
View File
@@ -0,0 +1,21 @@
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),
};
+27
View File
@@ -0,0 +1,27 @@
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),
};
+4
View File
@@ -0,0 +1,4 @@
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 -1
View File
@@ -1,2 +1,2 @@
export * from './get-pathname-from-url';
export * from './seconds-to-timestamp';
export * from './use-state';
+6 -2
View File
@@ -1,8 +1,8 @@
import { dev } from '$app/environment';
const useLogger = {
error: console.error,
info: console.info,
error: console.error,
warn: console.warn,
};
@@ -13,7 +13,11 @@ export function log(type: Logger, ...content: unknown[]) {
if (type === 'info' && !dev) return;
logger('🔊 svelte-podcast: ', ...content);
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);
+2 -2
View File
@@ -3,14 +3,14 @@ function section(t: number) {
return t;
}
export function secondsToTimestamp(seconds: number) {
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 = hh > 0 ? `${section(hh)}:` : '';
const hrs = force_hours || hh > 0 ? `${section(hh)}:` : '';
const mins = mm ? `${section(mm)}:` : '00:';
const secs = ss ? `${section(ss)}` : '00';
-7
View File
@@ -1,7 +0,0 @@
import { user_preferences } from '$lib/preferences';
import { podcast_progress } from '$lib/progress';
export function load_podcast_state() {
podcast_progress.load();
user_preferences.load();
}
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { AudioLoader } from '$lib/audio';
import { AudioLoader } from 'svelte-podcast';
</script>
<AudioLoader />
+62 -15
View File
@@ -1,6 +1,12 @@
<script lang="ts">
import { podcast_progress, user_preferences } from '$lib';
import { episode_audio, episode_progress } from '$lib/audio';
import {
episode_audio,
episode_progress,
PlayerStack,
PlayerWidget,
user_preferences,
user_progress,
} from 'svelte-podcast';
const sources = {
syntax: {
@@ -17,6 +23,8 @@
let current_time = 0;
$: current_time = $episode_progress.current_time;
$: console.log('details :: ', $episode_audio?.details);
</script>
<pre>{JSON.stringify(
@@ -26,16 +34,16 @@
$user_preferences,
},
null,
3,
2,
)}</pre>
<h1>Demo</h1>
<a href="/another-page">Another Page</a>
<button type="button" on:click={podcast_progress.clear}>Clear progress for all episodes</button>
<button type="button" on:click={user_progress.clear}>Clear progress for all episodes</button>
<button type="button" on:click={user_preferences.clear}>Clear all preferences</button>
<h5>Load Audio</h5>
<button type="button" on:click={() => episode_audio.load(sources['syntax'].src, sources['knomii'])}
<button type="button" on:click={() => episode_audio.load(sources['syntax'].src, sources['syntax'])}
>Syntax</button
>
<button type="button" on:click={() => episode_audio.load(sources['knomii'].src, sources['knomii'])}
@@ -59,15 +67,6 @@
<h6>Seeking</h6>
<input
type="range"
min={0}
max={$episode_audio?.duration}
style="width:100%"
bind:value={current_time}
on:change={(e) => episode_audio.seek(parseInt(e.currentTarget.value))}
/>
<button type="button" on:click={() => episode_audio.seek(30)}>Go to 30s from start </button>
<button type="button" on:click={() => episode_audio.seek(30, 'from-end')}>Go to 30s from end</button
>
@@ -77,7 +76,55 @@
<h6>Playback Rate</h6>
{#each [0.5, 1, 2, 3] as rate}
<button type="button" on:click={() => user_preferences.edit({ playback_rate: rate })}>
<button type="button" on:click={() => user_preferences.set.playback_rate(rate)}>
{rate}x
</button>
{/each}
<hr />
<!-- <PodcastPlayer
artwork={$episode_audio?.details?.artwork}
title={$episode_audio?.details?.title || 'Podcast Name'}
/> -->
<br />
<PlayerWidget />
<br />
<br />
<PlayerWidget include={{ playback_rate: true }} />
<br />
<br />
<PlayerWidget include={{ skip_back: 10, skip_forward: 30 }} />
<br />
<br />
<PlayerWidget
style="width: 100%;"
include={{
current_time: true,
playback_rate: true,
duration: true,
skip_back: 10,
skip_forward: 30,
}}
/>
<br />
<br />
<PlayerStack style="width:400px;" />
<br />
<br />
<PlayerStack include={{ playback_rate: true }} />
<br />
<br />
<PlayerStack include={{ skip_back: 10, skip_forward: 30, timestamps: true }} />
<br />
<br />
<PlayerStack
include={{
playback_rate: true,
timestamps: true,
skip_back: 10,
skip_forward: 30,
}}
/>
+98 -6
View File
@@ -1,16 +1,108 @@
<script lang="ts">
import { episode_audio, episode_progress } from '$lib/audio';
import {
episode_audio,
episode_progress,
PlayerWidget,
user_preferences,
user_progress,
} from 'svelte-podcast';
const sources = {
syntax: {
src: `/example-syntax.mp3`,
title: `Supper Club × Rich Harris, Author of Svelte`,
artwork: `https://ssl-static.libsyn.com/p/assets/b/3/c/d/b3cdf28da11ad39fe5bbc093207a2619/Syntax_-_499.jpg`,
},
knomii: {
src: `/example-knomii.mp3`,
title: `Empowerment starts with letting go of control`,
artwork: `https://ssl-static.libsyn.com/p/assets/f/a/8/d/fa8d56d5226884335f2e77a3093c12a1/ep-6.png`,
},
} as const;
let current_time = 0;
$: current_time = $episode_progress.current_time;
$: console.log('details :: ', $episode_audio?.details);
</script>
<h1>Demo</h1>
<a href="/">Demo</a>
<pre>{JSON.stringify(
{
$episode_audio,
$episode_progress,
$user_preferences,
},
null,
3,
2,
)}</pre>
<h1>Demo</h1>
<a href="/another-page">Another Page</a>
<button type="button" on:click={user_progress.clear}>Clear progress for all episodes</button>
<button type="button" on:click={user_preferences.clear}>Clear all preferences</button>
<h5>Load Audio</h5>
<button type="button" on:click={() => episode_audio.load(sources['syntax'].src, sources['syntax'])}
>Syntax</button
>
<button type="button" on:click={() => episode_audio.load(sources['knomii'].src, sources['knomii'])}
>Knomii</button
>
<button type="button" on:click={() => episode_audio.unload()}>None</button>
<h5>Custom audio controls</h5>
<h6>Play / Pause Actions</h6>
<button type="button" on:click={() => episode_audio.play()}>Play</button>
<button type="button" on:click={() => episode_audio.pause()}>Pause</button>
<button type="button" on:click={() => episode_audio.pause('toggle')}>Toggle</button>
<h6>Audio Actions</h6>
<button type="button" on:click={() => episode_audio.mute()}>Mute</button>
<button type="button" on:click={() => episode_audio.unmute()}>Unmute</button>
<button type="button" on:click={() => episode_audio.mute('toggle')}>Toggle</button>
<h6>Seeking</h6>
<button type="button" on:click={() => episode_audio.seek(30)}>Go to 30s from start </button>
<button type="button" on:click={() => episode_audio.seek(30, 'from-end')}>Go to 30s from end</button
>
<button type="button" on:click={() => episode_audio.skip(10, 'forward')}>Skip 10s</button>
<button type="button" on:click={() => episode_audio.skip(10, 'backward')}>Rewind 10s</button>
<h6>Playback Rate</h6>
{#each [0.5, 1, 2, 3] as rate}
<button type="button" on:click={() => user_preferences.set.playback_rate(rate)}>
{rate}x
</button>
{/each}
<hr />
<!-- <PodcastPlayer
artwork={$episode_audio?.details?.artwork}
title={$episode_audio?.details?.title || 'Podcast Name'}
/> -->
<br />
<PlayerWidget />
<br />
<br />
<PlayerWidget include={{ playback_rate: true }} />
<br />
<br />
<PlayerWidget include={{ skip_back: 10, skip_forward: 30 }} />
<br />
<br />
<PlayerWidget
include={{
current_time: true,
playback_rate: true,
duration: true,
skip_back: 10,
skip_forward: 30,
}}
/>
+1 -2
View File
@@ -10,8 +10,7 @@ const config = {
kit: {
adapter: adapter(),
alias: {
$lib: 'src/lib',
$pkg: 'src/lib/utility/package/',
'svelte-podcast': 'src/lib',
},
},
};
-3145
View File
File diff suppressed because it is too large Load Diff