diff --git a/apps/docs/package.json b/apps/docs/package.json index 6ba15c002..320796d22 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -14,8 +14,8 @@ "@podlove/subscribe-button": "workspace:*", "@podlove/types": "workspace:*", "@podlove/utils": "workspace:*", - "farbraum": "0.1.4", - "vue": "3.2.41" + "farbraum": "0.2.1", + "vue": "3.4.31" }, "devDependencies": { "@histoire/app": "0.16.1", diff --git a/apps/docs/src/stories/components/chapter-progress.story.vue b/apps/docs/src/stories/components/chapter-progress.story.vue index b950e2cae..c2026be17 100644 --- a/apps/docs/src/stories/components/chapter-progress.story.vue +++ b/apps/docs/src/stories/components/chapter-progress.story.vue @@ -28,7 +28,7 @@ const progressColor = ref(persianGreen); const ghostColor = ref(sandyBrown); const style = ref({ '--podlove-component--chapter-progress--background': progressColor, - '--podlove-component--chapter-progress--ghost-background': ghostColor + '--podlove-component--chapter-progress--ghost--background': ghostColor }); diff --git a/apps/page/README.md b/apps/page/README.md new file mode 100644 index 000000000..1db3fb399 --- /dev/null +++ b/apps/page/README.md @@ -0,0 +1,54 @@ +# Astro Starter Kit: Basics + +```sh +npm create astro@latest -- --template basics +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics) +[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics) +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json) + +> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! + +![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554) + +## 🚀 Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +```text +/ +├── public/ +│ └── favicon.svg +├── src/ +│ ├── components/ +│ │ └── Card.astro +│ ├── layouts/ +│ │ └── Layout.astro +│ └── pages/ +│ └── index.astro +└── package.json +``` + +Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. + +There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. + +Any static assets, like images, can be placed in the `public/` directory. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | + +## 👀 Want to learn more? + +Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/apps/page/astro.config.mjs b/apps/page/astro.config.mjs new file mode 100644 index 000000000..ab108a2c5 --- /dev/null +++ b/apps/page/astro.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from 'astro/config'; +import cloudflare from '@astrojs/cloudflare'; +// import node from '@astrojs/node'; +import vue from '@astrojs/vue'; + +import tailwind from '@astrojs/tailwind'; + +// https://astro.build/config +export default defineConfig({ + output: 'server', + // adapter: node({ + // mode: 'standalone' + // }), + adapter: cloudflare({ + platformProxy: { + enabled: true + } + }), + integrations: [vue({ appEntrypoint: '/src/app' }), tailwind()] +}); diff --git a/apps/page/package.json b/apps/page/package.json new file mode 100644 index 000000000..e3a5c01eb --- /dev/null +++ b/apps/page/package.json @@ -0,0 +1,47 @@ +{ + "name": "@podlove/page", + "type": "module", + "version": "0.0.1", + "scripts": { + "serve": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@podlove/player-sagas": "workspace:*", + "@podlove/utils": "workspace:*", + "@podlove/player-actions": "workspace:*", + "@podlove/player-state": "workspace:*", + "@podlove/components": "workspace:*", + "@podlove/types": "workspace:*", + "@podlove/clients": "workspace:*", + "@podlove/webvtt-parser": "workspace:*", + "@astrojs/check": "0.8.1", + "@astrojs/node": "8.3.2", + "@astrojs/tailwind": "5.1.0", + "@astrojs/vue": "4.5.0", + "@astrojs/cloudflare": "11.0.1", + "astro": "4.11.5", + "vue": "3.4.31", + "fast-xml-parser": "4.3.2", + "lodash-es": "4.17.21", + "redux": "4.2.1", + "redux-vuex": "3.1.2", + "redux-actions": "3.0.0", + "redux-saga": "1.2.3", + "reselect": "4.1.8", + "vue-i18n": "9.13.1", + "scroll-into-view-if-needed": "3.1.0", + "multimatch": "7.0.0", + "@m31coding/fuzzy-search": "1.0.1", + "color.js": "1.2.0", + "farbraum": "0.2.1" + }, + "devDependencies": { + "tailwindcss": "3.0.24", + "typescript": "5.3.2", + "@types/lodash-es": "4.17.12", + "@types/redux-actions": "2.6.5" + } +} diff --git a/apps/page/public/favicon.svg b/apps/page/public/favicon.svg new file mode 100644 index 000000000..f157bd1c5 --- /dev/null +++ b/apps/page/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/apps/page/service-worker.ts b/apps/page/service-worker.ts new file mode 100644 index 000000000..3262de9a1 --- /dev/null +++ b/apps/page/service-worker.ts @@ -0,0 +1,104 @@ +/// +/// +const sw: ServiceWorkerGlobalScope & typeof globalThis = self as any; + +let CACHE: Cache | null = null; + +const getFeed = (): string | null => new URLSearchParams(sw.location.search).get('feed'); +const getCacheKey = (): string | null => new URLSearchParams(sw.location.search).get('cacheKey'); + +const getCache = async (): Promise => { + if (CACHE) { + return CACHE; + } + + const feed = getFeed(); + + if (!feed) { + return null; + } + + return caches.open(feed); +}; + +const handleRequest = async (request: Request) => { + const cache = await getCache(); + const cacheKey = getCacheKey(); + + if (!cache || !cacheKey) { + return fetch(request); + } + + const cachedUrl = new URL(request.url); + cachedUrl.searchParams.set('cacheKey', cacheKey); + + // First try to get the resource from the cache + const responseFromCache = await cache.match(cachedUrl.toString()); + + if (responseFromCache) { + return responseFromCache; + } + + // Next try to get the resource from the network + const responseFromNetwork = await fetch(cachedUrl.toString(), { mode: 'no-cors' }); + if (responseFromNetwork.status <= 300) { + cache.put(cachedUrl.toString(), responseFromNetwork.clone()); + } + + return responseFromNetwork; +}; + +const getCacheKeyFromRequest = (request: Request) => + new URL(request.url).searchParams.get('cacheKey'); + +sw.addEventListener('fetch', (event: any) => { + const { request } = event; + const supportedDestinations = ['image', '']; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + if (request.method !== 'GET') { + return; + } + + if (!supportedDestinations.includes(request.destination)) { + return; + } + + event.respondWith(handleRequest(request)); +}); + +const cleanCache = async (cacheKey: string) => { + const cache = await getCache(); + + if (!cache) { + return; + } + + const requests = await cache.keys(); + + requests + ?.filter((request) => getCacheKeyFromRequest(request) !== cacheKey) + .forEach((request) => { + CACHE?.delete(request); + }); +}; + +sw.addEventListener('initialize', async (event: any) => { + const cacheKey = getCacheKey(); + + if (!cacheKey) { + return; + } + + event.waitUntil(cleanCache(cacheKey)); +}); diff --git a/apps/page/src/app.ts b/apps/page/src/app.ts new file mode 100644 index 000000000..436b62a13 --- /dev/null +++ b/apps/page/src/app.ts @@ -0,0 +1,17 @@ +import { provideStore } from 'redux-vuex'; +import { createI18n } from 'vue-i18n'; +import type { App } from 'vue'; +import { messages, defaultLang, getLanguage } from './i18n'; +import { store } from './logic'; + +export default (app: App) => { + const i18n = createI18n({ + legacy: false, + locale: getLanguage(), // set locale + fallbackLocale: defaultLang, + messages + }); + + app.use(i18n); + provideStore({ app, store }); +}; diff --git a/apps/page/src/components/Contributor.vue b/apps/page/src/components/Contributor.vue new file mode 100644 index 000000000..2539a617f --- /dev/null +++ b/apps/page/src/components/Contributor.vue @@ -0,0 +1,25 @@ + + + diff --git a/apps/page/src/components/CustomTransition.vue b/apps/page/src/components/CustomTransition.vue new file mode 100644 index 000000000..2cf8b1618 --- /dev/null +++ b/apps/page/src/components/CustomTransition.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/apps/page/src/components/HeaderContainer.vue b/apps/page/src/components/HeaderContainer.vue new file mode 100644 index 000000000..e489bb770 --- /dev/null +++ b/apps/page/src/components/HeaderContainer.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/page/src/components/Loader.vue b/apps/page/src/components/Loader.vue new file mode 100644 index 000000000..cce490018 --- /dev/null +++ b/apps/page/src/components/Loader.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/apps/page/src/components/PlayButton.vue b/apps/page/src/components/PlayButton.vue new file mode 100644 index 000000000..5862d0b8a --- /dev/null +++ b/apps/page/src/components/PlayButton.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/apps/page/src/components/Popover.vue b/apps/page/src/components/Popover.vue new file mode 100644 index 000000000..4467fbf18 --- /dev/null +++ b/apps/page/src/components/Popover.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/apps/page/src/env.d.ts b/apps/page/src/env.d.ts new file mode 100644 index 000000000..f964fe0cf --- /dev/null +++ b/apps/page/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/page/src/features/Colors.vue b/apps/page/src/features/Colors.vue new file mode 100644 index 000000000..b2e9716a6 --- /dev/null +++ b/apps/page/src/features/Colors.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/page/src/features/LoadingBar.vue b/apps/page/src/features/LoadingBar.vue new file mode 100644 index 000000000..0e0989e8a --- /dev/null +++ b/apps/page/src/features/LoadingBar.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/apps/page/src/features/LoadingOverlay.vue b/apps/page/src/features/LoadingOverlay.vue new file mode 100644 index 000000000..f6864e2f9 --- /dev/null +++ b/apps/page/src/features/LoadingOverlay.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/apps/page/src/features/PageFooter.vue b/apps/page/src/features/PageFooter.vue new file mode 100644 index 000000000..605c2d99c --- /dev/null +++ b/apps/page/src/features/PageFooter.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/page/src/features/PageHeader.vue b/apps/page/src/features/PageHeader.vue new file mode 100644 index 000000000..fbae95241 --- /dev/null +++ b/apps/page/src/features/PageHeader.vue @@ -0,0 +1,54 @@ + + diff --git a/apps/page/src/features/Search.vue b/apps/page/src/features/Search.vue new file mode 100644 index 000000000..afca323e2 --- /dev/null +++ b/apps/page/src/features/Search.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/apps/page/src/features/playbar/Chapter.vue b/apps/page/src/features/playbar/Chapter.vue new file mode 100644 index 000000000..9c907fb80 --- /dev/null +++ b/apps/page/src/features/playbar/Chapter.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/apps/page/src/features/playbar/PlayBar.vue b/apps/page/src/features/playbar/PlayBar.vue new file mode 100644 index 000000000..3a24146de --- /dev/null +++ b/apps/page/src/features/playbar/PlayBar.vue @@ -0,0 +1,384 @@ + + + + + diff --git a/apps/page/src/features/subscribe/Subscribe.vue b/apps/page/src/features/subscribe/Subscribe.vue new file mode 100644 index 000000000..76361e911 --- /dev/null +++ b/apps/page/src/features/subscribe/Subscribe.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/apps/page/src/features/timeline/Bullet.vue b/apps/page/src/features/timeline/Bullet.vue new file mode 100644 index 000000000..39fcfcb6b --- /dev/null +++ b/apps/page/src/features/timeline/Bullet.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/apps/page/src/features/timeline/Chapter.vue b/apps/page/src/features/timeline/Chapter.vue new file mode 100644 index 000000000..c18883fcc --- /dev/null +++ b/apps/page/src/features/timeline/Chapter.vue @@ -0,0 +1,86 @@ + + + diff --git a/apps/page/src/features/timeline/Marker.vue b/apps/page/src/features/timeline/Marker.vue new file mode 100644 index 000000000..1658ce4dc --- /dev/null +++ b/apps/page/src/features/timeline/Marker.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/page/src/features/timeline/Timeline.vue b/apps/page/src/features/timeline/Timeline.vue new file mode 100644 index 000000000..a76e4154a --- /dev/null +++ b/apps/page/src/features/timeline/Timeline.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/page/src/features/timeline/Transcript.vue b/apps/page/src/features/timeline/Transcript.vue new file mode 100644 index 000000000..faa021351 --- /dev/null +++ b/apps/page/src/features/timeline/Transcript.vue @@ -0,0 +1,141 @@ + + + diff --git a/apps/page/src/i18n/index.ts b/apps/page/src/i18n/index.ts new file mode 100644 index 000000000..a16c8d1ed --- /dev/null +++ b/apps/page/src/i18n/index.ts @@ -0,0 +1,2 @@ +export { messages, defaultLang } from './messages'; +export { getLanguage, useTranslations } from './utils'; diff --git a/apps/page/src/i18n/messages.ts b/apps/page/src/i18n/messages.ts new file mode 100644 index 000000000..b7cc55117 --- /dev/null +++ b/apps/page/src/i18n/messages.ts @@ -0,0 +1,161 @@ +export const defaultLang = 'de'; + +const de = { + ARCHIVE: { + LOAD_MORE: 'Mehr Anzeigen' + }, + HEADER: { + SUBSCRIBE: 'Subscribe', + EPISODES: 'Episoden', + CONTRIBUTORS: 'Kontributoren' + }, + FOOTER: { + COPYRIGHT: '© {copyright}', + CREATED_WITH: 'Erstellt mit {name} am {buildDate}', + CONTACT: 'Kontakt: {name}', + }, + EPISODE: { + SUMMARY: 'Info', + SHOWNOTES: 'Shownotes', + TIMELINE: 'Timeline', + DISCUSS: 'Kommentare' + }, + PLAYBAR: { + CHAPTERS: 'Kapitel' + }, + CONTRIBUTOR: { + SOCIAL: 'Social', + DONATION: 'Support', + SUMMARY: 'Zusammenfassung', + EPISODES: 'Episoden', + EPISODE: 'Episode', + TIMELINE: 'Verlauf', + WORDS_TOTAL: 'Wörter', + WORDS_TOTAL_TOOLTIP: '{relative}% aller Wörter in diesem Podcast ({total})', + TALK_TIME_TOTAL: 'Sprechzeit', + TALK_TIME_TOTAL_TOOLTIP: '{relative}% der gesamten Sprechzeit in diesem Podcast ({total})', + EPISODES_TOTAL: 'Episoden', + EPISODES_TOTAL_TOOLTIP: '{relative}% aller Episoden in diesem Podcast ({total})' + }, + CONTRIBUTOR_LIST: { + TITLE: 'Kontributoren', + EPISODES_COUNT: ({ count }: { count: number }) => (count <= 1 ? `${count} Episode` : `${count} Episoden`) + }, + SEARCH: { + PLACEHOLDER: 'Suchen', + NO_RESULTS: 'Es wurden keine Ergebnisse gefunden', + INDEXING: 'Suchindex wird erstellt', + EPISODE: `Episode {number} - {title}`, + CATEGORY: { + EPISODE: 'Episoden', + CONTRIBUTOR: 'Kontributoren', + TRANSCRIPT: 'Transkripte' + } + }, + SUBSCRIBE_BUTTON: { + CLIENTS: 'Podcast Clients', + FEED: 'RSS Feed' + }, + A11Y: { + PLAYER_CHAPTER_END: 'Zum Ende der Episode springen', + PLAYER_CHAPTER_NEXT: 'Zum nächsten Kapitel: {index} - {title}', + PLAYER_CHAPTER_START: 'Zum Start der Episode springen', + PLAYER_CHAPTER_PREVIOUS: 'Zum vorhergehenden Kapitel: {index} - {title}', + PLAYER_CHAPTER_CURRENT: 'Zum Anfang des aktiven Kapitels springen: {index} - {title}', + PLAYER_STEPPER_BACK: '{seconds} Sekunden zurückspringen', + PLAYER_STEPPER_FORWARD: '{seconds} Sekunden vorspringen', + PROGRESSBAR_INPUT: 'Spielzeit in Prozent ändern', + PLAYER_PLAY: 'Episode abspielen', + PLAYER_START: 'Starte Episode - Dauer: {hours} Stunden {minutes} Minuten {seconds} Sekunden', + PLAYER_RESTART: 'Episode neustarten', + PLAYER_ERROR: 'Nochmals versuchen Episode abzuspielen', + PLAYER_PAUSE: 'Episode pausieren', + PLAYER_LOADING: 'Episode lädt' + }, + TIMELINE: { + START: 'Start', + END: 'End', + } +}; + +const en = { + ARCHIVE: { + LOAD_MORE: 'Load More' + }, + HEADER: { + SUBSCRIBE: 'Subscribe', + EPISODES: 'Episodes', + CONTRIBUTORS: 'Contributors' + }, + FOOTER: { + COPYRIGHT: '© {copyright}', + CREATED_WITH: 'Created with {name} on {buildDate}', + CONTACT: 'Contact: {name}', + }, + EPISODE: { + SUMMARY: 'Summary', + SHOWNOTES: 'Shownotes', + TIMELINE: 'Timeline', + DISCUSS: 'Discuss' + }, + PLAYBAR: { + CHAPTERS: 'Chapters' + }, + CONTRIBUTOR: { + SOCIAL: 'Social', + DONATION: 'Support', + SUMMARY: 'Summary', + EPISODES: 'Episodes', + EPISODE: 'Episode', + TIMELINE: 'Timeline', + WORDS_TOTAL: 'Words', + WORDS_TOTAL_TOOLTIP: '{relative}% of all words in this podcast ({total})', + TALK_TIME_TOTAL: 'Speaking Time', + TALK_TIME_TOTAL_TOOLTIP: '{relative}% of the total speaking time in this podcast ({total})', + EPISODES_TOTAL: 'Episodes', + EPISODES_TOTAL_TOOLTIP: '{relative}% of all episodes in this podcast ({total})' + }, + CONTRIBUTOR_LIST: { + TITLE: 'Contributors', + EPISODES_COUNT: ({ count }: { count: number }) => (count <= 1 ? `${count} episode` : `${count} episodes`) + }, + SEARCH: { + PLACEHOLDER: 'Search', + NO_RESULTS: 'No results were found', + INDEXING: 'Search index is created', + EPISODE: `Episode {number} - {title}`, + CATEGORY: { + EPISODE: 'Episodes', + CONTRIBUTOR: 'Contributors', + TRANSCRIPT: 'Transcripts' + } + }, + SUBSCRIBE_BUTTON: { + CLIENTS: 'Podcast Clients', + FEED: 'RSS Feed' + }, + A11Y: { + PLAYER_CHAPTER_END: 'Skip to the end of the episode', + PLAYER_CHAPTER_NEXT: 'Skip to the next chapter: {index} - {title}', + PLAYER_CHAPTER_START: 'Skip to the start of the episode', + PLAYER_CHAPTER_PREVIOUS: 'Skip to the previous chapter: {index} - {title}', + PLAYER_CHAPTER_CURRENT: 'Jump to the beginning of the active chapter: {index} - {title}', + PLAYER_STEPPER_BACK: 'Jump back {seconds} seconds', + PLAYER_STEPPER_FORWARD: 'Jump forward {seconds} seconds', + PROGRESSBAR_INPUT: 'Change playing time in percent', + PLAYER_PLAY: 'Play Episode', + PLAYER_START: 'Start Episode - Duration: {hours} hours {minutes} minutes {seconds} seconds', + PLAYER_RESTART: 'Restart episode', + PLAYER_ERROR: 'Try to play the episode again', + PLAYER_PAUSE: 'Pause episode', + PLAYER_LOADING: 'Episode is loading' + }, + TIMELINE: { + START: 'Start', + END: 'End', + } +}; + +export const messages = { + de, en +}; diff --git a/apps/page/src/i18n/utils.ts b/apps/page/src/i18n/utils.ts new file mode 100644 index 000000000..0f5190b8f --- /dev/null +++ b/apps/page/src/i18n/utils.ts @@ -0,0 +1,28 @@ +import { get } from 'lodash-es'; +import { messages, defaultLang } from './messages'; +import { store, selectors } from '../logic' + +export function useTranslations() { + const lang = getLanguage(); + + return (key: string) => { + const message = get(messages, [lang, key].join('.')); + + if (message) { + return message + } + + const fallback = get(messages, [defaultLang, key].join('.')); + + if (fallback) { + return fallback; + } + + return key; + }; +} + +export function getLanguage() { + const locale = selectors.runtime.locale(store.getState()); + return get(locale.split('-'), 0, defaultLang); +} diff --git a/apps/page/src/layouts/Layout.astro b/apps/page/src/layouts/Layout.astro new file mode 100644 index 000000000..c3ed29606 --- /dev/null +++ b/apps/page/src/layouts/Layout.astro @@ -0,0 +1,57 @@ +--- +import { ViewTransitions } from 'astro:transitions'; +import PageHeader from '../features/PageHeader.vue'; +import PlayBar from '../features/playbar/PlayBar.vue'; +import Search from '../features/Search.vue'; +import PageFooter from '../features/PageFooter.vue'; +import LoadingBar from '../features/LoadingBar.vue'; +import Subscribe from '../features/subscribe/Subscribe.vue'; +import Colors from '../features/Colors.vue'; +import LoadingOverlay from '../features/LoadingOverlay.vue'; +import { store } from '../logic'; +import { getLanguage } from '../i18n'; + +const { title } = Astro.props; +const state = store.getState(); +const lang = getLanguage(); + +--- + + + + + + + + + + + {title} + + + + + + + + +
+ +
+ + + +
+ + + + + + + diff --git a/apps/page/src/lib/caching.ts b/apps/page/src/lib/caching.ts new file mode 100644 index 000000000..d35cf9394 --- /dev/null +++ b/apps/page/src/lib/caching.ts @@ -0,0 +1,15 @@ + +export const etag = async (input: any): Promise => { + const entity = JSON.stringify(input); + + const textAsBuffer = new TextEncoder().encode(entity); + const hashBuffer = await crypto.subtle.digest("SHA-1", textAsBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hash = hashArray + .map((item) => item.toString(16).padStart(2, "0")) + .join(""); + + const len = Buffer.byteLength(entity, 'utf8'); + + return len.toString(16) + '-' + Buffer.from(hash).toString('base64').substring(0, 27); +} diff --git a/apps/page/src/lib/directives/click-outside.ts b/apps/page/src/lib/directives/click-outside.ts new file mode 100644 index 000000000..52a611663 --- /dev/null +++ b/apps/page/src/lib/directives/click-outside.ts @@ -0,0 +1,11 @@ +export default { + beforeMount(el: HTMLElement, { value: fn } : { value: Function }) { + el.addEventListener('click', (evt) => { + if (evt.target !== el) { + return; + } + + fn(); + }); + } +} diff --git a/apps/page/src/lib/middleware.ts b/apps/page/src/lib/middleware.ts new file mode 100644 index 000000000..acbcb4cc1 --- /dev/null +++ b/apps/page/src/lib/middleware.ts @@ -0,0 +1,4 @@ +import { get } from 'lodash-es'; + +export const getRequestHeader = (request: Request, header: string, fallback: string) => + get((request.headers.get(header) || '').split(','), 0, fallback); diff --git a/apps/page/src/lib/persons.ts b/apps/page/src/lib/persons.ts new file mode 100644 index 000000000..086217c7b --- /dev/null +++ b/apps/page/src/lib/persons.ts @@ -0,0 +1,10 @@ +import { curry } from 'lodash-es'; +import type { Person } from '../types/feed.types'; + +export const findPerson = curry((persons: Person[], query: string): Person | null => { + const person: Person | undefined = persons.find( + (person: Person) => query === person.id || person.name.toLocaleLowerCase().startsWith(query) + ); + + return person || null; +}); diff --git a/apps/page/src/lib/runtime.ts b/apps/page/src/lib/runtime.ts new file mode 100644 index 000000000..789b58723 --- /dev/null +++ b/apps/page/src/lib/runtime.ts @@ -0,0 +1,2 @@ +export const isClient = () => typeof globalThis.window !== 'undefined'; +export const isServer = () => typeof globalThis.window === 'undefined'; diff --git a/apps/page/src/lib/url.ts b/apps/page/src/lib/url.ts new file mode 100644 index 000000000..54a9a33de --- /dev/null +++ b/apps/page/src/lib/url.ts @@ -0,0 +1,17 @@ +export const addQueryparams = ( + url: string, + params: { [key: string]: string | number | boolean | null } +) => { + const tmp = new URL(url); + + Object.entries(params).forEach(([key, value]) => { + if (value === null) { + return; + } + tmp.searchParams.append(key, value.toString()); + }); + + return tmp.toString(); +}; + +export const proxy = (url: string) => `/proxy?` + new URLSearchParams({ url }) diff --git a/apps/page/src/logic/data/feed-parser.ts b/apps/page/src/logic/data/feed-parser.ts new file mode 100644 index 000000000..c0c925f22 --- /dev/null +++ b/apps/page/src/logic/data/feed-parser.ts @@ -0,0 +1,174 @@ +import { get, castArray, kebabCase } from 'lodash-es'; +import { XMLParser } from 'fast-xml-parser'; +import { toPlayerTime } from '@podlove/utils/time'; +import webVttParser from '@podlove/webvtt-parser'; + +import type { + Audio, + Author, + Chapter, + Episode, + Person, + Podcast, + Show, + Transcript +} from '../../types/feed.types'; + +const parser = new XMLParser({ + ignoreAttributes: false +}); + +const transformPerson = (input: any): Person => { + const name = get(input, '#text', null); + + return { + name: get(input, '#text', null), + avatar: get(input, '@_img', null), + id: kebabCase(name) + }; +}; + +const transformChapter = (input: any): Chapter => ({ + start: toPlayerTime(get(input, ['@_start'], null)), + title: get(input, ['@_title'], null), + end: null, + image: null, + href: null +}); + +export const transformTranscript = (input: any): Transcript => { + const speaker = get(input, ['speaker'], null); + + return { + speaker: kebabCase(speaker), + voice: speaker, + start: get(input, ['startTime'], 0), + end: get(input, ['endTime'], 0), + text: get(input, ['body'], '') + }; +}; + +const buildChapterList = + (duration: number | null) => + (input: Chapter, index: number, list: Chapter[]): Chapter => ({ + ...input, + end: get(list, [index + 1, 'start'], duration) as number + }); + +const transformShow = (data: any): Show => ({ + title: get(data, ['channel', 'title'], null), + description: get(data, ['channel', 'description'], null), + link: get(data, ['channel', 'link'], null), + summary: get(data, ['channel', 'itunes:summary'], null), + poster: get(data, ['channel', 'image', 'url'], null) +}); + +const getTranscriptUrl = async (data: any): Promise => { + const transcripts: { '@_url': string; '@_type': string }[] = get( + data, + ['podcast:transcript'], + [] + ); + const vtt = transcripts.find((item) => get(item, ['@_type'], null) === 'text/vtt'); + + return get(vtt, ['@_url'], null); +}; + +export const resolveTranscripts = async (transcriptsUrl: string): Promise => + fetch(transcriptsUrl) + .then((result) => result.text()) + .then(webVttParser) + .then((result) => get(result, ['cues'], [])) + .then((cues) => + cues.map((cue) => ({ + voice: cue.voice || null, + speaker: cue.identifier || kebabCase(cue.voice) || null, + start: cue.start, + end: cue.end, + text: cue.text + })) + ); + +const transformAudio = (input: any): Audio[] => { + const url = get(input, ['enclosure', '@_url'], null); + const size = get(input, ['enclosure', '@_length'], null); + const mimeType = get(input, ['enclosure', '@_type'], null); + + return [ + { + url, + size, + mimeType + } + ]; +}; + +const resolveEpisode = + (episodeId?: number) => + async (data: any): Promise => { + const id = get(data, 'itunes:episode', null); + const duration = toPlayerTime(get(data, 'itunes:duration', 0)); + const transcriptUrl = await getTranscriptUrl(data); + + return { + id, + title: get(data, 'title', null), + description: get(data, 'description', null), + subtitle: get(data, 'itunes:subtitle', null), + link: get(data, 'link', null), + publicationDate: get(data, 'pubDate', null), + duration, + content: get(data, 'content:encoded', null), + poster: get(data, ['itunes:image', '@_href'], null), + contributors: castArray(get(data, ['podcast:person'], [])).map(transformPerson), + chapters: castArray(get(data, ['psc:chapters', 'psc:chapter'], [])) + .map(transformChapter) + .map(buildChapterList(duration)), + transcripts: id === episodeId && transcriptUrl ? await resolveTranscripts(transcriptUrl) : [], + audio: transformAudio(data) + }; + }; + +const transformAuthor = (data: any): Author => ({ + copyright: get(data, ['channel', 'copyright'], null), + owner: get(data, ['channel', 'itunes:owner', 'itunes:name'], null), + name: get(data, ['channel', 'itunes:author'], null), + mail: get(data, ['channel', 'itunes:owner', 'itunes:email'], null) +}); + +const transform = + (episodeId?: number) => + async (data: any): Promise => ({ + etag: get(data, 'etag', null), + buildDate: get(data, ['channel', 'lastBuildDate'], null), + author: transformAuthor(data), + show: transformShow(data), + episodes: await Promise.all( + castArray(get(data, ['channel', 'item'], [])).map(resolveEpisode(episodeId)) + ).then(episodes => episodes.filter(episode => episode.id !== null)), + hosts: castArray(get(data, ['channel', 'podcast:person'], [])).map(transformPerson) + }); + +export default async ({ + feed, + episodeId +}: { + feed: string | null; + episodeId?: number; +}): Promise => { + if (!feed) { + return transform(episodeId)({}); + } + + return await fetch(feed) + .then(async (result) => { + const feedXml = await result.text(); + const etag: string | null = result.headers.get('etag'); + + return { + ...get(parser.parse(feedXml), 'rss', {}), + etag + }; + }) + .then(transform(episodeId)); +}; diff --git a/apps/page/src/logic/index.ts b/apps/page/src/logic/index.ts new file mode 100644 index 000000000..7ddc08191 --- /dev/null +++ b/apps/page/src/logic/index.ts @@ -0,0 +1,9 @@ +import { type Store } from 'redux'; +import { createStore, actions, selectors, type State } from './store'; +import { createSideEffects } from './sagas'; + +const store: Store = createStore(); + +createSideEffects(); + +export { store, actions, selectors }; diff --git a/apps/page/src/logic/sagas/data.sagas.ts b/apps/page/src/logic/sagas/data.sagas.ts new file mode 100644 index 000000000..fb34ee2fe --- /dev/null +++ b/apps/page/src/logic/sagas/data.sagas.ts @@ -0,0 +1,31 @@ +import type { Action } from 'redux-actions'; +import { put, select, takeEvery } from 'redux-saga/effects'; +import { actions } from '../store'; +import parseFeed from '../data/feed-parser'; +import type { initializeAppPayload } from '../store/stores/runtime.store'; +import type { Podcast } from '../../types/feed.types'; +import { version } from '../../../package.json'; +import { etag } from '../../lib/caching.js'; + +function* fetchData(input: Action) { + const data: Podcast = yield parseFeed(input.payload); + + const cacheKey: string | null = data.etag ? yield etag({ + feed: data.etag, + version + }) : null + + yield put(actions.lifecycle.dataFetched({ data, cacheKey })); +} + +export default ({ selectInitializedApp }: { selectInitializedApp: (input: any) => boolean }) => { + return function* () { + const initialized: boolean = yield select(selectInitializedApp); + + if (initialized) { + return; + } + + yield takeEvery(actions.lifecycle.initializeApp.toString(), fetchData); + }; +}; diff --git a/apps/page/src/logic/sagas/episode.sagas.ts b/apps/page/src/logic/sagas/episode.sagas.ts new file mode 100644 index 000000000..e912779b0 --- /dev/null +++ b/apps/page/src/logic/sagas/episode.sagas.ts @@ -0,0 +1,103 @@ +import { put, takeEvery, select } from 'redux-saga/effects'; +import { + requestPlay, + requestPause, + backendLoadingStart, + requestLoad, +} from '@podlove/player-actions/play'; +import { backendPlaytime, requestPlaytime } from '@podlove/player-actions/timepiece'; +import { takeOnce } from '@podlove/player-sagas/helper'; +import { setRate, setVolume, mute, unmute } from '@podlove/player-actions/audio'; +import { init, ready } from '@podlove/player-actions/lifecycle'; +import { isNil } from 'lodash-es'; +import type { Action } from 'redux-actions'; + +import { actions } from '../store'; +import type { Episode, Show } from '../../types/feed.types'; +import type { playEpisodePayload } from '../store/stores/player.store'; +import { toPlayerEpisode } from '../transformations/player'; + +export default ({ + selectEpisode, + selectShow, + selectRate, + selectVolume, + selectMuted, + selectCurrentEpisode, + selectPlaying +}: { + selectEpisode: (id: string | number) => (input: any) => Episode; + selectShow: (input: any) => Show; + selectRate: (input: any) => number; + selectVolume: (input: any) => number; + selectMuted: (input: any) => boolean; + selectPlaying: (input: any) => boolean; + selectCurrentEpisode: (input: any) => string | null; +}) => { + function* resetMeta() { + const rate: number = yield select(selectRate); + const volume: number = yield select(selectVolume); + const muted: boolean = yield select(selectMuted); + + yield put(setRate(rate)); + yield put(setVolume(volume)); + + yield put(muted ? mute() : unmute()); + } + + function* injectEpisode(episode: Episode) { + const playing: boolean = yield select(selectPlaying); + + if (playing) { + yield put(requestPause()); + } + + const show: Show = yield select(selectShow); + yield put(init(toPlayerEpisode(episode, show))); + yield put(requestLoad()); + + yield takeOnce(ready.toString(), requestPlayEpisode); + yield takeOnce(backendLoadingStart.toString(), resetMeta); + } + + function* playEpisode({ payload: { id, playtime } }: Action) { + const currentEpisode: string = yield select(selectCurrentEpisode); + + if (currentEpisode === id && !isNil(playtime)) { + yield put(requestPlaytime(playtime)); + yield put(requestPlay()); + return; + } + + yield put(actions.player.selectEpisode(id)); + + const episode: Episode = yield select(selectEpisode(id)); + + if (currentEpisode !== id) { + yield injectEpisode(episode); + } else { + yield put(requestPlay()); + } + + if (!isNil(playtime)) { + yield takeOnce(backendPlaytime.toString(), requestPlaytimeEpisode, [playtime]); + } + } + + function* pauseEpisode() { + yield put(requestPause()); + } + + function* requestPlayEpisode() { + yield put(requestPlay()); + } + + function* requestPlaytimeEpisode(playtime: number) { + yield put(requestPlaytime(playtime)); + } + + return function* () { + yield takeEvery(actions.player.playEpisode.toString(), playEpisode); + yield takeEvery(actions.player.pauseEpisode.toString(), pauseEpisode); + }; +}; diff --git a/apps/page/src/logic/sagas/index.ts b/apps/page/src/logic/sagas/index.ts new file mode 100644 index 000000000..599190f5b --- /dev/null +++ b/apps/page/src/logic/sagas/index.ts @@ -0,0 +1,84 @@ +import sagasEngine from '@podlove/player-sagas/middleware'; +import { quantilesSaga } from '@podlove/player-sagas/quantiles'; +import { chaptersSaga } from '@podlove/player-sagas/chapters'; +import { stepperSaga } from '@podlove/player-sagas/stepper'; +import { lifeCycleSaga } from '@podlove/player-sagas/lifecycle'; + +import { selectors } from '../store'; +import dataSagas from './data.sagas'; +import episodeSagas from './episode.sagas'; +import playbarSagas from './playbar.sagas'; +import routerSaga from './router.sagas'; + +import { isClient } from '../../lib/runtime'; +import serviceworkerSaga from './serviceworker.sagas'; +import searchSaga from './search.sagas'; +import layoutSaga from './layout.sagas'; + +export async function createSideEffects() { + const sagas = [ + lifeCycleSaga, + routerSaga, + dataSagas({ selectInitializedApp: selectors.runtime.initialized }), + episodeSagas({ + selectEpisode: selectors.episode.data, + selectShow: selectors.podcast.show, + selectRate: selectors.player.audio.rate, + selectVolume: selectors.player.audio.volume, + selectMuted: selectors.player.audio.muted, + selectCurrentEpisode: selectors.current.episode, + selectPlaying: selectors.player.playing + }), + chaptersSaga({ + selectDuration: selectors.player.duration, + selectPlaytime: selectors.player.playtime, + selectCurrentChapter: selectors.player.chapters.current, + selectChapterList: selectors.player.chapters.list + }), + stepperSaga({ + selectDuration: selectors.player.duration, + selectPlaytime: selectors.player.playtime, + selectLivesync: selectors.player.livesync + }), + quantilesSaga, + playbarSagas({ + selectRate: selectors.player.audio.rate, + selectMuted: selectors.player.audio.muted + }), + layoutSaga({ + selectSearchOverlayVisible: selectors.search.visible, + selectSubscribeOverlayVisible: selectors.subscribeButton.visible, + selectShowPoster: selectors.podcast.poster + }) + ] as any[]; + + if (isClient()) { + sagas.push(serviceworkerSaga({ + selectFeed: selectors.podcast.feed, + selectCacheKey: selectors.runtime.cacheKey, + })); + + sagas.push(searchSaga({ + selectInitialized: selectors.search.initialized, + selectEpisodes: selectors.episodes.list, + // selectContributors: selectors.contributors.list, + selectVisible: selectors.search.visible, + selectResults: selectors.search.results, + selectSelectedResult: selectors.search.selectedResult, + })) + + const { playerSaga } = await import('@podlove/player-sagas/player'); + + sagas.push( + playerSaga({ + selectMedia: selectors.player.media, + selectPlaytime: selectors.player.playtime, + selectPoster: selectors.player.image, + selectTitle: selectors.player.title, + mountPoint: document.getElementById('media-container') as HTMLDivElement + }) + ); + } + + sagasEngine.run(...sagas); +} diff --git a/apps/page/src/logic/sagas/layout.sagas.ts b/apps/page/src/logic/sagas/layout.sagas.ts new file mode 100644 index 000000000..0e9e90dec --- /dev/null +++ b/apps/page/src/logic/sagas/layout.sagas.ts @@ -0,0 +1,107 @@ +import type { EventChannel } from 'redux-saga'; +import { call, fork, put, select, takeEvery } from 'redux-saga/effects'; +import { channel } from '@podlove/player-sagas/helper'; +import { prominent } from 'color.js'; +import { lighten, negate } from 'farbraum'; + +import actions from '../store/actions'; +import { isClient } from '../../lib/runtime'; +import type { ColorTokens, rgbColor } from '../../types/color.types'; +import { proxy } from '../../lib/url'; + +export default function ({ + selectSubscribeOverlayVisible, + selectSearchOverlayVisible, + selectShowPoster +}: { + selectSubscribeOverlayVisible: (input: any) => boolean; + selectSearchOverlayVisible: (input: any) => boolean; + selectShowPoster: (input: any) => string | null; +}) { + function* disableOverflow() { + document.body.classList.add('overflow-hidden'); + } + + function* enableOverflow() { + document.body.classList.remove('overflow-hidden'); + } + + function* startLoading() { + yield put(actions.view.startLoading()); + } + + function* stopLoading() { + yield put(actions.view.stopLoading()); + } + + function* initializeColors() { + const poster: string | null = yield select(selectShowPoster); + + const tailwindColorTokens = (color: rgbColor | null): ColorTokens | null => { + const tokens = [100, 200, 300, 400, 500, 600, 700, 800]; + + if (!color) { + return null; + } + + return tokens.reduce( + (result, token) => ({ + ...result, + [token]: lighten(color, (900 - token) / 1000) as rgbColor + }), + { 900: color } + ) as ColorTokens; + }; + + if (!poster) { + return; + } + + const primaryColor: rgbColor = yield prominent(proxy(poster), { + amount: 1 + }); + + if (!primaryColor) { + return; + } + + const complementaryColor = negate(primaryColor) as rgbColor; + + const primary = tailwindColorTokens(primaryColor); + const complementary = tailwindColorTokens(complementaryColor); + + yield put( + actions.colors.setColors({ + ...(primary ? { primary } : {}), + ...(complementary ? { complementary } : {}) + }) + ); + } + + return function* () { + if (isClient()) { + yield fork(initializeColors); + + const pageLoadStart: EventChannel = yield call(channel, (cb: EventListener) => + document.addEventListener('astro:before-preparation', cb) + ); + const pageLoadEnd: EventChannel = yield call(channel, (cb: EventListener) => + document.addEventListener('astro:after-preparation', cb) + ); + + yield takeEvery(pageLoadStart, startLoading); + yield takeEvery(pageLoadEnd, stopLoading); + } + + while (true) { + const subscribeOverlayVisible: boolean = yield select(selectSubscribeOverlayVisible); + const searchOverlayVisible: boolean = yield select(selectSearchOverlayVisible); + + if (subscribeOverlayVisible || searchOverlayVisible) { + disableOverflow(); + } else { + enableOverflow(); + } + } + }; +} diff --git a/apps/page/src/logic/sagas/playbar.sagas.ts b/apps/page/src/logic/sagas/playbar.sagas.ts new file mode 100644 index 000000000..01727c1b8 --- /dev/null +++ b/apps/page/src/logic/sagas/playbar.sagas.ts @@ -0,0 +1,101 @@ +import { put, takeEvery, select, fork, delay, call } from 'redux-saga/effects'; +import { + backendEnd, + backendLoadingEnd, + backendLoadingStart, + backendPause, + backendPlay, + type backendLoadingEndPayload +} from '@podlove/player-actions/play'; +import { setRate, mute, unmute } from '@podlove/player-actions/audio'; +import scrollIntoView from 'scroll-into-view-if-needed'; +import type { Action } from 'redux-actions'; + +import { actions, selectors } from '../store'; +import { isClient } from '../../lib/runtime'; + +export default ({ + selectRate, + selectMuted +}: { + selectRate: (input: any) => number; + selectMuted: (input: any) => boolean; +}) => { + function* play() { + yield put(actions.playbar.play()); + } + + function* pause() { + yield put(actions.playbar.pause()); + } + + function* loading() { + yield put(actions.playbar.loading()); + } + + function* restart() { + yield put(actions.playbar.restart()); + } + + function* loaded({ payload }: Action) { + if (payload.paused) { + yield pause(); + } else { + yield play(); + } + } + + function* nextRate() { + const steps = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2.0]; + const rate: number = yield select(selectRate); + + const next = steps.indexOf(rate) + 1; + + if (next < steps.length) { + yield put(setRate(steps[next])); + } else { + yield put(setRate(steps[0])); + } + } + + function* toggleMute() { + const muted: boolean = yield select(selectMuted); + yield put(muted ? unmute() : mute()); + } + + function* followContent(): any { + const episodeId: string | null = yield select(selectors.current.episode); + const routerEpisodeId: string | null = yield select(selectors.router.episodeId); + const followContentEnabled: boolean = yield select(selectors.playbar.followContent); + const ghostActive: boolean = yield select(selectors.player.ghost.active); + + if (episodeId && routerEpisodeId && followContentEnabled) { + const node = ghostActive ? document.getElementById('transcript-ghost-active') : document.getElementById('transcript-active'); + + + node && + scrollIntoView(node, { + behavior: 'smooth', + scrollMode: 'always', + block: 'center', + inline: 'center' + }) + } + yield delay(500); + yield call(followContent); + } + + return function* () { + yield takeEvery(backendPlay.toString(), play); + yield takeEvery(backendPause.toString(), pause); + yield takeEvery(backendLoadingStart.toString(), loading); + yield takeEvery(backendLoadingEnd.toString(), loaded); + yield takeEvery(backendEnd.toString(), restart); + yield takeEvery(actions.playbar.nextRate.toString(), nextRate); + yield takeEvery(actions.playbar.toggleMute.toString(), toggleMute); + + if (isClient()) { + yield fork(followContent); + } + }; +}; diff --git a/apps/page/src/logic/sagas/router.sagas.ts b/apps/page/src/logic/sagas/router.sagas.ts new file mode 100644 index 000000000..aaf1201c3 --- /dev/null +++ b/apps/page/src/logic/sagas/router.sagas.ts @@ -0,0 +1,12 @@ +import { put, takeEvery } from 'redux-saga/effects'; +import type { Action } from 'redux-actions'; +import { actions } from '../store'; +import type { episodePagePayload } from '../store/stores/router.store'; + +export default function* () { + function* onEpisodePageNavigate({ payload }: Action) { + yield put(actions.router.setRoute([payload.base, 'episode', payload.episodeId])); + } + + yield takeEvery(actions.router.episodePage.toString(), onEpisodePageNavigate); +} diff --git a/apps/page/src/logic/sagas/search.sagas.ts b/apps/page/src/logic/sagas/search.sagas.ts new file mode 100644 index 000000000..aab3aa59a --- /dev/null +++ b/apps/page/src/logic/sagas/search.sagas.ts @@ -0,0 +1,229 @@ +import * as fuzzySearch from '@m31coding/fuzzy-search'; +import { flattenDeep } from 'lodash-es'; +import { put, select, takeEvery, all, throttle, call } from 'redux-saga/effects'; +import type { Action } from 'redux-actions'; +import type { EventChannel } from 'redux-saga'; +import { takeOnce, channel } from '@podlove/player-sagas/helper'; +import type { + EpisodeResult, + TranscriptResult, + searchActionPayload +} from '../store/stores/search.store'; +import { actions } from '../store'; +import { resolveTranscripts } from '../data/feed-parser'; +import type { Episode, Person, Transcript } from '../../types/feed.types'; +// import { findPerson } from '../../lib/persons'; +import { proxy } from '../../lib/url'; + +export default ({ + selectVisible, + selectInitialized, + selectEpisodes, + // selectContributors, + selectResults, + selectSelectedResult +}: { + selectVisible: (input: any) => boolean; + selectInitialized: (input: any) => boolean; + selectEpisodes: (input: any) => Episode[]; + // selectContributors: (input: any) => Person[]; + selectResults: (input: any) => { id: string | number }[]; + selectSelectedResult: (input: any) => string | null; +}) => { + const EPISODES = fuzzySearch.SearcherFactory.createDefaultSearcher(); + const TRANSCRIPTS = fuzzySearch.SearcherFactory.createDefaultSearcher(); + const CONTRIBUTORS = fuzzySearch.SearcherFactory.createDefaultSearcher(); + + function* createEpisodesSearchIndex(episodes: Episode[]) { + const results = episodes.map((episode) => ({ + ...episode, + id: `episode-${episode.id}`, + episodeId: episode.id, + chapters: episode.chapters + .map(({ title }) => title) + .filter(Boolean) + .join(' '), + contributors: episode.contributors + .map(({ name }) => name) + .filter(Boolean) + .join(' ') + })); + + EPISODES.indexEntities( + results, + (e: any) => e.id, + (e: any) => [e.title, e.description, e.chapters, e.contributors, e.content] + ); + yield put(actions.search.initialize('episodes')); + } + + function* fetchTranscripts(episode: Episode) { + let transcripts: Transcript[] = []; + + if (typeof episode.transcripts === 'string') { + transcripts = yield resolveTranscripts(proxy(episode.transcripts)); + } + + return { ...episode, transcripts }; + } + + function* createTranscriptsSearchIndex(episodes: Episode[]) { + const results = episodes.map((episode) => + ((episode.transcripts as Transcript[]) || []).map((transcript, index) => ({ + id: `transcript-${episode.id}-${index}`, + text: transcript.text, + speaker: transcript.speaker, + episodeId: episode.id, + episodeTitle: episode.title + })) + ); + + TRANSCRIPTS.indexEntities( + flattenDeep(results), + (e: any) => e.id, + (e: any) => [e.text, e.speaker.name] + ); + + yield put(actions.search.initialize('transcripts')); + } + + // function* createContributorsSearchIndex(contributors: Person[], episodes: Episode[]) { + // const results = contributors.map((contributor) => { + // const attendedEpisodes = episodes.filter((episode) => + // findPerson(episode.contributors, contributor.id) + // ); + + // return { + // ...contributor, + // id: `contributor-${contributor.id}`, + // episode: { + // title: attendedEpisodes.map(({ title }) => title).join(' '), + // description: attendedEpisodes.map(({ description }) => description).join(' ') + // } + // }; + // }); + + // CONTRIBUTORS.indexEntities( + // flattenDeep(results), + // (e: any) => e.id, + // (e: any) => [e.episode.title, e.episode.description] + // ); + + // yield put(actions.search.initialize('contributors')); + // } + + function* createSearchIndex() { + const episodes: Episode[] = yield select(selectEpisodes); + // const contributors: Person[] = yield select(selectContributors); + const resolvedEpisodes: Episode[] = yield all(episodes.map(fetchTranscripts)); + yield createEpisodesSearchIndex(resolvedEpisodes); + yield createTranscriptsSearchIndex(resolvedEpisodes); + // yield createContributorsSearchIndex(contributors, resolvedEpisodes); + } + + function* searchForResults({ payload }: Action) { + const initialized: boolean = yield select(selectInitialized); + if (!initialized) { + return; + } + + const episodes = EPISODES.getMatches(new fuzzySearch.Query(payload || '', 5)).matches.map( + (match) => match.entity + ) as unknown as EpisodeResult[]; + + const contributors = CONTRIBUTORS.getMatches( + new fuzzySearch.Query(payload || '', 5) + ).matches.map((match) => match.entity) as unknown as Person[]; + + const transcripts = TRANSCRIPTS.getMatches(new fuzzySearch.Query(payload || '', 5)).matches.map( + (match) => match.entity + ) as unknown as TranscriptResult[]; + + yield put(actions.search.setEpisodeResults(episodes)); + yield put(actions.search.setTranscriptsResults(transcripts)); + yield put(actions.search.setContributorsResults(contributors)); + yield put(actions.search.setResults([...episodes, ...transcripts, ...contributors].length)); + } + + // Keyboard interactions + function focusResult(id: string) { + const result: HTMLAnchorElement | null = document.querySelector(`[data-result="${id}"]`); + result && result.focus(); + } + + function focusSearch() { + setTimeout(() => { + const search = document.getElementById('search-input'); + search && search.focus(); + }, 300); + } + + function* nextResult(modifier: number) { + const results: { id: string | number }[] = yield select(selectResults); + const resultsMap = results.map(({ id }) => id.toString()); + + if (resultsMap.length === 0) { + return; + } + + const selectedResult: string = yield select(selectSelectedResult); + + const index = resultsMap.findIndex((result) => result === selectedResult); + const size = resultsMap.length - 1; + + let next; + + if (index === -1) { + next = 0; + } else { + next = index + modifier; + } + + if (next > size) { + next = 0; + } + + if (next < 0) { + next = size; + } + + focusResult(resultsMap[next]); + } + + function* handleKeyboardEvent(event: KeyboardEvent) { + const visible: boolean = yield select(selectVisible); + + if (visible && event.key === 'ArrowUp') { + yield nextResult(-1); + event.preventDefault(); + } + + if (visible && (event.key === 'ArrowDown' || event.key === 'Tab')) { + yield nextResult(1); + event.preventDefault(); + } + + if (event.metaKey && event.code === 'KeyK') { + if (!visible) { + yield put(actions.search.show()); + } + + focusSearch(); + } + + if (visible && event.key === 'Escape') { + yield put(actions.search.hide()); + } + } + + return function* () { + yield takeOnce(actions.search.show.toString(), createSearchIndex); + yield throttle(300, actions.search.search.toString(), searchForResults); + + const keyboardEvents: EventChannel = yield call(channel, (cb: EventListener) => + document.addEventListener('keydown', cb) + ); + + yield takeEvery(keyboardEvents, handleKeyboardEvent); + }; +}; diff --git a/apps/page/src/logic/sagas/serviceworker.sagas.ts b/apps/page/src/logic/sagas/serviceworker.sagas.ts new file mode 100644 index 000000000..6cb65d689 --- /dev/null +++ b/apps/page/src/logic/sagas/serviceworker.sagas.ts @@ -0,0 +1,41 @@ +import { select } from 'redux-saga/effects'; +import serviceWorker from '../../../service-worker.js?url'; + +export default ({ + selectFeed, + selectCacheKey +}: { + selectFeed: (input: any) => string | null; + selectCacheKey: (input: any) => string | null; +}) => { + function* registerServiceWorker() { + const serviceWorkerAvailable = 'serviceWorker' in navigator; + + if (!serviceWorkerAvailable) { + return; + } + + const feed: string = yield select(selectFeed); + const cacheKey: string = yield select(selectCacheKey); + + try { + const registration: ServiceWorkerRegistration = yield navigator.serviceWorker.register( + `${serviceWorker}?feed=${feed}&cacheKey=${cacheKey}`, + { + scope: '/' + } + ); + + // auto check for updates + registration.update(); + } catch (error) { + console.error(`Registration failed with ${error}`); + } + } + + return function* () { + if(import.meta.env.MODE === 'production') { + yield registerServiceWorker(); + } + }; +}; diff --git a/apps/page/src/logic/store/actions.ts b/apps/page/src/logic/store/actions.ts new file mode 100644 index 000000000..aa4f1bccf --- /dev/null +++ b/apps/page/src/logic/store/actions.ts @@ -0,0 +1,31 @@ +import { setVolume } from '@podlove/player-actions/audio'; +import { setRate } from '@podlove/player-actions/audio'; +import { simulatePlaytime } from '@podlove/player-actions/timepiece'; +import { enableGhost, disableGhost } from '@podlove/player-actions/progress'; + +import { actions as lifecycle } from './stores/runtime.store'; +import { actions as episodes } from './stores/episodes.store'; +import { actions as search } from './stores/search.store'; +import { actions as player } from './stores/player.store'; +import { actions as playbar } from './stores/playbar.store'; +import { actions as subscribeButton } from './stores/subscribe-button.store'; +import { actions as router } from './stores/router.store'; +import { actions as view } from './stores/view.store'; +import { actions as colors } from './stores/colors.store'; + +export default { + episodes, + player, + playbar, + subscribeButton, + search, + lifecycle, + router, + setVolume, + setRate, + simulatePlaytime, + disableGhost, + enableGhost, + view, + colors +}; diff --git a/apps/page/src/logic/store/helper.ts b/apps/page/src/logic/store/helper.ts new file mode 100644 index 000000000..5c3758152 --- /dev/null +++ b/apps/page/src/logic/store/helper.ts @@ -0,0 +1,6 @@ +import { get } from 'lodash-es'; + +export const select = + (prop: string, fallback = {}): ((input: any) => T) => + (data: any) => + get(data, prop, fallback) as unknown as T; diff --git a/apps/page/src/logic/store/index.ts b/apps/page/src/logic/store/index.ts new file mode 100644 index 000000000..d149c0878 --- /dev/null +++ b/apps/page/src/logic/store/index.ts @@ -0,0 +1,27 @@ +import { createStore as createReduxStore, applyMiddleware, compose, type Store } from 'redux'; +import sagasEngine from '@podlove/player-sagas/middleware'; + +import selectors from './selectors'; +import actions from './actions'; +import reducers from './reducers'; +import type State from './state'; + +export function createStore(): Store { + let composeEnhancers = compose; + let preloadedState = undefined; + + if (globalThis.window) { + composeEnhancers = (globalThis.window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + preloadedState = (globalThis.window as any).REDUX_STATE + } + + const store = createReduxStore( + reducers, + preloadedState, + composeEnhancers(applyMiddleware(sagasEngine.middleware)), + ); + + return store; +} + +export { selectors, actions, type State }; diff --git a/apps/page/src/logic/store/reducers.ts b/apps/page/src/logic/store/reducers.ts new file mode 100644 index 000000000..a28befce8 --- /dev/null +++ b/apps/page/src/logic/store/reducers.ts @@ -0,0 +1,51 @@ +import { combineReducers } from 'redux'; +import { reducer as driver } from '@podlove/player-state/driver'; +import { reducer as show } from '@podlove/player-state/show'; +import { reducer as media } from '@podlove/player-state/media'; +import { reducer as timepiece } from '@podlove/player-state/timepiece'; +import { reducer as episode } from '@podlove/player-state/episode'; +import { reducer as audio } from '@podlove/player-state/audio'; +import { reducer as network } from '@podlove/player-state/network'; +import { reducer as ghost } from '@podlove/player-state/ghost'; +import { reducer as chapters } from '@podlove/player-state/chapters'; +import { reducer as quantiles } from '@podlove/player-state/quantiles'; + +import { reducer as runtime } from './stores/runtime.store'; +import { reducer as podcast } from './stores/podcast.store'; +import { reducer as action } from './stores/action.store'; +import { reducer as episodes } from './stores/episodes.store'; +import { reducer as playbar } from './stores/playbar.store'; +import { reducer as player } from './stores/player.store'; +import { reducer as search } from './stores/search.store'; +import { reducer as subscribeButton } from './stores/subscribe-button.store'; +import { reducer as router } from './stores/router.store'; +import { reducer as contributors } from './stores/contributors.store'; +import { reducer as view } from './stores/view.store'; +import { reducer as colors } from './stores/colors.store'; + +export default combineReducers({ + runtime, + action, + episodes, + playbar, + podcast, + player: combineReducers({ + quantiles, + chapters, + ghost, + network, + driver, + show, + media, + timepiece, + episode, + audio, + current: player + }), + search, + subscribeButton, + router, + contributors, + view, + colors +}); diff --git a/apps/page/src/logic/store/selectors.ts b/apps/page/src/logic/store/selectors.ts new file mode 100644 index 000000000..d0c44d205 --- /dev/null +++ b/apps/page/src/logic/store/selectors.ts @@ -0,0 +1,301 @@ +import { createSelector } from 'reselect'; +import { calcHours, calcMinutes, calcSeconds } from '@podlove/utils/time'; +import { currentChapterByPlaytime } from '@podlove/utils/chapters'; +import type { PodloveWebPlayerChapter } from '@podlove/types'; + +import type State from './state.js'; + +import { selectors as runtime } from './stores/runtime.store'; +import { selectors as podcast } from './stores/podcast.store'; +import { selectors as episodes } from './stores/episodes.store'; +import { selectors as player } from './stores/player.store'; +import { selectors as playbar } from './stores/playbar.store'; +import { selectors as subscribeButton } from './stores/subscribe-button.store'; +import { selectors as search } from './stores/search.store'; +import { selectors as router } from './stores/router.store'; +import { selectors as contributors } from './stores/contributors.store'; +import { selectors as view } from './stores/view.store'; +import { selectors as colors } from './stores/colors.store'; + +const slices = { + runtime: (state: State) => state.runtime, + podcast: (state: State) => state.podcast, + player: (state: State) => state.player, + playbar: (state: State) => state.playbar, + search: (state: State) => state.search, + episodes: (state: State) => state.episodes, + subscribeButton: (state: State) => state.subscribeButton, + router: (state: State) => state.router, + contributors: (state: State) => state.contributors, + view: (state: State) => state.view, + colors: (state: State) => state.colors +}; + +// runtime +const cacheKey = createSelector(slices.runtime, runtime.cacheKey); + +// podcast +const feed = createSelector(slices.podcast, podcast.feed); + +const playtime = createSelector(slices.player, player.playtime); + +const duration = createSelector(slices.player, player.duration); + +const showTitle = createSelector(slices.player, player.showTitle); +const showPoster = createSelector(slices.player, player.showPoster); + +const episodeTitle = createSelector(slices.player, player.episodeTitle); +const episodePoster = createSelector(slices.player, player.episodePoster); + +const playing = createSelector(slices.player, player.playing); +const currentEpisode = createSelector(slices.player, player.currentEpisode); + +const volume = createSelector(slices.player, player.volume); +const muted = createSelector(slices.player, player.muted); +const rate = createSelector(slices.player, player.rate); + +const playerGhostTime = createSelector(slices.player, player.ghostTime); + +// chapters +const chaptersList = createSelector(slices.player, player.chaptersList); +const chaptersNext = createSelector(slices.player, player.chaptersNext); +const chaptersPrevious = createSelector(slices.player, player.chaptersPrevious); +const chaptersCurrent = createSelector(slices.player, player.chaptersCurrent); +const chaptersTitle = createSelector(slices.player, player.chaptersTitle); +const chaptersImage = createSelector(slices.player, player.chaptersImage); + +// router +const base = createSelector(slices.router, router.base); + +const translation = (key: string, attr = {}) => ({ key, attr }); + +export default { + runtime: { + initialized: createSelector(slices.runtime, runtime.initialized), + locale: createSelector(slices.runtime, runtime.locale), + cacheKey, + buildDate: createSelector(slices.runtime, runtime.buildDate) + }, + podcast: { + show: createSelector(slices.podcast, podcast.show), + feed, + title: createSelector(slices.podcast, podcast.title), + poster: createSelector(slices.podcast, podcast.poster), + description: createSelector(slices.podcast, podcast.description), + summary: createSelector(slices.podcast, podcast.summary), + copyright: createSelector(slices.podcast, podcast.copyright), + mail: createSelector(slices.podcast, podcast.mail), + owner: createSelector(slices.podcast, podcast.owner) + }, + current: { + episode: currentEpisode + }, + episode: { + data: (id: number | string) => createSelector(slices.episodes, episodes.item(id)), + title: episodeTitle, + poster: episodePoster, + loaded: (id: string) => + createSelector(currentEpisode, (episodeId: string | null) => episodeId === id), + playing: (id: string) => + createSelector( + [currentEpisode, playing], + (episodeId: string | null, isPlaying: boolean) => episodeId === id && isPlaying + ) + }, + episodes: { + list: createSelector(slices.episodes, episodes.list) + }, + view: { + archive: { + episodes: createSelector(slices.view, view.archiveEpisodes), + page: createSelector(slices.view, view.archiveEpisodesPage) + }, + loading: createSelector(slices.view, view.loading) + }, + colors: { + values: createSelector(slices.colors, colors.colors), + initialized: createSelector(slices.colors, colors.initialized), + }, + show: { + poster: showPoster, + title: showTitle + }, + player: { + playtime, + duration, + playing, + livesync: createSelector(slices.player, player.livesync), + title: createSelector( + [episodeTitle, showTitle], + (episode: string | null, show: string | null) => episode || show || '' + ), + image: createSelector( + [episodePoster, showPoster], + (episode: string | null, show: string | null) => episode || show || '' + ), + quantiles: createSelector(slices.player, player.quantiles), + buffer: createSelector(slices.player, player.buffer), + ghost: { + time: playerGhostTime, + active: createSelector(slices.player, player.ghostActive), + chapter: createSelector( + [chaptersList, playerGhostTime], + (chapters: PodloveWebPlayerChapter[], ghostPlaytime: number | null) => + currentChapterByPlaytime(chapters || [], ghostPlaytime || 0) + ) + }, + media: createSelector(slices.player, player.media), + audio: { + muted, + volume, + rate + }, + chapters: { + list: chaptersList, + next: chaptersNext, + previous: chaptersPrevious, + current: chaptersCurrent, + title: chaptersTitle, + image: chaptersImage + } + }, + playbar: { + active: createSelector(slices.playbar, playbar.active), + button: createSelector(slices.playbar, playbar.button), + followContent: createSelector(slices.playbar, playbar.followContent), + volume: (state: State) => { + if (muted(state)) { + return 'speaker-0'; + } + + if (volume(state) >= 0.75) { + return 'speaker-75'; + } + + if (volume(state) >= 0.5) { + return 'speaker-50'; + } + + if (volume(state) > 0) { + return 'speaker-25'; + } + + return 'speaker-0'; + }, + rate: (state: State) => { + if (rate(state) <= 0.5) { + return 'speed-050'; + } + + if (rate(state) <= 0.75) { + return 'speed-075'; + } + + if (rate(state) <= 1) { + return 'speed-100'; + } + + if (rate(state) <= 1.25) { + return 'speed-125'; + } + + if (rate(state) <= 1.5) { + return 'speed-150'; + } + + if (rate(state) <= 1.75) { + return 'speed-175'; + } + + return 'speed-200'; + }, + chapters: createSelector(slices.playbar, playbar.chapters) + }, + subscribeButton: { + visible: createSelector(slices.subscribeButton, subscribeButton.visible) + }, + search: { + query: createSelector(slices.search, search.query), + visible: createSelector(slices.search, search.visible), + initialized: createSelector(slices.search, search.initialized), + contributors: createSelector(slices.search, search.contributors), + episodes: createSelector(slices.search, search.episodes), + transcripts: createSelector(slices.search, search.transcripts), + results: createSelector(slices.search, search.results), + hasResults: createSelector(slices.search, search.hasResults), + selectedResult: createSelector(slices.search, search.selectedResult), + episodesInitialized: createSelector(slices.search, search.episodesInitialized), + transcriptsInitialized: createSelector(slices.search, search.transcriptsInitialized) + }, + contributors: { + item: (id: string) => createSelector(slices.contributors, contributors.item(id)), + list: createSelector(slices.contributors, contributors.list) + }, + router: { + base, + episodeId: createSelector(slices.router, router.episodeId), + index: createSelector([base, feed], (...args) => args.filter(Boolean).join('/')), + episode: (episodeId: string) => + createSelector([base, feed], (...args) => + [...args, 'episode', episodeId].filter(Boolean).join('/') + ) + }, + a11y: { + chapterNext: (state: State) => { + const next = chaptersNext(state); + + if (!next) { + return translation('A11Y.PLAYER_CHAPTER_END'); + } + + return translation('A11Y.PLAYER_CHAPTER_NEXT', next); + }, + chapterPrevious: (state: State) => { + const previous = chaptersPrevious(state); + const current = chaptersCurrent(state); + + if (!previous) { + return translation('A11Y.PLAYER_CHAPTER_START'); + } + + if (Number(current?.start) + 2000 > playtime(state)) { + return translation('A11Y.PLAYER_CHAPTER_CURRENT', current); + } + + return translation('A11Y.PLAYER_CHAPTER_PREVIOUS', previous); + }, + progressBar: () => { + return translation('A11Y.PROGRESSBAR_INPUT'); + }, + stepperBackwards: () => { + return translation('A11Y.PLAYER_STEPPER_BACK', { seconds: 15 }); + }, + stepperForward: () => { + return translation('A11Y.PLAYER_STEPPER_FORWARD', { seconds: 30 }); + }, + playButtonPause: (state: State) => { + return translation('A11Y.PLAYER_PAUSE', chaptersCurrent(state)); + }, + playButtonDuration: (state: State) => { + const time = { + duration: duration(state), + playtime: playtime(state) + }; + + return translation('A11Y.PLAYER_START', { + hours: calcHours(time.playtime > 0 ? time.playtime : time.duration), + minutes: calcMinutes(time.playtime > 0 ? time.playtime : time.duration), + seconds: calcSeconds(time.playtime > 0 ? playtime : time.duration) + }); + }, + playButtonReplay: () => { + return translation('A11Y.PLAYER_LOADING'); + }, + playButtonPlay: () => { + return translation('A11Y.PLAYER_PLAY'); + }, + playButtonError: () => { + return translation('A11Y.PLAYER_ERROR'); + } + } +}; diff --git a/apps/page/src/logic/store/state.ts b/apps/page/src/logic/store/state.ts new file mode 100644 index 000000000..075dfcaab --- /dev/null +++ b/apps/page/src/logic/store/state.ts @@ -0,0 +1,27 @@ +import { type State as runtime } from './stores/runtime.store'; +import { type State as podcast } from './stores/podcast.store'; +import { type State as action } from './stores/action.store'; +import { type State as episodes } from './stores/episodes.store'; +import { type State as playbar } from './stores/playbar.store'; +import { type State as player } from './stores/player.store'; +import { type State as search } from './stores/search.store'; +import { type State as subscribeButton } from './stores/subscribe-button.store'; +import { type State as router } from './stores/router.store'; +import { type State as contributors } from './stores/contributors.store'; +import { type State as view } from './stores/view.store'; +import { type State as colors } from './stores/colors.store'; + +export default interface State { + runtime: runtime, + podcast: podcast, + action: action, + episodes: episodes, + playbar: playbar, + search: search, + subscribeButton: subscribeButton, + player: player, + router: router, + contributors: contributors, + view: view, + colors: colors +}; diff --git a/apps/page/src/logic/store/stores/action.store.ts b/apps/page/src/logic/store/stores/action.store.ts new file mode 100644 index 000000000..7e4fd63fa --- /dev/null +++ b/apps/page/src/logic/store/stores/action.store.ts @@ -0,0 +1,10 @@ +import type { Action } from 'redux'; + +export const INITIAL_STATE = { + type: null, + payload: null +}; + +export type State = Action; + +export const reducer = (_state = INITIAL_STATE, action: Action) => action; diff --git a/apps/page/src/logic/store/stores/colors.store.ts b/apps/page/src/logic/store/stores/colors.store.ts new file mode 100644 index 000000000..2f33a2c22 --- /dev/null +++ b/apps/page/src/logic/store/stores/colors.store.ts @@ -0,0 +1,71 @@ +import { identity } from 'lodash-es'; +import { handleActions, createAction, type Action } from 'redux-actions'; +import type { ColorTokens } from '../../../types/color.types'; + +export interface Colors { + initialized: boolean; + primary: ColorTokens; + complementary: ColorTokens; + gray: ColorTokens; +} + +export type setColorsPayload = Partial; + +export const actions = { + setColors: createAction('COLORS_SET') +}; + +export type State = Colors; + +export const reducer = handleActions( + { + [actions.setColors.toString()]: (state, { payload }: Action) => ({ + ...state, + initialized: true, + ...payload + }) + }, + { + initialized: false, + primary: { + 100: [242, 248, 251], + 200: [217, 235, 248], + 300: [176, 215, 244], + 400: [135, 192, 234], + 500: [103, 168, 219], + 600: [71, 138, 186], + 700: [33, 97, 144], + 800: [12, 65, 104], + 900: [4, 41, 68] + }, + + complementary: { + 100: [255, 248, 246], + 200: [254, 236, 222], + 300: [255, 231, 173], + 400: [249, 155, 125], + 500: [249, 119, 81], + 600: [202, 85, 57], + 700: [157, 65, 39], + 800: [118, 52, 28], + 900: [52, 20, 1] + }, + + gray: { + 100: [255, 255, 255], + 200: [230, 231, 232], + 300: [205, 209, 215], + 400: [154, 165, 172], + 500: [115, 127, 135], + 600: [86, 97, 104], + 700: [61, 70, 85], + 800: [34, 38, 44], + 900: [7, 8, 9] + } + } +); + +export const selectors = { + colors: identity, + initialized: (state: State) => state.initialized +}; diff --git a/apps/page/src/logic/store/stores/contributors.store.ts b/apps/page/src/logic/store/stores/contributors.store.ts new file mode 100644 index 000000000..35903e909 --- /dev/null +++ b/apps/page/src/logic/store/stores/contributors.store.ts @@ -0,0 +1,31 @@ +import { handleActions, type Action } from 'redux-actions'; + +import { flattenDeep, get } from 'lodash-es'; + +import { actions as lifecycleActions, type dataFetchedPayload } from './runtime.store' +import type { Episode, Person } from '../../../types/feed.types'; + +export interface State { + [key: string]: Person; +} + +export type episodeLoadPayload = Episode[]; +export type requestEpisodePayload = string | string[]; + + +export const reducer = handleActions({ + [lifecycleActions.dataFetched.toString()]: (state, { payload }: Action) => { + return flattenDeep(payload.data.episodes.map(episode => episode.contributors || [])).reduce((result, contributor) => ({ + ...result, + [contributor.id]: contributor + }), state) + }, + + }, + {} +); + +export const selectors = { + item: (id: string | number) => (state: State) => get(state, id, {}) as Person, + list: (state: State) => Object.values(state) +}; diff --git a/apps/page/src/logic/store/stores/episodes.store.ts b/apps/page/src/logic/store/stores/episodes.store.ts new file mode 100644 index 000000000..b9082dfd3 --- /dev/null +++ b/apps/page/src/logic/store/stores/episodes.store.ts @@ -0,0 +1,53 @@ +import { handleActions, createAction, type Action } from 'redux-actions'; + +import { get } from 'lodash-es'; + +import { actions as lifecycleActions, type dataFetchedPayload } from './runtime.store' +import type { Episode } from '../../../types/feed.types'; + +export interface State { + [key: string]: Episode; +} + +type addEpisodePayload = Episode +export type episodeLoadPayload = Episode[]; +type updateEpisodePayload = {id: string} & Partial +export type requestEpisodePayload = string | string[]; + +export const actions = { + addEpisode: createAction('EPISODES_ADD'), + updateEpisode: createAction('EPISODES_UPDATE'), + requestEpisode: createAction('EPISODE_REQUEST'), + episodeLoad: createAction('EPISODE_LOAD') +}; + +export const reducer = handleActions({ + [lifecycleActions.dataFetched.toString()]: (state, { payload }: Action) => { + return payload.data.episodes.reduce((result, episode) => ({ + ...result, + [episode.id]: episode + }), state) + }, + [actions.addEpisode.toString()]: (state: State, { payload }: Action) => ({ + ...state, + [payload.id]: payload + }), + [actions.updateEpisode.toString()]: (state: State, { payload }: Action) => ({ + ...state, + [payload.id]: { + ...state[payload.id], + ...payload + } + }), + [actions.episodeLoad.toString()]: (state: State, { payload }: Action) => payload.reduce((result, episode) => ({ + ...result, + [episode.id]: episode + }), state), + }, + {} +); + +export const selectors = { + item: (id: string | number) => (state: State) => get(state, id, {}) as Episode, + list: (state: State) => Object.values(state) +}; diff --git a/apps/page/src/logic/store/stores/playbar.store.ts b/apps/page/src/logic/store/stores/playbar.store.ts new file mode 100644 index 000000000..fc3c4348a --- /dev/null +++ b/apps/page/src/logic/store/stores/playbar.store.ts @@ -0,0 +1,66 @@ +import { handleActions, createAction } from 'redux-actions'; +import * as player from './player.store'; + +export const actions = { + play: createAction('PLAYBAR_PLAY'), + pause: createAction('PLAYBAR_PAUSE'), + loading: createAction('PLAYBAR_LOADING'), + restart: createAction('PLAYBAR_RESTART'), + toggleMute: createAction('TOGGLE_MUTE'), + nextRate: createAction('NEXT_RATE'), + toggleFollowContent: createAction('FOLLOW_CONTENT'), + toggleChaptersOverlay: createAction('TOGGLE_CHAPTERS') +}; + +export interface State { + active: boolean; + button: 'play' | 'pause' | 'loading' | 'restart'; + followContent: boolean; + chapters: boolean; +} + +export const reducer = handleActions( + { + [actions.play.toString()]: (state) => ({ + ...state, + button: 'pause' + }), + [actions.pause.toString()]: (state) => ({ + ...state, + button: 'play' + }), + [actions.loading.toString()]: (state) => ({ + ...state, + button: 'loading' + }), + [actions.restart.toString()]: (state) => ({ + ...state, + button: 'restart' + }), + [actions.toggleFollowContent.toString()]: (state) => ({ + ...state, + followContent: !state.followContent + }), + [actions.toggleChaptersOverlay.toString()]: (state) => ({ + ...state, + chapters: !state.chapters + }), + [player.actions.selectEpisode.toString()]: (state) => ({ + ...state, + active: true + }) + }, + { + active: false, + button: 'play', + followContent: false, + chapters: false + } +); + +export const selectors = { + active: (state: State) => state.active, + button: (state: State) => state.button, + followContent: (state: State) => state.followContent, + chapters: (state: State) => state.chapters +}; diff --git a/apps/page/src/logic/store/stores/player.store.ts b/apps/page/src/logic/store/stores/player.store.ts new file mode 100644 index 000000000..87dc6162a --- /dev/null +++ b/apps/page/src/logic/store/stores/player.store.ts @@ -0,0 +1,98 @@ +import { handleActions, createAction, type Action } from 'redux-actions' +import { createSelector } from 'reselect'; + +import * as driver from '@podlove/player-state/driver'; +import * as show from '@podlove/player-state/show'; +import * as media from '@podlove/player-state/media'; +import * as timepiece from '@podlove/player-state/timepiece'; +import * as episode from '@podlove/player-state/episode'; +import * as audio from '@podlove/player-state/audio'; +import * as network from '@podlove/player-state/network'; +import * as ghost from '@podlove/player-state/ghost'; +import * as chapters from '@podlove/player-state/chapters'; +import * as quantiles from '@podlove/player-state/quantiles'; + +type selectEpisodePayload = string; + +export interface playEpisodePayload { + id: string; + playtime?: number; +} + +export type restoreEpisodePayload = playEpisodePayload; + +export const actions = { + playEpisode: createAction('EPISODE_PLAY'), + pauseEpisode: createAction('EPISODE_PAUSE'), + selectEpisode: createAction('EPISODE_SELECT'), + restoreEpisode: createAction('EPISODE_RESTORE'), +} + +interface EpisodeState { + episode: string | null; +} + +export interface State { + quantiles: quantiles.State, + chapters: chapters.State, + ghost: ghost.State, + network: network.State, + driver: driver.State, + show: show.State, + media: media.State, + timepiece: timepiece.State, + episode: episode.State, + audio: audio.State, + current: EpisodeState +} + +export const reducer = handleActions( + { + [actions.selectEpisode.toString()]: (state, { payload }: Action) => ({ + ...state, + episode: payload + }) + }, + { + episode: null + } +); + +const getTimePiece = (input: State) => input.timepiece; +const getShow = (input: State) => input.show; +const getEpisode = (input: State) => input.episode; +const getDriver = (input: State) => input.driver; +const getCurrent = (input: State) => input.current; +const getAudio = (input: State) => input.audio; +const getGhost = (input: State) => input.ghost; +const getChapters = (input: State) => input.chapters; +const getQuantiles = (input: State) => input.quantiles; +const getNetwork = (input: State) => input.network; +const getMedia = (input: State) => input.media; + +export const selectors = { + episode: (state: State) => state.episode, + playtime: createSelector(getTimePiece, timepiece.selectors.playtime), + livesync: createSelector(getTimePiece, timepiece.selectors.livesync), + duration: createSelector(getTimePiece, timepiece.selectors.duration), + showTitle: createSelector(getShow, show.selectors.title), + showPoster: createSelector(getShow, show.selectors.poster), + episodeTitle: createSelector(getEpisode, episode.selectors.title), + episodePoster: createSelector(getEpisode, episode.selectors.poster), + playing: createSelector(getDriver, driver.selectors.playing), + currentEpisode: createSelector(getCurrent, (state: EpisodeState) => state.episode), + volume: createSelector(getAudio, audio.selectors.volume), + muted: createSelector(getAudio, audio.selectors.muted), + rate: createSelector(getAudio, audio.selectors.rate), + ghostTime: createSelector(getGhost, ghost.selectors.time), + ghostActive: createSelector(getGhost, ghost.selectors.active), + chaptersList: createSelector(getChapters, chapters.selectors.list), + chaptersNext: createSelector(getChapters, chapters.selectors.next), + chaptersPrevious: createSelector(getChapters, chapters.selectors.previous), + chaptersCurrent: createSelector(getChapters, chapters.selectors.current), + chaptersTitle: createSelector(getChapters, chapters.selectors.title), + chaptersImage: createSelector(getChapters, chapters.selectors.image), + quantiles: createSelector(getQuantiles, quantiles.selectors.quantiles), + buffer: createSelector(getNetwork, network.selectors.buffer), + media: createSelector(getMedia, media.selectors.media), +} diff --git a/apps/page/src/logic/store/stores/podcast.store.ts b/apps/page/src/logic/store/stores/podcast.store.ts new file mode 100644 index 000000000..d9ae764ed --- /dev/null +++ b/apps/page/src/logic/store/stores/podcast.store.ts @@ -0,0 +1,63 @@ +import { handleActions, type Action } from 'redux-actions'; +import { get } from 'lodash-es'; +import { actions as lifecycleActions, type dataFetchedPayload, type initializeAppPayload } from './runtime.store' +import type { Author, Show } from '../../../types/feed.types'; + +export interface State { + feed: string | null; + title: string | null; + poster: string | null; + description: string | null; + summary: string | null; + author: Author; +} + +export const reducer = handleActions({ + [lifecycleActions.dataFetched.toString()]: (state, { payload }: Action) => ({ + ...state, + title: get(payload, ['data', 'show', 'title'], null), + poster: get(payload, ['data', 'show', 'poster'], null), + description: get(payload, ['data', 'show', 'description'], null), + summary: get(payload, ['data', 'show', 'summary'], null), + author: get(payload, ['data', 'author'], { + owner: null, + copyright: null, + name: null, + mail: null, + }), + }), + [lifecycleActions.initializeApp.toString()]: (state, { payload }: Action) => ({ + ...state, + feed: payload.feed + }) +}, { + feed: null, + title: null, + poster: null, + description: null, + summary: null, + author: { + owner: null, + copyright: null, + name: null, + mail: null, + } +}); + +export const selectors = { + show: (state: State): Show => ({ + title: get(state, 'title') || '', + description: get(state, 'description') || '', + link: get(state, 'link') || '', + poster: get(state, 'poster') || '', + summary: get(state, 'summary') || '', + }), + feed: (state: State) => get(state, 'feed'), + title: (state: State) => get(state, 'title'), + poster: (state: State) => get(state, 'poster'), + description: (state: State) => get(state, 'description'), + summary: (state: State) => get(state, 'summary'), + copyright: (state: State) => get(state, ['author', 'copyright']), + owner: (state: State) => get(state, ['author', 'owner']), + mail: (state: State) => get(state, ['author', 'mail']) +} diff --git a/apps/page/src/logic/store/stores/router.store.ts b/apps/page/src/logic/store/stores/router.store.ts new file mode 100644 index 000000000..8d72b6338 --- /dev/null +++ b/apps/page/src/logic/store/stores/router.store.ts @@ -0,0 +1,61 @@ + +import { last } from 'lodash-es'; +import { createAction, handleActions, type Action } from 'redux-actions'; + +export interface State { + path: string[]; +} + +export type navigatePayload = string[]; +export type setRoutePayload = string[]; +export interface episodePagePayload { + base: string; + episodeId: string; +}; + +export const actions = { + navigate: createAction('ROUTE_NAVIGATE'), + setRoute: createAction('ROUTE_SET'), + episodePage: createAction('ROUTE_EPISODE_PAGE'), +}; + +const updatePath = (state: State, { payload }: Action) => ({ + ...state, + path: payload +}); + +export const reducer = handleActions( + { + [actions.navigate.toString()]: updatePath, + [actions.setRoute.toString()]: updatePath + }, + { path: [] } +); + +export const selectors = { + episodeId: (state: State): string | null => { + if (!state.path.includes('episode')) { + return null + } + + return last(state.path) || null; + }, + segment: (state: State) => { + switch (true) { + case state.path.includes('episode'): + return 'episode'; + + default: + return 'index'; + } + }, + base: (state: State): string | null => { + switch (true) { + case state.path.includes('feed'): + return '/feed'; + + default: + return null; + } + } +}; diff --git a/apps/page/src/logic/store/stores/runtime.store.ts b/apps/page/src/logic/store/stores/runtime.store.ts new file mode 100644 index 000000000..72d2dce96 --- /dev/null +++ b/apps/page/src/logic/store/stores/runtime.store.ts @@ -0,0 +1,52 @@ +import { get } from 'lodash-es'; +import { createAction, handleActions, type Action } from 'redux-actions'; +import type { Podcast } from '../../../types/feed.types.js'; + +export interface State { + initialized: boolean; + locale: string; + cacheKey : string | null; + buildDate: string | null; +} + +export interface initializeAppPayload { + feed: string; + locale: string; + episodeId?: number +} + +export type dataFetchedPayload = { + data: Podcast, + cacheKey: string | null; +}; + +export const actions = { + initializeApp: createAction('INITIALIZE'), + initializeEpisode: createAction('INITIALIZE_EPISODE'), + dataFetched: createAction('DATA_FETCHED') +}; + +export const reducer = handleActions( + { + [actions.initializeApp.toString()]: (state, { payload }: Action) => ({ + ...state, + initialized: false, + locale: payload.locale + }), + [actions.dataFetched.toString()]: (state, { payload }: Action) => ({ + ...state, + initialized: true, + buildDate: get(payload, ['data', 'buildDate'], null), + cacheKey: get(payload, 'cacheKey', null) + }), + + }, + { initialized: false, locale: 'en-US', cacheKey: null, buildDate: null } +); + +export const selectors = { + initialized: (state: State) => state.initialized, + locale: (state: State) => state.locale, + cacheKey: (state: State) => state.cacheKey, + buildDate: (state: State) => state.buildDate, +}; diff --git a/apps/page/src/logic/store/stores/search.store.ts b/apps/page/src/logic/store/stores/search.store.ts new file mode 100644 index 000000000..5e5a0ab71 --- /dev/null +++ b/apps/page/src/logic/store/stores/search.store.ts @@ -0,0 +1,133 @@ +import { handleActions, createAction, type Action } from 'redux-actions'; +import type { Person } from '../../../types/feed.types'; + +type showSearchActionPayload = void; +type hideSearchActionPayload = void; +type loadActionPayload = void; +type initializedSearchPayload = 'episodes' | 'contributors' | 'transcripts'; +export type searchActionPayload = string; +type setResultsPayload = number; +type selectSearchResultPayload = string | null; +export type selectEpisodePayload = number; + +export interface EpisodeResult { + id: number; + title: string; + description: string; + episodeId: number; +} + +export interface TranscriptResult { + id: string; text: string; speaker: string; episodeId: number; episodeTitle: string; +} + +type setEpisodeResultsPayload = EpisodeResult[]; +type setTranscriptsResultsPayload = TranscriptResult[]; +type setContributorsPayload = Person[]; + +export const actions = { + initialize: createAction('SEARCH_INITIALIZED'), + show: createAction('SEARCH_SHOW'), + hide: createAction('SEARCH_HIDE'), + search: createAction('SEARCH_QUERY'), + setEpisodeResults: createAction('SEARCH_RESULTS_EPISODE'), + setTranscriptsResults: createAction('SEARCH_RESULTS_TRANSCRIPT'), + setContributorsResults: createAction('SEARCH_RESULTS_CONTRIBUTOR'), + selectSearchResult: createAction('SEARCH_SELECT_RESULT'), + load: createAction('SEARCH_LOADING'), + setResults: createAction('SEARCH_RESULTS'), +} + +export interface State { + visible: boolean; + hasResults: boolean; + query: string; + episodes: EpisodeResult[]; + contributors: Person[]; + transcripts: TranscriptResult[]; + selectedResult: null | string; + initialized: { + episodes: boolean; + contributors: boolean; + transcripts: boolean; + }; +} + +export const reducer = handleActions( + { + [actions.show.toString()]: (state) => ({ + ...state, + visible: true, + hasResults: false, + query: '', + episodes: [], + contributors: [], + transcripts: [], + selectedResult: null + }), + [actions.hide.toString()]: (state) => ({ + ...state, + visible: false + }), + [actions.search.toString()]: (state, { payload }: Action) => ({ + ...state, + query: payload + }), + [actions.setEpisodeResults.toString()]: (state, { payload }: Action) => ({ + ...state, + episodes: payload + }), + [actions.setTranscriptsResults.toString()]: (state, { payload }: Action) => ({ + ...state, + transcripts: payload + }), + [actions.setContributorsResults.toString()]: (state, { payload }: Action) => ({ + ...state, + contributors: payload + }), + [actions.setResults.toString()]: (state, { payload }: Action) => ({ + ...state, + hasResults: payload > 0 + }), + [actions.initialize.toString()]: (state, { payload }: Action) => ({ + ...state, + initialized: { + ...state.initialized, + [payload]: true + }, + loading: false + }), + [actions.selectSearchResult.toString()]: (state, { payload }: Action) => ({ + ...state, + selectedResult: payload + }) + }, + { + visible: false, + hasResults: false, + query: '', + episodes: [], + contributors: [], + transcripts: [], + selectedResult: null, + initialized: { + episodes: false, + contributors: true, + transcripts: false, + } + } +) + +export const selectors = { + initialized: (state: State) => Object.values(state.initialized).every(Boolean), + episodesInitialized: (state: State) => state.initialized.episodes, + transcriptsInitialized: (state: State) => state.initialized.transcripts, + visible: (state: State) => state.visible, + query: (state: State) => state.query, + contributors: (state: State) => state.contributors, + episodes: (state: State) => state.episodes, + transcripts: (state: State) => state.transcripts, + hasResults: (state: State) => state.hasResults, + selectedResult: (state: State) => state.selectedResult, + results: (state: State) => [...state.contributors, ...state.episodes, ...state.transcripts] +} diff --git a/apps/page/src/logic/store/stores/subscribe-button.store.ts b/apps/page/src/logic/store/stores/subscribe-button.store.ts new file mode 100644 index 000000000..31cd8a2a4 --- /dev/null +++ b/apps/page/src/logic/store/stores/subscribe-button.store.ts @@ -0,0 +1,25 @@ +import { handleActions, createAction } from 'redux-actions'; + +export interface State { + visible: boolean; +} + +export const actions = { + toggleSubscribeOverlay: createAction('TOGGLE_SUBSCRIBE_OVERLAY') +}; + +export const reducer = handleActions( + { + [actions.toggleSubscribeOverlay.toString()]: (state) => ({ + ...state, + visible: !state.visible + }) + }, + { + visible: false + } +); + +export const selectors = { + visible: (state: State) => state.visible +}; diff --git a/apps/page/src/logic/store/stores/view.store.ts b/apps/page/src/logic/store/stores/view.store.ts new file mode 100644 index 000000000..547b1377f --- /dev/null +++ b/apps/page/src/logic/store/stores/view.store.ts @@ -0,0 +1,54 @@ +import { createAction, handleActions, type Action } from 'redux-actions'; +import { type dataFetchedPayload, actions as runtimeActions } from './runtime.store.js'; + +export interface State { + archive: { + episodes: number[]; + page: number; + }, + loading: boolean; +} + +export type archiveLoadMorePayload = void; +export type startLoadingPayload = void; +export type stopLoadingPayload = void; + +export const actions = { + archiveLoadMore: createAction('ARCHIVE_LOAD_MORE'), + startLoading: createAction('START_LOADING'), + stopLoading: createAction('STOP_LOADING'), +}; + +export const reducer = handleActions( + { + [runtimeActions.dataFetched.toString()]: (state, { payload }: Action) => ({ + ...state, + archive: { + ...state.archive, + episodes: payload.data.episodes.map(({ id }) => id) + } + }), + [actions.archiveLoadMore.toString()]: (state) => ({ + ...state, + archive: { + ...state.archive, + page: state.archive.page + 1 + } + }), + [actions.startLoading.toString()]: (state) => ({ + ...state, + loading: true + }), + [actions.stopLoading.toString()]: (state) => ({ + ...state, + loading: false + }) + }, + { archive: { episodes: [], page: 1 }, loading: false } +); + +export const selectors = { + archiveEpisodesPage: (state: State) => state.archive.page, + archiveEpisodes: (state: State) => state.archive.episodes, + loading: (state: State) => state.loading, +}; diff --git a/apps/page/src/logic/transformations/player.ts b/apps/page/src/logic/transformations/player.ts new file mode 100644 index 000000000..0346f3bac --- /dev/null +++ b/apps/page/src/logic/transformations/player.ts @@ -0,0 +1,24 @@ +import type { PodloveWebPlayerEpisode } from '@podlove/types'; +import { toHumanTime } from '@podlove/utils/time'; +import type { Episode, Show } from '../../types/feed.types'; + +export const toPlayerEpisode = (episode: Episode, show: Show): PodloveWebPlayerEpisode => ({ + version: 6, + show: { + link: show.link, + poster: show.poster, + subtitle: show.description, + title: show.title, + summary: show.summary + }, + title: episode.title || '', + subtitle: episode.subtitle || '', + summary: episode.description || '', + audio: episode.audio, + publicationDate: episode.publicationDate || '', + + duration: toHumanTime(episode.duration || 0), + poster: episode.poster || '', + link: episode.link || '', + chapters: episode.chapters +}); diff --git a/apps/page/src/middleware/caching.ts b/apps/page/src/middleware/caching.ts new file mode 100644 index 000000000..145a12866 --- /dev/null +++ b/apps/page/src/middleware/caching.ts @@ -0,0 +1,18 @@ +import { defineMiddleware } from "astro:middleware"; +import { store, selectors } from '../logic'; + +export const setEtag = defineMiddleware(async (_, next) => { + const cacheKey = selectors.runtime.cacheKey(store.getState()); + + if (!cacheKey) { + return next(); + } + + const response = await next(); + + if ((response as Response).headers) { + (response as Response).headers.set('ETag', `"${cacheKey}"`); + } + + return response; +}); diff --git a/apps/page/src/middleware/index.ts b/apps/page/src/middleware/index.ts new file mode 100644 index 000000000..b237dc863 --- /dev/null +++ b/apps/page/src/middleware/index.ts @@ -0,0 +1,10 @@ +import { sequence } from 'astro:middleware'; + +import { initializeStore } from './store'; +import { setEtag } from './caching'; +import { defineMiddlewareRouter } from './router' + +export const onRequest = defineMiddlewareRouter({ + '/feed/**': sequence(initializeStore, setEtag), + '/proxy**': sequence() +}) diff --git a/apps/page/src/middleware/router.ts b/apps/page/src/middleware/router.ts new file mode 100644 index 000000000..3d4d98ff2 --- /dev/null +++ b/apps/page/src/middleware/router.ts @@ -0,0 +1,13 @@ +import { defineMiddleware, sequence } from 'astro:middleware'; +import multimatch from 'multimatch'; + +export function defineMiddlewareRouter(router: Record) { + const entries = Object.entries(router); + return defineMiddleware((context, next) => + sequence( + ...entries + .filter(([path]) => multimatch(context.url.pathname, path).length > 0) + .map(([_, handler]) => handler) + )(context, next) + ); +} diff --git a/apps/page/src/middleware/store.ts b/apps/page/src/middleware/store.ts new file mode 100644 index 000000000..1167d90ff --- /dev/null +++ b/apps/page/src/middleware/store.ts @@ -0,0 +1,25 @@ +import { defineMiddleware } from 'astro:middleware'; +import { waitFor } from '@podlove/utils/promise'; +import { toInteger } from 'lodash-es'; +import { actions, store, selectors } from '../logic'; +import { getRequestHeader } from '../lib/middleware'; + +export const initializeStore = defineMiddleware(async ({ request, params }, next) => { + const locale = getRequestHeader(request, 'accept-language', 'en-US'); + const { feed, episodeId } = params; + + if (!feed) { + throw Error('Missing Feed Url'); + } + + store.dispatch(actions.lifecycle.initializeApp({ feed, locale, episodeId: toInteger(episodeId) })); + + await waitFor(() => { + const initialized = selectors.runtime.initialized(store.getState()); + return initialized ? true : undefined; + }, 10000).catch(() => { + throw Error('Request timed out'); + }); + + return next(); +}); diff --git a/apps/page/src/pages/feed/[...feed]/episode/[episodeId].astro b/apps/page/src/pages/feed/[...feed]/episode/[episodeId].astro new file mode 100644 index 000000000..157f2c38a --- /dev/null +++ b/apps/page/src/pages/feed/[...feed]/episode/[episodeId].astro @@ -0,0 +1,98 @@ +--- +import { createTimeline } from '@podlove/utils/transcripts' +import Layout from '../../../../layouts/Layout.astro'; +import Hero from '../../../../screens/episodes/Hero.vue' +import Navigation from '../../../../screens/episodes/Navigation.vue' +import Timeline from '../../../../features/timeline/Timeline.vue' + +import { store, selectors, actions } from '../../../../logic'; +import { useTranslations } from '../../../../i18n' +import type { PodloveWebPlayerTranscript } from '@podlove/types'; + +const { episodeId } = Astro.params; +const $t = useTranslations(); +const podcastTitle = selectors.podcast.title(store.getState()); +const episode = selectors.episode.data(episodeId as string)(store.getState()); + +store.dispatch(actions.router.episodePage({ base: 'feed', episodeId: episodeId as string })); +const timeline = [ + { start: 0, title: $t('TIMELINE.START') as string, type: 'marker' } as any, + ...createTimeline((episode.transcripts || []) as unknown as PodloveWebPlayerTranscript[], episode.chapters || [], episode.contributors || []), + { start: episode.duration, title: $t('TIMELINE.END') as string, type: 'marker' } as any, +]; +--- + + + { episodeId && + 0} discuss={false} /> + } +
+
+
+

+ { $t('EPISODE.SUMMARY') } +

+
+ { episode.description } +
+
+ { episode.content &&
+

+ { $t('EPISODE.SHOWNOTES') } +

+
+
} + { timeline.length > 0 &&
+

+ { $t('EPISODE.TIMELINE') } +

+ +
} +
+
+
+ + + diff --git a/apps/page/src/pages/feed/[...feed]/index.astro b/apps/page/src/pages/feed/[...feed]/index.astro new file mode 100644 index 000000000..1063a5f12 --- /dev/null +++ b/apps/page/src/pages/feed/[...feed]/index.astro @@ -0,0 +1,24 @@ +--- +import Layout from '../../../layouts/Layout.astro'; +import EpisodeList from '../../../screens/archive/List.vue'; +import { actions, selectors, store } from '../../../logic'; +import HeroIndex from '../../../screens/archive/Hero.vue'; +import LoadMore from '../../../screens/archive/LoadMore.vue'; + +const title = selectors.podcast.title(store.getState()); + +store.dispatch(actions.router.setRoute(['feed'])); +--- + + + +
+ +
+ + +
+ + diff --git a/apps/page/src/pages/manifest.json.ts b/apps/page/src/pages/manifest.json.ts new file mode 100644 index 000000000..f2a08c2d0 --- /dev/null +++ b/apps/page/src/pages/manifest.json.ts @@ -0,0 +1,32 @@ +import type { APIRoute } from 'astro'; +import { getRequestHeader } from '../lib/middleware'; + +export const GET: APIRoute = async ({ request }) => { + const lang = getRequestHeader(request, 'accept-language', 'en-US'); + + const manifest = { + name: 'PWA-DEMO', + short_name: 'PWA-DEMO', + lang, + start_url: '/', + // "display": "standalone", + // "theme_color": "#e30613", + // "background_color": "#ffffff", + // "icons": [ + // { + // "src": "pwa-demo.png", + // "sizes": "512x512", + // "type": "image\/png" + // }, + // { + // "src": "pwa-demo-smaller.png", + // "sizes": "192x192", + // "type": "image\/png" + // } + // ] + }; + + return new Response(JSON.stringify(manifest), { + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/apps/page/src/pages/proxy.ts b/apps/page/src/pages/proxy.ts new file mode 100644 index 000000000..2a15bd2a7 --- /dev/null +++ b/apps/page/src/pages/proxy.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = async ({ url }) => { + const requestUrl = url.searchParams.get('url'); + + if (!requestUrl) { + throw new Error('Missing url'); + } + + const response = await fetch(requestUrl).then(res => res.body); + + return new Response(response); +}; diff --git a/apps/page/src/screens/archive/Hero.vue b/apps/page/src/screens/archive/Hero.vue new file mode 100644 index 000000000..1f917fb29 --- /dev/null +++ b/apps/page/src/screens/archive/Hero.vue @@ -0,0 +1,56 @@ + + + diff --git a/apps/page/src/screens/archive/Item.vue b/apps/page/src/screens/archive/Item.vue new file mode 100644 index 000000000..9a1f9fbe4 --- /dev/null +++ b/apps/page/src/screens/archive/Item.vue @@ -0,0 +1,103 @@ + + + diff --git a/apps/page/src/screens/archive/List.vue b/apps/page/src/screens/archive/List.vue new file mode 100644 index 000000000..12aa7455a --- /dev/null +++ b/apps/page/src/screens/archive/List.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/page/src/screens/archive/LoadMore.vue b/apps/page/src/screens/archive/LoadMore.vue new file mode 100644 index 000000000..e142a947b --- /dev/null +++ b/apps/page/src/screens/archive/LoadMore.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/page/src/screens/episodes/Hero.vue b/apps/page/src/screens/episodes/Hero.vue new file mode 100644 index 000000000..061e650c9 --- /dev/null +++ b/apps/page/src/screens/episodes/Hero.vue @@ -0,0 +1,97 @@ + + + diff --git a/apps/page/src/screens/episodes/Navigation.vue b/apps/page/src/screens/episodes/Navigation.vue new file mode 100644 index 000000000..5f696ce52 --- /dev/null +++ b/apps/page/src/screens/episodes/Navigation.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/apps/page/src/types/color.types.ts b/apps/page/src/types/color.types.ts new file mode 100644 index 000000000..5ba815808 --- /dev/null +++ b/apps/page/src/types/color.types.ts @@ -0,0 +1,13 @@ +export type rgbColor = [number, number, number]; + +export interface ColorTokens { + 100: rgbColor; + 200: rgbColor; + 300: rgbColor; + 400: rgbColor; + 500: rgbColor; + 600: rgbColor; + 700: rgbColor; + 800: rgbColor; + 900: rgbColor; +} diff --git a/apps/page/src/types/feed.types.ts b/apps/page/src/types/feed.types.ts new file mode 100644 index 000000000..7bc967c77 --- /dev/null +++ b/apps/page/src/types/feed.types.ts @@ -0,0 +1,68 @@ +export interface Podcast { + etag: string | null; + buildDate: string | null; + show: Show; + episodes: Episode[]; + hosts: Person[]; + author: Author; +} + +export interface Transcript { + voice: string | null; + speaker: string | null; + start: number; + end: number; + text: string; +} + +export interface Show { + title: string; + description: string; + link: string; + poster: string; + summary: string; +} + +export interface Audio { + url: string; + size: string; + mimeType: string; +} + +export interface Episode { + id: number; + title: string | null; + publicationDate: string | null; + description: string | null; + subtitle: string | null; + link: string | null; + duration: number | null; + content: string | null; + contributors: Person[]; + poster: string; + chapters: Chapter[]; + transcripts: Transcript[]; + audio: Audio[]; +} + +export interface Person { + id: string; + name: string; + avatar?: string; +} + +export interface Chapter { + start: number | string | null; + duration?: number | null; + end?: number | null; + image?: string | null; + title?: string | null; + href?: string | null; +} + +export interface Author { + owner: string | null; + copyright: string | null; + name: string | null; + mail: string | null; +} diff --git a/apps/page/tailwind.config.cjs b/apps/page/tailwind.config.cjs new file mode 100644 index 000000000..c5edc626d --- /dev/null +++ b/apps/page/tailwind.config.cjs @@ -0,0 +1,91 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', '../../packages/components/src/**/*.vue'], + theme: { + colors: { + primary: { + 100: 'rgb(var(--primary-color-100))', + 200: 'rgb(var(--primary-color-200))', + 300: 'rgb(var(--primary-color-300))', + 400: 'rgb(var(--primary-color-400))', + 500: 'rgb(var(--primary-color-500))', + 600: 'rgb(var(--primary-color-600))', + 700: 'rgb(var(--primary-color-700))', + 800: 'rgb(var(--primary-color-900))', + 900: 'rgb(var(--primary-color-900))' + }, + complementary: { + 100: 'rgb(var(--complementary-color-100))', + 200: 'rgb(var(--complementary-color-200))', + 300: 'rgb(var(--complementary-color-300))', + 400: 'rgb(var(--complementary-color-400))', + 500: 'rgb(var(--complementary-color-500))', + 600: 'rgb(var(--complementary-color-600))', + 700: 'rgb(var(--complementary-color-700))', + 800: 'rgb(var(--complementary-color-800))', + 900: 'rgb(var(--complementary-color-900))' + }, + gray: { + 100: 'rgb(var(--gray-color-100))', + 200: 'rgb(var(--gray-color-200))', + 300: 'rgb(var(--gray-color-300))', + 400: 'rgb(var(--gray-color-400))', + 500: 'rgb(var(--gray-color-500))', + 600: 'rgb(var(--gray-color-600))', + 700: 'rgb(var(--gray-color-700))', + 800: 'rgb(var(--gray-color-800))', + 900: 'rgb(var(--gray-color-900))' + } + }, + screens: { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px' + }, + fontFamily: { + sans: [ + 'Roboto', + 'system-ui', + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + '"Helvetica Neue"', + 'Arial', + '"Noto Sans"', + 'sans-serif', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + '"Noto Color Emoji"' + ], + mono: [ + 'Roboto Mono', + 'Menlo', + 'Monaco', + 'Consolas', + '"Liberation Mono"', + '"Courier New"', + 'monospace' + ] + }, + inset: { + 0: 0, + auto: 'auto', + 100: '100%' + }, + extend: { + spacing: { + 96: '24rem', + 128: '32rem' + }, + width: { + app: '1024px' + } + } + }, + variants: { + visibility: ['responsive', 'hover', 'focus', 'group-hover'] + }, + plugins: [] +}; diff --git a/apps/page/tsconfig.json b/apps/page/tsconfig.json new file mode 100644 index 000000000..cc1ecc799 --- /dev/null +++ b/apps/page/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "preserve", + "resolveJsonModule": true + } +} diff --git a/apps/player/package.json b/apps/player/package.json index 4512493e8..1fa251844 100644 --- a/apps/player/package.json +++ b/apps/player/package.json @@ -28,18 +28,17 @@ "@podlove/subscribe-button": "workspace:*", "@podlove/types": "workspace:*", "@podlove/utils": "workspace:*", - "core-js": "3.15.2", "date-fns": "2.16.1", "debounce": "2.0.0", - "farbraum": "0.1.4", + "farbraum": "0.2.1", "hashcode": "1.0.3", "ramda": "0.29.0", "redux": "4.2.0", "redux-vuex": "3.0.0", "reselect": "4.1.8", "tailwindcss": "3.2.4", - "vue": "3.2.41", - "vue-i18n": "9.2.2" + "vue": "3.4.31", + "vue-i18n": "9.13.1" }, "devDependencies": { "@types/node": "20.8.4", diff --git a/apps/player/src/components/play-button/PlayButton.vue b/apps/player/src/components/play-button/PlayButton.vue index 07d85fcc7..4649a53ba 100644 --- a/apps/player/src/components/play-button/PlayButton.vue +++ b/apps/player/src/components/play-button/PlayButton.vue @@ -1,7 +1,7 @@ diff --git a/apps/player/src/store/index.ts b/apps/player/src/store/index.ts index e0963bc57..b68df3e34 100644 --- a/apps/player/src/store/index.ts +++ b/apps/player/src/store/index.ts @@ -56,7 +56,8 @@ sagas.run( selectMedia: selectors.media, selectPlaytime: selectors.playtime, selectPoster: selectors.driver.image, - selectTitle: selectors.driver.title + selectTitle: selectors.driver.title, + mountPoint: document.body }), transcriptsSaga({ selectChapters: selectors.chapters.list, diff --git a/apps/subscribe-button/index.html b/apps/subscribe-button/index.html index cbf0fa2f7..7e3e68c6b 100644 --- a/apps/subscribe-button/index.html +++ b/apps/subscribe-button/index.html @@ -20,7 +20,6 @@ const store = event.detail; document.getElementById('show-button').addEventListener('click', () => { - console.log('call!') store.dispatch({ type: 'BUTTON_SHOW_OVERLAY' }); }); }); diff --git a/apps/subscribe-button/package.json b/apps/subscribe-button/package.json index b4c4dadfa..7267ffdc4 100644 --- a/apps/subscribe-button/package.json +++ b/apps/subscribe-button/package.json @@ -27,8 +27,8 @@ "redux-vuex": "3.0.2", "reselect": "4.1.8", "tailwindcss": "3.2.4", - "vue": "3.2.41", - "vue-i18n": "9.2.2" + "vue": "3.4.31", + "vue-i18n": "9.13.1" }, "devDependencies": { "@fullhuman/postcss-purgecss": "5.0.0", diff --git a/devbox.lock b/devbox.lock index 7140c199a..53a906db6 100644 --- a/devbox.lock +++ b/devbox.lock @@ -17,6 +17,7 @@ }, "nodejs@20.5.1": { "last_modified": "2023-09-06T20:35:33Z", + "plugin_version": "0.0.2", "resolved": "github:NixOS/nixpkgs/efd23a1c9ae8c574e2ca923c2b2dc336797f4cc4#nodejs_20", "source": "devbox-search", "version": "20.5.1", diff --git a/packages/clients/package.json b/packages/clients/package.json index d9a90e47e..e0ffa469b 100644 --- a/packages/clients/package.json +++ b/packages/clients/package.json @@ -14,6 +14,9 @@ ], "author": "Alexander Heimbuch ", "license": "MIT", + "dependencies": { + "lodash-es": "4.17.21" + }, "devDependencies": { "@typescript-eslint/eslint-plugin": "6.12.0", "@typescript-eslint/parser": "6.12.0", diff --git a/packages/clients/src/apple-podcasts/index.ts b/packages/clients/src/apple-podcasts/index.ts index f2e652db3..527e6d4ac 100644 --- a/packages/clients/src/apple-podcasts/index.ts +++ b/packages/clients/src/apple-podcasts/index.ts @@ -1,6 +1,6 @@ -import { type, platform, client } from '../types' -import { removeProtocol } from '../helper' -import icon from './icon.svg' +import { type, platform, client } from '../types.js'; +import { removeProtocol } from '../helper.js'; +import icon from './icon.svg'; export default [ client({ @@ -26,4 +26,4 @@ export default [ type: type.service, icon }) -] +]; diff --git a/packages/clients/src/beyond-pod/index.ts b/packages/clients/src/beyond-pod/index.ts index acf740428..5f6874a27 100644 --- a/packages/clients/src/beyond-pod/index.ts +++ b/packages/clients/src/beyond-pod/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/castbox/index.ts b/packages/clients/src/castbox/index.ts index eb0404d32..90966a4a6 100644 --- a/packages/clients/src/castbox/index.ts +++ b/packages/clients/src/castbox/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/castro/index.ts b/packages/clients/src/castro/index.ts index 5d8350669..00c82c56d 100644 --- a/packages/clients/src/castro/index.ts +++ b/packages/clients/src/castro/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/clementine/index.ts b/packages/clients/src/clementine/index.ts index 8201bb705..383006772 100644 --- a/packages/clients/src/clementine/index.ts +++ b/packages/clients/src/clementine/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/deezer/index.ts b/packages/clients/src/deezer/index.ts index c181a61eb..b96e16a71 100644 --- a/packages/clients/src/deezer/index.ts +++ b/packages/clients/src/deezer/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/downcast/index.ts b/packages/clients/src/downcast/index.ts index c7943fb0d..296b6e5f5 100644 --- a/packages/clients/src/downcast/index.ts +++ b/packages/clients/src/downcast/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/google-podcasts/index.ts b/packages/clients/src/google-podcasts/index.ts index fd9d0a581..0b137dbf0 100644 --- a/packages/clients/src/google-podcasts/index.ts +++ b/packages/clients/src/google-podcasts/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ @@ -20,7 +20,7 @@ export default [ }), client({ title: 'Google Podcasts', - scheme: (feed) => `https://podcasts.google.com/?feed=${btoa(feed)}`, + scheme: (feed) => `https://podcasts.google.com/?feed=${window.btoa(feed)}`, platform: platform.web, type: type.service, icon diff --git a/packages/clients/src/gpodder/index.ts b/packages/clients/src/gpodder/index.ts index 2e43b56ec..a778ca019 100644 --- a/packages/clients/src/gpodder/index.ts +++ b/packages/clients/src/gpodder/index.ts @@ -1,6 +1,6 @@ -import { type, platform, client } from '../types' -import { removeHttps } from '../helper' -import icon from './icon.svg' +import { type, platform, client } from '../types.js'; +import { removeHttps } from '../helper.js'; +import icon from './icon.svg'; export default [ client({ @@ -27,4 +27,4 @@ export default [ type: type.app, icon }) -] +]; diff --git a/packages/clients/src/i-catcher/index.ts b/packages/clients/src/i-catcher/index.ts index 2327d0edb..cfa0ea38f 100644 --- a/packages/clients/src/i-catcher/index.ts +++ b/packages/clients/src/i-catcher/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/index.ts b/packages/clients/src/index.ts index 5733a7289..ac3b7d400 100644 --- a/packages/clients/src/index.ts +++ b/packages/clients/src/index.ts @@ -1,31 +1,31 @@ -import { PodcastClient, PodcastClientType, PodcastPlatform } from './types'; +import { PodcastClient, PodcastClientType, PodcastPlatform } from './types.js'; -import antennaPod from './antenna-pod'; -import applePodcasts from './apple-podcasts'; -import beyondPod from './beyond-pod'; -import castro from './castro'; -import clementine from './clementine'; -import deezer from './deezer'; -import downcast from './downcast'; -import googlePodcasts from './google-podcasts'; -import gpodder from './gpodder'; -import iCatcher from './i-catcher'; -import instacast from './instacast'; -import overcast from './overcast'; -import playerFm from './player-fm'; -import pocketCasts from './pocket-casts'; -import podcastAddict from './podcast-addict'; -import podcastRepublic from './podcast-republic'; -import podcat from './podcat'; -import podscout from './podscout'; -import procast from './procast'; -import rss from './rss'; -import rssRadio from './rss-radio'; -import soundcloud from './soundcloud'; -import spotify from './spotify'; -import stitcher from './stitcher'; -import youtube from './youtube'; -import castbox from './castbox'; +import antennaPod from './antenna-pod/index.js'; +import applePodcasts from './apple-podcasts/index.js'; +import beyondPod from './beyond-pod/index.js'; +import castro from './castro/index.js'; +import clementine from './clementine/index.js'; +import deezer from './deezer/index.js'; +import downcast from './downcast/index.js'; +import googlePodcasts from './google-podcasts/index.js'; +import gpodder from './gpodder/index.js'; +import iCatcher from './i-catcher/index.js'; +import instacast from './instacast/index.js'; +import overcast from './overcast/index.js'; +import playerFm from './player-fm/index.js'; +import pocketCasts from './pocket-casts/index.js'; +import podcastAddict from './podcast-addict/index.js'; +import podcastRepublic from './podcast-republic/index.js'; +import podcat from './podcat/index.js'; +import podscout from './podscout/index.js'; +import procast from './procast/index.js'; +import rss from './rss/index.js'; +import rssRadio from './rss-radio/index.js'; +import soundcloud from './soundcloud/index.js'; +import spotify from './spotify/index.js'; +import stitcher from './stitcher/index.js'; +import youtube from './youtube/index.js'; +import castbox from './castbox/index.js'; import { PodcatcherClientId } from './types.js'; export const CLIENTS = { diff --git a/packages/clients/src/instacast/index.ts b/packages/clients/src/instacast/index.ts index ef46b4790..985680a2d 100644 --- a/packages/clients/src/instacast/index.ts +++ b/packages/clients/src/instacast/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/overcast/index.ts b/packages/clients/src/overcast/index.ts index 837b336a7..1edf59d9a 100644 --- a/packages/clients/src/overcast/index.ts +++ b/packages/clients/src/overcast/index.ts @@ -1,5 +1,5 @@ -import { type, platform, client } from '../types' -import { removeHttps } from '../helper' +import { type, platform, client } from '../types.js' +import { removeHttps } from '../helper.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/player-fm/index.ts b/packages/clients/src/player-fm/index.ts index 0a5d4abe2..19e604cb0 100644 --- a/packages/clients/src/player-fm/index.ts +++ b/packages/clients/src/player-fm/index.ts @@ -1,6 +1,6 @@ -import { type, platform, client } from '../types' -import { removeHttps } from '../helper' -import icon from './icon.svg' +import { type, platform, client } from '../types.js'; +import { removeHttps } from '../helper.js'; +import icon from './icon.svg'; export default [ client({ @@ -19,4 +19,4 @@ export default [ type: type.service, icon }) -] +]; diff --git a/packages/clients/src/pocket-casts/index.ts b/packages/clients/src/pocket-casts/index.ts index 508483b7a..aec747930 100644 --- a/packages/clients/src/pocket-casts/index.ts +++ b/packages/clients/src/pocket-casts/index.ts @@ -1,5 +1,5 @@ -import { type, platform, client } from '../types' -import icon from './icon.svg' +import { type, platform, client } from '../types.js'; +import icon from './icon.svg'; export default [ client({ @@ -26,4 +26,4 @@ export default [ type: type.service, icon }) -] +]; diff --git a/packages/clients/src/podcast-addict/index.ts b/packages/clients/src/podcast-addict/index.ts index 3258efdfd..5db2de338 100644 --- a/packages/clients/src/podcast-addict/index.ts +++ b/packages/clients/src/podcast-addict/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/podcast-republic/index.ts b/packages/clients/src/podcast-republic/index.ts index 1ccb71d97..d1bf82d50 100644 --- a/packages/clients/src/podcast-republic/index.ts +++ b/packages/clients/src/podcast-republic/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/podcat/index.ts b/packages/clients/src/podcat/index.ts index 108a906f7..e603af656 100644 --- a/packages/clients/src/podcat/index.ts +++ b/packages/clients/src/podcat/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/podscout/index.ts b/packages/clients/src/podscout/index.ts index e818bbb4e..8e670b88e 100644 --- a/packages/clients/src/podscout/index.ts +++ b/packages/clients/src/podscout/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/procast/index.ts b/packages/clients/src/procast/index.ts index 0978cb372..83d6d8fb3 100644 --- a/packages/clients/src/procast/index.ts +++ b/packages/clients/src/procast/index.ts @@ -1,4 +1,4 @@ -import { type, platform, client } from '../types' +import { type, platform, client } from '../types.js' import icon from './icon.svg' export default [ diff --git a/packages/clients/src/rss-radio/index.ts b/packages/clients/src/rss-radio/index.ts index df23c3db4..c09b155df 100644 --- a/packages/clients/src/rss-radio/index.ts +++ b/packages/clients/src/rss-radio/index.ts @@ -1,5 +1,5 @@ -import { type, platform, client } from '../types' -import icon from './icon.svg' +import { type, platform, client } from '../types.js'; +import icon from './icon.svg'; export default [ client({ @@ -10,4 +10,4 @@ export default [ type: type.app, icon }) -] +]; diff --git a/packages/clients/src/rss/index.ts b/packages/clients/src/rss/index.ts index c8363b201..aa8f5e983 100644 --- a/packages/clients/src/rss/index.ts +++ b/packages/clients/src/rss/index.ts @@ -1,5 +1,5 @@ -import { type, platform, client } from '../types' -import icon from './icon.svg' +import { type, platform, client } from '../types.js'; +import icon from './icon.svg'; export default [ client({ @@ -9,4 +9,4 @@ export default [ type: type.service, icon }) -] +]; diff --git a/packages/clients/src/soundcloud/index.ts b/packages/clients/src/soundcloud/index.ts index 220eecb11..399e0912e 100644 --- a/packages/clients/src/soundcloud/index.ts +++ b/packages/clients/src/soundcloud/index.ts @@ -1,5 +1,5 @@ -import { type, platform, client } from '../types' -import icon from './icon.svg' +import { type, platform, client } from '../types.js'; +import icon from './icon.svg'; export default [ client({ @@ -10,4 +10,4 @@ export default [ type: type.service, icon }) -] +]; diff --git a/packages/clients/src/spotify/index.ts b/packages/clients/src/spotify/index.ts index 4c17967aa..34fa9e617 100644 --- a/packages/clients/src/spotify/index.ts +++ b/packages/clients/src/spotify/index.ts @@ -1,5 +1,5 @@ -import { type, platform, client } from '../types' -import icon from './icon.svg' +import { type, platform, client } from '../types.js'; +import icon from './icon.svg'; export default [ client({ @@ -10,4 +10,4 @@ export default [ type: type.service, icon }) -] +]; diff --git a/packages/clients/src/types.ts b/packages/clients/src/types.ts index 59e61f80d..ab39a4de4 100644 --- a/packages/clients/src/types.ts +++ b/packages/clients/src/types.ts @@ -1,9 +1,14 @@ -import { CLIENTS } from "./index.js"; +import { CLIENTS } from './index.js'; export interface PodcastClient { title: string | null; scheme: (feed: string) => string | null; - icon: string | null; + icon: { + format: string; + height: number; + src: string; + width: number; + }; install?: string | null; service?: string; type: PodcastClientType | null; diff --git a/packages/components/package.json b/packages/components/package.json index 952f820bc..9935d1733 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -38,7 +38,8 @@ "ramda": "0.29.0", "rangetouch": "2.0.1", "tailwindcss": "3.2.4", - "vue": "3.2.41" + "vue": "3.4.31", + "lodash-es": "4.17.21" }, "peerDependencies": { "vue": "3" @@ -46,10 +47,11 @@ "devDependencies": { "@podlove/types": "workspace:*", "@types/ramda": "0.29.0", + "@types/lodash-es": "4.17.12", "@typescript-eslint/eslint-plugin": "6.11.0", "@typescript-eslint/parser": "6.11.0", "@vitejs/plugin-vue": "4.2.1", - "@vue/test-utils": "2.3.2", + "@vue/test-utils": "2.4.6", "eslint": "8.54.0", "eslint-plugin-vue": "9.18.1", "happy-dom": "9.1.9", diff --git a/packages/components/src/components/chapter-progress/ChapterProgress.vue b/packages/components/src/components/chapter-progress/ChapterProgress.vue index c9ead9415..e088c8366 100644 --- a/packages/components/src/components/chapter-progress/ChapterProgress.vue +++ b/packages/components/src/components/chapter-progress/ChapterProgress.vue @@ -3,47 +3,47 @@ import { disableGhost, enableGhost } from '@podlove/player-actions/progress'; import { setChapter } from '@podlove/player-actions/chapters'; import { simulatePlaytime, requestPlaytime } from '@podlove/player-actions/timepiece'; import { requestPlay } from '@podlove/player-actions/play'; -import type { - PodloveWebPlayerChapter -} from '@podlove/types'; +import type { PodloveWebPlayerChapter } from '@podlove/types'; import LinkIcon from '../icons/Link.vue'; import Timer from '../timer/Timer.vue'; import { computed, ref } from 'vue'; +import { toInt } from '@podlove/utils/helper'; const props = defineProps<{ - chapter: PodloveWebPlayerChapter & { linkTitle?: string }, - showLink?: boolean, - playtime?: number, - ghost?: number, + chapter: PodloveWebPlayerChapter & { linkTitle?: string }; + showLink?: boolean; + playtime?: number; + ghost?: number; }>(); const emit = defineEmits(['chapter', 'play', 'playtime', 'ghost', 'simulate', 'hover']); +const ghost = computed(() => props.ghost || 0); +const playtime = computed(() => props.playtime || 0); +const start = computed(() => toInt(props.chapter.start)); +const end = computed(() => toInt(props.chapter.end)); const progressContainer = ref(); // computed -const progress = (time: number) => - `${((time - props.chapter.start) * 100) / (props.chapter.end - props.chapter.start)}%`; +const progress = (time: number) => `${((time - start.value) * 100) / (end.value - start.value)}%`; -const progressActive = computed(() => props.chapter.active && props.playtime < props.chapter.end); -const ghostActive = computed( - () => props.ghost && props.ghost > props.chapter.start && props.ghost < props.chapter.end -); +const progressActive = computed(() => props.chapter.active && (props?.playtime || 0) < end.value); +const ghostActive = computed(() => ghost.value > start.value && ghost.value < end.value); -const progressWidth = computed(() => progress(props.playtime)) -const ghostWidth = computed(() => progress(props.ghost)) +const progressWidth = computed(() => progress(props.playtime)); +const ghostWidth = computed(() => progress(props.ghost)); const remainingTime = computed(() => { if (props.chapter.active) { - return props.chapter.end - props.playtime; + return end.value - playtime.value; } if (ghostActive.value) { - return props.chapter.end - props.ghost; + return end.value - props.ghost; } - return props.chapter.end - props.chapter.start; + return end.value - start.value; }); const hasLink = computed(() => props.chapter.href && props.showLink); @@ -55,14 +55,12 @@ const hoverPlaytime = (event: MouseEvent): number | null => { } const clientRect = progressContainer.value?.getBoundingClientRect(); return ( - props.chapter.start + - ((props.chapter.end - props.chapter.start) * (event.clientX - clientRect.left)) / - clientRect.width + start.value + ((end.value - start.value) * (event.clientX - clientRect.left)) / clientRect.width ); }; const progressClick = () => { - emit('chapter', setChapter(props.chapter.index - 1)); + emit('chapter', setChapter(props.chapter.index)); emit('play', requestPlay()); return false; }; @@ -95,7 +93,7 @@ const linkLeave = () => {