Merge branch 'main' of https://github.com/OllieJT/svelte-podcast
This commit is contained in:
@@ -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 />`
|
||||
@@ -12,3 +12,5 @@ node_modules
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
**/components/_*
|
||||
@@ -17,4 +17,7 @@ module.exports = {
|
||||
es2017: true,
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', { patterns: ['$lib', '$lib/*'] }],
|
||||
},
|
||||
};
|
||||
|
||||
+24
-22
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -9,3 +9,4 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.eslintcache
|
||||
@@ -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" } }]
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -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"
|
||||
|
||||
Vendored
+5
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3373
File diff suppressed because it is too large
Load Diff
Vendored
+1
@@ -4,6 +4,7 @@
|
||||
declare module './lib/types' {
|
||||
interface EpisodeDetails {
|
||||
title: string;
|
||||
artwork: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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')}
|
||||
/>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
export * from './db-state';
|
||||
@@ -1 +0,0 @@
|
||||
export const _episode_progress_map = new Map<string, number>();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
export * from './db-state';
|
||||
+5
-1
@@ -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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './preferences';
|
||||
export * from './progress';
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,2 +1,2 @@
|
||||
export * from './get-pathname-from-url';
|
||||
export * from './seconds-to-timestamp';
|
||||
export * from './use-state';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { AudioLoader } from '$lib/audio';
|
||||
import { AudioLoader } from 'svelte-podcast';
|
||||
</script>
|
||||
|
||||
<AudioLoader />
|
||||
|
||||
+62
-15
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -10,8 +10,7 @@ const config = {
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
$lib: 'src/lib',
|
||||
$pkg: 'src/lib/utility/package/',
|
||||
'svelte-podcast': 'src/lib',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user