{"_id":"@dudgy/podcast-widget","_rev":"3-be64df5e8b0e255cf9612e458836b991","name":"@dudgy/podcast-widget","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@dudgy/podcast-widget","version":"0.1.0","keywords":["podcast","player","widget","audio","typescript"],"license":"MIT","_id":"@dudgy/podcast-widget@0.1.0","maintainers":[{"name":"dudgy","email":"dudgy@wintermute.world"}],"dist":{"shasum":"384f27e347f6f78488327eba85b0926045baf4bf","tarball":"https://registry.npmjs.org/@dudgy/podcast-widget/-/podcast-widget-0.1.0.tgz","fileCount":11,"integrity":"sha512-39bdLfd1/jmDLRihI/LADAVwoq5pwEL2ZeS2rYM86OwBcZuz3XFpdLxBAqqXpO0uXtmaWS8CiI1vB6ZKrynCQA==","signatures":[{"sig":"MEUCIQC85nnDJ/oiYRyfBdj+KHC+M3c07a5x3NW0yXRDctQK2wIgRFdUSrfZxAIiv7Ws5Kz/6w0mKA+LUBgLMNfl4TwUFQ8=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":142490},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./styles":"./dist/podcast-player.css","./podcast-index":{"types":"./dist/podcast-index.d.ts","import":"./dist/podcast-index.js","require":"./dist/podcast-index.cjs"}},"gitHead":"e0a1e6f89e703d4faf4dcd1497a70ebb5cccfaa2","scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","typecheck":"tsc --noEmit && tsc --noEmit -p tsconfig.server.json","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"dudgy","email":"dudgy@wintermute.world"},"_npmVersion":"11.6.2","description":"Framework-agnostic podcast player widget with configurable player modes, theming via CSS custom properties, and optional PodcastIndex API client.","directories":{},"sideEffects":["./dist/podcast-player.css"],"_nodeVersion":"24.11.1","_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","jsdom":"^28.0.0","vitest":"^4.0.18","typescript":"^5.0.0","@types/node":"^25.2.3","@vitest/coverage-v8":"^4.0.18"},"_npmOperationalInternal":{"tmp":"tmp/podcast-widget_0.1.0_1771125611981_0.4043976750614695","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@dudgy/podcast-widget","version":"0.1.1","keywords":["podcast","player","widget","audio","typescript"],"license":"MIT","_id":"@dudgy/podcast-widget@0.1.1","maintainers":[{"name":"dudgy","email":"dudgy@wintermute.world"}],"dist":{"shasum":"374a52ae38456b53275c74c9571f1955083fae09","tarball":"https://registry.npmjs.org/@dudgy/podcast-widget/-/podcast-widget-0.1.1.tgz","fileCount":11,"integrity":"sha512-XmV7ohh4dCcj2xVhZQjWNvw7HLEEs4l65PM8mbCshutI9GIXjQ6gbWJv7fxXX+xhj4aSw3kjFGYkZ24EdpfE1A==","signatures":[{"sig":"MEUCIQD4eEIUUSlb2clsP0VpLyIIaUow2nLiaqJqRKaa634RcAIgXPg3en18GAUuHzc5T8vG23mC4kvoqgbKOYMyL3ngWbk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":142584},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./styles":"./dist/podcast-player.css","./podcast-index":{"types":"./dist/podcast-index.d.ts","import":"./dist/podcast-index.js","require":"./dist/podcast-index.cjs"}},"gitHead":"e0a1e6f89e703d4faf4dcd1497a70ebb5cccfaa2","scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","typecheck":"tsc --noEmit && tsc --noEmit -p tsconfig.server.json","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"dudgy","email":"dudgy@wintermute.world"},"_npmVersion":"11.6.2","description":"Framework-agnostic podcast player widget with configurable player modes, theming via CSS custom properties, and optional PodcastIndex API client.","directories":{},"sideEffects":["./dist/podcast-player.css"],"_nodeVersion":"24.11.1","_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","jsdom":"^28.0.0","vitest":"^4.0.18","typescript":"^5.0.0","@types/node":"^25.2.3","@vitest/coverage-v8":"^4.0.18"},"_npmOperationalInternal":{"tmp":"tmp/podcast-widget_0.1.1_1771168466564_0.21473677434560368","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@dudgy/podcast-widget","version":"0.2.0","description":"Framework-agnostic podcast player widget with configurable player modes, theming via CSS custom properties, and optional PodcastIndex API client.","type":"module","main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./podcast-index":{"types":"./dist/podcast-index.d.ts","import":"./dist/podcast-index.js","require":"./dist/podcast-index.cjs"},"./styles":"./dist/podcast-player.css"},"sideEffects":["./dist/podcast-player.css"],"scripts":{"build":"tsup","dev":"tsup --watch","typecheck":"tsc --noEmit && tsc --noEmit -p tsconfig.server.json","test":"vitest run","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build && npm test"},"keywords":["podcast","player","widget","audio","typescript"],"license":"MIT","devDependencies":{"@types/node":"^25.2.3","@vitest/coverage-v8":"^4.0.18","jsdom":"^28.0.0","tsup":"^8.0.0","typescript":"^5.0.0","vitest":"^4.0.18"},"gitHead":"22aab1326ddcea2732d02d7fdc1fc50f95b057b4","_id":"@dudgy/podcast-widget@0.2.0","_nodeVersion":"24.11.1","_npmVersion":"11.6.2","dist":{"integrity":"sha512-mDacpJ5mi43/oLr983hV4ODUyH3Ire4nbAq8o1vElGVuxqaV/jFEYpLNO4BfvOj+TlulhGE0g8QcDW6OuDtWJQ==","shasum":"11529877180f6f96b28c6ffe18eb2b1eaec60c28","tarball":"https://registry.npmjs.org/@dudgy/podcast-widget/-/podcast-widget-0.2.0.tgz","fileCount":11,"unpackedSize":145710,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCICtN1c995nDnMlY4QX9TpVYwvR/yhbtct3PbTInmXvaxAiEA680YOixcgN3g9YI0y6aBvWZnyXNcRHNRVJXHCs5rAsE="}]},"_npmUser":{"name":"dudgy","email":"dudgy@wintermute.world"},"directories":{},"maintainers":[{"name":"dudgy","email":"dudgy@wintermute.world"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/podcast-widget_0.2.0_1773006860640_0.5095438560310706"},"_hasShrinkwrap":false}},"time":{"created":"2026-02-15T03:20:11.890Z","modified":"2026-03-08T21:54:20.899Z","0.1.0":"2026-02-15T03:20:12.159Z","0.1.1":"2026-02-15T15:14:26.727Z","0.2.0":"2026-03-08T21:54:20.787Z"},"license":"MIT","keywords":["podcast","player","widget","audio","typescript"],"description":"Framework-agnostic podcast player widget with configurable player modes, theming via CSS custom properties, and optional PodcastIndex API client.","maintainers":[{"name":"dudgy","email":"dudgy@wintermute.world"}],"readme":"# podcast-widget\r\n\r\nFramework-agnostic podcast player widget with configurable player modes, CSS custom property theming, and an optional PodcastIndex API client.\r\n\r\nZero runtime dependencies. Ships ESM + CJS with full TypeScript declarations.\r\n\r\n## Install\r\n\r\n```bash\r\nnpm install podcast-widget\r\n```\r\n\r\n## Quick Start\r\n\r\n```typescript\r\nimport { AudioEngine, PlayerController, FetchEpisodeProvider, MiniPlayer, FullPlayer } from 'podcast-widget';\r\nimport 'podcast-widget/styles';\r\n\r\n// 1. Create the core objects\r\nconst engine = new AudioEngine();\r\nconst provider = new FetchEpisodeProvider('/api/podcast-episodes', {\r\n  storagePrefix: 'podcast',\r\n  maxStoredPositions: 100,\r\n  positionSaveInterval: 5000,\r\n  skipSeconds: 30,\r\n  episodeCacheTTL: 3600000,\r\n});\r\nconst controller = new PlayerController(engine, provider);\r\n\r\n// 2. Create one or more player UIs\r\nconst mini = new MiniPlayer({\r\n  container: document.getElementById('mini-player')!,\r\n  controller,\r\n  controls: { playPause: true, progress: true, expand: true },\r\n});\r\n\r\nconst full = new FullPlayer({\r\n  container: document.getElementById('full-player')!,\r\n  controller,\r\n  controls: { playPause: true, skipForward: true, skipBackward: true, volume: true, speed: true, progress: true, playlist: true, download: true },\r\n});\r\n\r\n// 3. Wire them together\r\nmini.on('expand', () => full.show());\r\nfull.on('close', () => full.hide());\r\n\r\n// 4. Load episodes\r\ncontroller.loadEpisodes();\r\n```\r\n\r\n## Architecture\r\n\r\n```\r\npodcast-widget/\r\n├── src/\r\n│   ├── core/\r\n│   │   ├── types.ts              # Episode, PlayerConfig, events, EpisodeProvider interface\r\n│   │   ├── event-emitter.ts      # Lightweight typed event emitter\r\n│   │   ├── audio-engine.ts       # HTMLAudioElement wrapper with seek, destroy, error handling\r\n│   │   ├── playback-storage.ts   # localStorage for resume positions (namespaced keys)\r\n│   │   ├── player-state.ts       # localStorage for player state + speed preference\r\n│   │   └── episode-provider.ts   # FetchEpisodeProvider with caching + retry\r\n│   ├── ui/\r\n│   │   ├── utils.ts              # formatTime, escapeHtml, getEpisodeImage\r\n│   │   ├── html-templates.ts     # SVG icons, playlist rendering, speed options\r\n│   │   ├── player-controller.ts  # Orchestrator: engine + storage + provider → events\r\n│   │   ├── mini-player.ts        # Mini mode with configurable controls + inline playlist\r\n│   │   └── full-player.ts        # Full modal with hero, playlist, keyboard shortcuts\r\n│   ├── podcast-index/\r\n│   │   ├── auth.ts               # HMAC-SHA1 auth headers (server-only, uses Node crypto)\r\n│   │   ├── client.ts             # Typed PodcastIndexClient\r\n│   │   └── index.ts              # Barrel export\r\n│   ├── styles/\r\n│   │   └── podcast-player.css    # Plain CSS with custom property theming (pw- prefix)\r\n│   └── index.ts                  # Main barrel export\r\n├── package.json                  # Dual ESM/CJS, subpath exports\r\n├── tsconfig.json                 # Client-side TS config\r\n├── tsconfig.server.json          # Server-side (podcast-index) config\r\n└── tsup.config.ts                # Build config\r\n```\r\n\r\n## Player Modes\r\n\r\nThe package provides two player modes that can be used independently or together. Each mode accepts a `controls` config to toggle which controls are visible.\r\n\r\n### Controls Config\r\n\r\nAll controls default to `false` (opt-in):\r\n\r\n```typescript\r\ninterface ControlsConfig {\r\n  playPause?: boolean;\r\n  skipForward?: boolean;\r\n  skipBackward?: boolean;\r\n  volume?: boolean;\r\n  speed?: boolean;\r\n  progress?: boolean;\r\n  playlist?: boolean;   // Inline dropdown playlist (mini) or full playlist (full)\r\n  expand?: boolean;     // Button to emit 'expand' event\r\n  download?: boolean;   // Download button for current episode\r\n}\r\n```\r\n\r\n### MiniPlayer\r\n\r\nCompact horizontal bar. Defaults: `playPause: true, progress: true`.\r\n\r\n```typescript\r\nconst mini = new MiniPlayer({\r\n  container: document.getElementById('mini-player')!,\r\n  controller,\r\n  controls: {\r\n    playPause: true,\r\n    progress: true,\r\n    volume: true,\r\n    playlist: true,  // Adds inline dropdown playlist\r\n    expand: true,    // Adds expand button\r\n  },\r\n});\r\n\r\nmini.on('expand', () => { /* open full player, etc. */ });\r\nmini.destroy(); // Cleanup when done\r\n```\r\n\r\n### FullPlayer\r\n\r\nModal overlay with hero section (blurred artwork background), controls, and scrollable playlist. Defaults: all controls enabled. Each playlist item includes a download button.\r\n\r\n```typescript\r\nconst full = new FullPlayer({\r\n  container: document.getElementById('full-player')!,\r\n  backdrop: document.getElementById('backdrop'),  // Optional separate backdrop element\r\n  controller,\r\n});\r\n\r\nfull.show();  // Open modal\r\nfull.hide();  // Close modal\r\nfull.on('close', () => { /* cleanup, etc. */ });\r\nfull.destroy();\r\n```\r\n\r\n**Keyboard shortcuts** (when full player is visible):\r\n- `Space` / `k` — play/pause\r\n- `ArrowLeft` — skip backward\r\n- `ArrowRight` — skip forward\r\n- `Escape` — close\r\n\r\n## Core API\r\n\r\n### AudioEngine\r\n\r\nWraps `HTMLAudioElement` with typed events and proper cleanup.\r\n\r\n```typescript\r\nconst engine = new AudioEngine(30); // skip seconds (default: 30)\r\n\r\nengine.load(url, startPosition?);\r\nawait engine.play();\r\nengine.pause();\r\nengine.stop();\r\nengine.skipForward();\r\nengine.skipBackward();\r\nengine.seek(120);            // Seek to 120 seconds\r\nengine.seekToPercentage(50); // Seek to 50%\r\n\r\nengine.volume = 0.8;\r\nengine.playbackRate = 1.5;\r\n\r\nengine.on('play', () => {});\r\nengine.on('pause', ({ currentTime }) => {});\r\nengine.on('time-update', ({ currentTime, duration, percentage }) => {});\r\nengine.on('ended', () => {});\r\nengine.on('error', ({ error, context }) => {});\r\n\r\nengine.destroy(); // Removes all listeners, releases audio element\r\n```\r\n\r\n### PlayerController\r\n\r\nOrchestrates AudioEngine, PlaybackStorage, and EpisodeProvider. Manages episode state, resume positions, and player preferences.\r\n\r\n```typescript\r\nconst controller = new PlayerController(engine, provider, {\r\n  storagePrefix: 'podcast',       // localStorage key prefix (default: \"podcast\")\r\n  maxStoredPositions: 100,         // Max saved resume positions (default: 100)\r\n  positionSaveInterval: 5000,      // Save interval in ms (default: 5000)\r\n  skipSeconds: 30,                 // Skip amount (default: 30)\r\n  episodeCacheTTL: 3600000,        // Cache TTL in ms (default: 1 hour)\r\n});\r\n\r\nawait controller.loadEpisodes();\r\ncontroller.loadEpisode(0);         // Load without playing\r\ncontroller.playEpisode(2);         // Load and play\r\ncontroller.setSpeed(1.5);\r\ncontroller.setVolume(0.8);\r\n\r\ncontroller.on('episode-change', ({ episode, index }) => {});\r\ncontroller.on('episodes-loaded', ({ episodes }) => {});\r\ncontroller.on('episode-display', ({ imageUrl, title, show }) => {});\r\n\r\ncontroller.destroy();\r\n```\r\n\r\n### EpisodeProvider\r\n\r\nInterface for loading episodes. The package ships `FetchEpisodeProvider` which fetches from a URL with caching and retry.\r\n\r\n```typescript\r\n// Use the built-in fetch provider\r\nconst provider = new FetchEpisodeProvider('/api/episodes', resolvedConfig);\r\n\r\n// Or implement your own\r\nconst customProvider: EpisodeProvider = {\r\n  async getEpisodes() {\r\n    return [\r\n      { id: '1', feedId: 123, feedName: 'My Show', title: 'Ep 1', audioUrl: '...', pubDate: '...' },\r\n    ];\r\n  },\r\n};\r\n```\r\n\r\nThe `FetchEpisodeProvider` expects the endpoint to return `{ episodes: Episode[] }`.\r\n\r\n### Episode Interface\r\n\r\n```typescript\r\ninterface Episode {\r\n  id: string;\r\n  feedId: number;\r\n  feedName: string;\r\n  title: string;\r\n  audioUrl: string;\r\n  pubDate: string;\r\n  pubDateTime?: string;\r\n  pubTimestamp?: number;\r\n  duration?: number;\r\n  image?: string;\r\n  feedImage?: string;\r\n  artwork?: string;\r\n}\r\n```\r\n\r\n## CSS Theming\r\n\r\nImport the styles:\r\n\r\n```typescript\r\nimport 'podcast-widget/styles';\r\n```\r\n\r\nOr link directly:\r\n\r\n```html\r\n<link rel=\"stylesheet\" href=\"node_modules/podcast-widget/dist/podcast-player.css\">\r\n```\r\n\r\nDefine these CSS custom properties on a parent element to theme the player:\r\n\r\n```css\r\n.my-player-wrapper {\r\n  --player-surface: #002b36;            /* Player background */\r\n  --player-surface-variant: #073642;    /* Secondary background */\r\n  --player-on-surface: #839496;         /* Primary text */\r\n  --player-on-surface-variant: #657b83; /* Secondary text */\r\n  --player-on-surface-rgb: 131, 148, 150; /* RGB triplet for rgba() overlays */\r\n  --player-outline: #586e75;            /* Borders */\r\n  --player-outline-variant: #073642;    /* Subtle borders */\r\n  --md-primary: #268bd2;               /* Accent (play button, active episode) */\r\n  --md-on-primary: #fdf6e3;            /* Text on accent */\r\n  --md-secondary: #2aa198;             /* Secondary accent (saved position) */\r\n  --md-on-secondary: #fdf6e3;          /* Text on secondary accent */\r\n}\r\n```\r\n\r\nAll CSS classes use the `pw-` prefix to avoid collisions.\r\n\r\n## PodcastIndex Client (Server-Only)\r\n\r\nSeparate subpath export for server-side use. Uses Node's `crypto` module for HMAC-SHA1 auth — not included in the browser bundle.\r\n\r\n```typescript\r\nimport { PodcastIndexClient } from 'podcast-widget/podcast-index';\r\n\r\nconst client = new PodcastIndexClient(API_KEY, API_SECRET);\r\n\r\nconst episodes = await client.getEpisodes(feedId, 10);\r\nconst latest = await client.getLatestEpisode(feedId);\r\nconst feed = await client.getFeedInfo(feedId);\r\nconst results = await client.search('javascript');\r\n```\r\n\r\nYou can also use the auth headers directly:\r\n\r\n```typescript\r\nimport { createPodcastIndexHeaders } from 'podcast-widget/podcast-index';\r\n\r\nconst headers = createPodcastIndexHeaders(apiKey, apiSecret);\r\n// { 'X-Auth-Key': '...', 'X-Auth-Date': '...', Authorization: '...', 'User-Agent': '...' }\r\n```\r\n\r\n## Local Development\r\n\r\n```bash\r\nnpm run build        # Build\r\nnpm run dev          # Build + watch\r\nnpm run typecheck    # Type check\r\nnpm test             # Run tests\r\n```\r\n\r\n### Testing in a Consumer Before Publishing\r\n\r\n```bash\r\ncd /path/to/podcast-widget\r\nnpm run build\r\nnpm link\r\n\r\ncd /path/to/my-site\r\nnpm link podcast-widget\r\n```\r\n\r\nNow `import ... from 'podcast-widget'` resolves to your local build. Run `npm run build` in podcast-widget after changes and the consumer picks them up immediately.\r\n\r\nWhen done:\r\n\r\n```bash\r\ncd /path/to/my-site\r\nnpm unlink podcast-widget\r\nnpm install\r\n```\r\n\r\n## Publishing\r\n\r\n```bash\r\nnpm login              # First time only\r\nnpm publish\r\n```\r\n\r\nVersion bumping:\r\n\r\n```bash\r\nnpm version patch      # 0.1.0 → 0.1.1 (bug fixes)\r\nnpm version minor      # 0.1.1 → 0.2.0 (new features)\r\nnpm version major      # 0.2.0 → 1.0.0 (breaking changes)\r\n```\r\n\r\n## License\r\n\r\nMIT\r\n","readmeFilename":"README.md"}