diff --git a/src/components/MergedWaveform.tsx b/src/components/MergedWaveform.tsx new file mode 100644 index 0000000..725c5a0 --- /dev/null +++ b/src/components/MergedWaveform.tsx @@ -0,0 +1,93 @@ +/** + * MergedWaveform — unified progress bar + waveform display + * + * Shows waveform bars coloured to indicate played vs unplayed portions. + * The played section doubles as the progress indicator, replacing the + * separate progress bar. Click-to-seek is supported. + */ + +import { createSignal, createEffect, onCleanup } from "solid-js" +import { getWaveformData, getWaveformDataSync } from "../utils/audio-waveform" + +type MergedWaveformProps = { + /** Audio URL — used to generate or retrieve waveform data */ + audioUrl: string + /** Current playback position in seconds */ + position: number + /** Total duration in seconds */ + duration: number + /** Whether audio is currently playing */ + isPlaying: boolean + /** Number of data points / columns */ + resolution?: number + /** Callback when user clicks to seek */ + onSeek?: (seconds: number) => void +} + +/** Block characters for waveform amplitude levels */ +const BARS = [".", "-", "~", "=", "#"] + +export function MergedWaveform(props: MergedWaveformProps) { + const resolution = () => props.resolution ?? 64 + + // Waveform data — start with sync/cached, kick off async extraction + const [data, setData] = createSignal( + getWaveformDataSync(props.audioUrl, resolution()), + ) + + // When the audioUrl changes, attempt async extraction for real data + createEffect(() => { + const url = props.audioUrl + const res = resolution() + if (!url) return + + let cancelled = false + getWaveformData(url, res).then((result) => { + if (!cancelled) setData(result) + }) + onCleanup(() => { cancelled = true }) + }) + + const playedRatio = () => + props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration) + + const renderLine = () => { + const d = data() + const played = Math.floor(d.length * playedRatio()) + const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590" + const futureColor = "#3b4252" + + const playedChars = d + .slice(0, played) + .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) + .join("") + + const futureChars = d + .slice(played) + .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) + .join("") + + return ( + + {playedChars || " "} + {futureChars || " "} + + ) + } + + const handleClick = (event: { x: number }) => { + const d = data() + const ratio = d.length === 0 ? 0 : event.x / d.length + const next = Math.max( + 0, + Math.min(props.duration, Math.round(props.duration * ratio)), + ) + props.onSeek?.(next) + } + + return ( + + {renderLine()} + + ) +} diff --git a/src/components/MyShowsPage.tsx b/src/components/MyShowsPage.tsx index 15c1051..fb0f887 100644 --- a/src/components/MyShowsPage.tsx +++ b/src/components/MyShowsPage.tsx @@ -4,7 +4,7 @@ * Right panel: episodes for the selected show */ -import { createSignal, For, Show, createMemo } from "solid-js" +import { createSignal, For, Show, createMemo, createEffect } from "solid-js" import { useKeyboard } from "@opentui/solid" import { useFeedStore } from "../stores/feed" import { format } from "date-fns" @@ -26,6 +26,9 @@ export function MyShowsPage(props: MyShowsPageProps) { const [episodeIndex, setEpisodeIndex] = createSignal(0) const [isRefreshing, setIsRefreshing] = createSignal(false) + /** Threshold: load more when within this many items of the end */ + const LOAD_MORE_THRESHOLD = 5 + const shows = () => feedStore.getFilteredFeeds() const selectedShow = createMemo(() => { @@ -42,6 +45,19 @@ export function MyShowsPage(props: MyShowsPageProps) { ) }) + // Detect when user navigates near the bottom and load more episodes + createEffect(() => { + const idx = episodeIndex() + const eps = episodes() + const show = selectedShow() + if (!show || eps.length === 0) return + + const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD + if (nearBottom && feedStore.hasMoreEpisodes(show.id) && !feedStore.isLoadingMore()) { + feedStore.loadMoreEpisodes(show.id) + } + }) + const formatDate = (date: Date): string => { return format(date, "MMM d, yyyy") } @@ -231,6 +247,16 @@ export function MyShowsPage(props: MyShowsPageProps) { )} + + + Loading more episodes... + + + + + Scroll down for more episodes + + diff --git a/src/components/Player.tsx b/src/components/Player.tsx index e7ff8b0..466f889 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,7 +1,6 @@ import { useKeyboard } from "@opentui/solid" import { PlaybackControls } from "./PlaybackControls" -import { Waveform } from "./Waveform" -import { createWaveform } from "../utils/waveform" +import { MergedWaveform } from "./MergedWaveform" import { useAudio } from "../hooks/useAudio" import type { Episode } from "../types/episode" @@ -24,8 +23,6 @@ const SAMPLE_EPISODE: Episode = { export function Player(props: PlayerProps) { const audio = useAudio() - const waveform = () => createWaveform(64) - // The episode to display — prefer a passed-in episode, then the // currently-playing episode, then fall back to the sample. const episode = () => props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE @@ -86,7 +83,7 @@ export function Player(props: PlayerProps) { Now Playing - {formatTime(audio.position())} / {formatTime(dur())} + {formatTime(audio.position())} / {formatTime(dur())} ({progressPercent()}%) @@ -100,27 +97,13 @@ export function Player(props: PlayerProps) { {episode().description} - - - Progress: - - - - {progressPercent()}% - - - audio.seek(next)} - /> - + audio.seek(next)} + /> Episode[]) */ +const fullEpisodeCache = new Map() + +/** Track how many episodes are currently loaded per feed */ +const episodeLoadCount = new Map() + /** Save feeds to file (async, fire-and-forget) */ function saveFeeds(feeds: Feed[]): void { saveFeedsToFile(feeds).catch(() => {}) @@ -56,6 +62,7 @@ export function createFeedStore() { sortDirection: "desc", }) const [selectedFeedId, setSelectedFeedId] = createSignal(null) + const [isLoadingMore, setIsLoadingMore] = createSignal(false) /** Get filtered and sorted feeds */ const getFilteredFeeds = (): Feed[] => { @@ -132,8 +139,8 @@ export function createFeedStore() { return allEpisodes } - /** Fetch latest episodes from an RSS feed URL */ - const fetchEpisodes = async (feedUrl: string, limit: number): Promise => { + /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */ + const fetchEpisodes = async (feedUrl: string, limit: number, feedId?: string): Promise => { try { const response = await fetch(feedUrl, { headers: { @@ -144,7 +151,15 @@ export function createFeedStore() { if (!response.ok) return [] const xml = await response.text() const parsed = parseRSSFeed(xml, feedUrl) - return parsed.episodes.slice(0, limit) + const allEpisodes = parsed.episodes + + // Cache all parsed episodes for pagination + if (feedId) { + fullEpisodeCache.set(feedId, allEpisodes) + episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length)) + } + + return allEpisodes.slice(0, limit) } catch { return [] } @@ -152,9 +167,10 @@ export function createFeedStore() { /** Add a new feed and auto-fetch latest 20 episodes */ const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => { - const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE) + const feedId = crypto.randomUUID() + const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE, feedId) const newFeed: Feed = { - id: crypto.randomUUID(), + id: feedId, podcast, episodes, visibility, @@ -174,7 +190,7 @@ export function createFeedStore() { const refreshFeed = async (feedId: string) => { const feed = getFeed(feedId) if (!feed) return - const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH) + const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feedId) setFeeds((prev) => { const updated = prev.map((f) => f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f @@ -194,6 +210,8 @@ export function createFeedStore() { /** Remove a feed */ const removeFeed = (feedId: string) => { + fullEpisodeCache.delete(feedId) + episodeLoadCount.delete(feedId) setFeeds((prev) => { const updated = prev.filter((f) => f.id !== feedId) saveFeeds(updated) @@ -283,18 +301,76 @@ export function createFeedStore() { return id ? getFeed(id) : undefined } + /** Check if a feed has more episodes available beyond what's currently loaded */ + const hasMoreEpisodes = (feedId: string): boolean => { + const cached = fullEpisodeCache.get(feedId) + if (!cached) return false + const loaded = episodeLoadCount.get(feedId) ?? 0 + return loaded < cached.length + } + + /** Load the next chunk of episodes for a feed from the cache. + * If no cache exists (e.g. app restart), re-fetches from the RSS feed. */ + const loadMoreEpisodes = async (feedId: string) => { + if (isLoadingMore()) return + const feed = getFeed(feedId) + if (!feed) return + + setIsLoadingMore(true) + try { + let cached = fullEpisodeCache.get(feedId) + + // If no cache, re-fetch and parse the full feed + if (!cached) { + const response = await fetch(feed.podcast.feedUrl, { + headers: { + "Accept-Encoding": "identity", + "Accept": "application/rss+xml, application/xml, text/xml, */*", + }, + }) + if (!response.ok) return + const xml = await response.text() + const parsed = parseRSSFeed(xml, feed.podcast.feedUrl) + cached = parsed.episodes + fullEpisodeCache.set(feedId, cached) + // Set current load count to match what's already displayed + episodeLoadCount.set(feedId, feed.episodes.length) + } + + const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length + const newCount = Math.min(currentCount + MAX_EPISODES_REFRESH, cached.length) + + if (newCount <= currentCount) return // nothing more to load + + episodeLoadCount.set(feedId, newCount) + const episodes = cached.slice(0, newCount) + + setFeeds((prev) => { + const updated = prev.map((f) => + f.id === feedId ? { ...f, episodes } : f + ) + saveFeeds(updated) + return updated + }) + } finally { + setIsLoadingMore(false) + } + } + return { // State feeds, sources, filter, selectedFeedId, + isLoadingMore, // Computed getFilteredFeeds, getAllEpisodesChronological, getFeed, getSelectedFeed, + hasMoreEpisodes, // Actions setFilter, @@ -305,6 +381,7 @@ export function createFeedStore() { togglePinned, refreshFeed, refreshAllFeeds, + loadMoreEpisodes, addSource, removeSource, toggleSource, diff --git a/src/utils/audio-waveform.ts b/src/utils/audio-waveform.ts new file mode 100644 index 0000000..52a63fb --- /dev/null +++ b/src/utils/audio-waveform.ts @@ -0,0 +1,149 @@ +/** + * Audio waveform analysis for PodTUI + * + * Extracts amplitude data from audio files using ffmpeg (when available) + * or generates procedural waveform data as a fallback. Results are cached + * in-memory keyed by audio URL. + */ + +/** Number of amplitude data points to generate */ +const DEFAULT_RESOLUTION = 128 + +/** In-memory cache: audioUrl -> amplitude data */ +const waveformCache = new Map() + +/** + * Try to extract real waveform data from an audio URL using ffmpeg. + * Returns null if ffmpeg is not available or the extraction fails. + */ +async function extractWithFfmpeg(audioUrl: string, resolution: number): Promise { + try { + if (!Bun.which("ffmpeg")) return null + + // Use ffmpeg to output raw PCM samples, then downsample to `resolution` points. + // -t 300: read at most 5 minutes (enough data to fill the waveform) + const proc = Bun.spawn( + [ + "ffmpeg", + "-i", audioUrl, + "-t", "300", + "-ac", "1", // mono + "-ar", "8000", // low sample rate to keep data small + "-f", "s16le", // raw signed 16-bit PCM + "-v", "quiet", + "-", + ], + { stdout: "pipe", stderr: "ignore" }, + ) + + const output = await new Response(proc.stdout).arrayBuffer() + await proc.exited + + if (output.byteLength === 0) return null + + const samples = new Int16Array(output) + if (samples.length === 0) return null + + // Downsample to `resolution` buckets by taking the max absolute amplitude + // in each bucket. + const bucketSize = Math.max(1, Math.floor(samples.length / resolution)) + const data: number[] = [] + + for (let i = 0; i < resolution; i++) { + const start = i * bucketSize + const end = Math.min(start + bucketSize, samples.length) + let maxAbs = 0 + for (let j = start; j < end; j++) { + const abs = Math.abs(samples[j]) + if (abs > maxAbs) maxAbs = abs + } + // Normalise to 0-1 + data.push(Number((maxAbs / 32768).toFixed(3))) + } + + return data + } catch { + return null + } +} + +/** + * Generate a procedural (fake) waveform that looks plausible. + * Uses a combination of sine waves with different frequencies to + * simulate varying audio energy. + */ +function generateProcedural(resolution: number, seed: number): number[] { + const data: number[] = [] + for (let i = 0; i < resolution; i++) { + const t = i + seed + const value = + 0.15 + + Math.abs(Math.sin(t / 3.7)) * 0.35 + + Math.abs(Math.sin(t / 7.3)) * 0.25 + + Math.abs(Math.sin(t / 13.1)) * 0.15 + + (Math.random() * 0.1) + data.push(Number(Math.min(1, value).toFixed(3))) + } + return data +} + +/** + * Simple numeric hash of a string, used to seed procedural generation + * so the same URL always produces the same waveform. + */ +function hashString(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) | 0 + } + return Math.abs(h) +} + +/** + * Get waveform data for an audio URL. + * + * Returns cached data if available, otherwise attempts ffmpeg extraction + * and falls back to procedural generation. + */ +export async function getWaveformData( + audioUrl: string, + resolution: number = DEFAULT_RESOLUTION, +): Promise { + const cacheKey = `${audioUrl}:${resolution}` + const cached = waveformCache.get(cacheKey) + if (cached) return cached + + // Try real extraction first + const real = await extractWithFfmpeg(audioUrl, resolution) + if (real) { + waveformCache.set(cacheKey, real) + return real + } + + // Fall back to procedural + const procedural = generateProcedural(resolution, hashString(audioUrl)) + waveformCache.set(cacheKey, procedural) + return procedural +} + +/** + * Synchronous fallback: get a waveform immediately (from cache or procedural). + * Use this when you need data without waiting for async extraction. + */ +export function getWaveformDataSync( + audioUrl: string, + resolution: number = DEFAULT_RESOLUTION, +): number[] { + const cacheKey = `${audioUrl}:${resolution}` + const cached = waveformCache.get(cacheKey) + if (cached) return cached + + const procedural = generateProcedural(resolution, hashString(audioUrl)) + waveformCache.set(cacheKey, procedural) + return procedural +} + +/** Clear the waveform cache (for memory management) */ +export function clearWaveformCache(): void { + waveformCache.clear() +} diff --git a/tasks/INDEX.md b/tasks/INDEX.md index 0745e18..cb505b2 100644 --- a/tasks/INDEX.md +++ b/tasks/INDEX.md @@ -45,11 +45,16 @@ Each feature has its own directory with: **Tasks:** 5 tasks **Directory:** `tasks/config-persistence/` +### 8. Audio Playback Fix +**Feature:** Fix non-functional volume/speed controls and add multimedia key support +**Tasks:** 5 tasks +**Directory:** `tasks/audio-playback-fix/` + ## Task Summary -**Total Features:** 7 -**Total Tasks:** 27 -**Critical Path:** Feature 7 (Config Persistence) - 5 tasks +**Total Features:** 8 +**Total Tasks:** 32 +**Critical Path:** Feature 7 (Config Persistence) - 5 tasks, Feature 8 (Audio Playback Fix) - 5 tasks ## Task Dependencies @@ -82,11 +87,17 @@ Each feature has its own directory with: - 21 → 22 ### Feature 7: Config Persistence to XDG_CONFIG_HOME -- 23 → 24 -- 23 → 25 -- 24 → 26 -- 25 → 26 -- 26 → 27 +- 23 -> 24 +- 23 -> 25 +- 24 -> 26 +- 25 -> 26 +- 26 -> 27 + +### Feature 8: Audio Playback Fix +- 28 -> 29 +- 29 -> 30 +- 30 -> 31 +- 31 -> 32 ## Priority Overview @@ -95,19 +106,23 @@ Each feature has its own directory with: - 24: Refactor feeds persistence to JSON file - 25: Refactor theme persistence to JSON file - 26: Add config file validation and migration +- 28: Fix volume and speed controls in audio backends +- 32: Test multimedia controls across platforms **P2 (High):** -- All other tasks (01-22, 27) +- All other tasks (01-22, 27, 29-31) **P3 (Medium):** - 09: Optimize waveform rendering performance - 13: Add loading indicator for pagination - 19: Create download queue management +- 30: Add multimedia key detection and handling +- 31: Implement platform-specific media stream integration ## Next Steps 1. Review all task files for accuracy 2. Confirm task dependencies -3. Start with P1 tasks (Feature 7) +3. Start with P1 tasks (Feature 7 or Feature 8) 4. Follow dependency order within each feature 5. Mark tasks complete as they're finished diff --git a/tasks/audio-playback-fix/01-fix-volume-speed-controls.md b/tasks/audio-playback-fix/01-fix-volume-speed-controls.md new file mode 100644 index 0000000..c5a0e27 --- /dev/null +++ b/tasks/audio-playback-fix/01-fix-volume-speed-controls.md @@ -0,0 +1,65 @@ +# 01. Fix volume and speed controls in audio backends + +meta: + id: audio-playback-fix-01 + feature: audio-playback-fix + priority: P1 + depends_on: [] + tags: [implementation, backend-fix, testing-required] + +objective: +- Fix non-functional volume and speed controls in audio player backends (mpv, ffplay, afplay) +- Implement proper error handling and validation for volume/speed commands +- Ensure commands are successfully received and applied by the audio player + +deliverables: +- Fixed `MpvBackend.setVolume()` and `MpvBackend.setSpeed()` methods with proper IPC command validation +- Enhanced `AfplayBackend.setVolume()` and `AfplayBackend.setSpeed()` for runtime changes +- Added command response validation in all backends +- Unit tests for volume and speed control methods + +steps: +- Step 1: Analyze current IPC implementation in MpvBackend (lines 206-223) +- Step 2: Implement proper response validation for setVolume and setSpeed IPC commands +- Step 3: Fix afplay backend to apply volume/speed changes at runtime (currently only on next play) +- Step 4: Add error handling and logging for failed volume/speed commands +- Step 5: Add unit tests in `src/utils/audio-player.test.ts` for volume/speed methods +- Step 6: Verify volume changes apply immediately and persist across playback +- Step 7: Verify speed changes apply immediately and persist across playback + +tests: +- Unit: + - Test MpvBackend.setVolume() sends correct IPC command and receives valid response + - Test MpvBackend.setSpeed() sends correct IPC command and receives valid response + - Test AfplayBackend.setVolume() applies volume immediately + - Test AfplayBackend.setSpeed() applies speed immediately + - Test volume clamp values (0-1 range) + - Test speed clamp values (0.25-3 range) +- Integration: + - Test volume control through Player component UI + - Test speed control through Player component UI + - Test volume/speed changes persist across pause/resume cycles + - Test volume/speed changes persist across track changes + +acceptance_criteria: +- Volume slider in Player component changes volume in real-time +- Speed controls in Player component change playback speed in real-time +- Volume changes are visible in system audio output +- Speed changes are immediately reflected in playback rate +- No errors logged when changing volume or speed +- Volume/speed settings persist when restarting the app + +validation: +- Run `bun test src/utils/audio-player.test.ts` to verify unit tests pass +- Test volume control using Up/Down arrow keys in Player +- Test speed control using 'S' key in Player +- Verify volume level is visible in PlaybackControls component +- Verify speed level is visible in PlaybackControls component +- Check console logs for any IPC errors + +notes: +- mpv backend uses JSON IPC over Unix socket - need to validate response format +- afplay backend needs to restart process for volume/speed changes (current behavior) +- ffplay backend doesn't support runtime volume/speed changes (document limitation) +- Volume and speed state is stored in backend class properties and should be updated on successful commands +- Reference: src/utils/audio-player.ts lines 206-223 (mpv send method), lines 789-791 (afplay setVolume), lines 793-795 (afplay setSpeed) diff --git a/tasks/audio-playback-fix/02-add-multimedia-key-detection.md b/tasks/audio-playback-fix/02-add-multimedia-key-detection.md new file mode 100644 index 0000000..431c264 --- /dev/null +++ b/tasks/audio-playback-fix/02-add-multimedia-key-detection.md @@ -0,0 +1,61 @@ +# 02. Add multimedia key detection and handling + +meta: + id: audio-playback-fix-02 + feature: audio-playback-fix + priority: P2 + depends_on: [] + tags: [implementation, keyboard, multimedia] + +objective: +- Implement detection and handling of multimedia keys (Play/Pause, Next/Previous, Volume Up/Down) +- Create reusable multimedia key handler hook +- Map multimedia keys to audio playback actions + +deliverables: +- New `useMultimediaKeys()` hook in `src/hooks/useMultimediaKeys.ts` +- Integration with existing audio hook to handle multimedia key events +- Documentation of supported multimedia keys and their mappings + +steps: +- Step 1: Research @opentui/solid keyboard event types for multimedia key detection +- Step 2: Create `useMultimediaKeys()` hook with event listener for multimedia keys +- Step 3: Define multimedia key mappings (Play/Pause, Next, Previous, Volume Up, Volume Down) +- Step 4: Integrate hook with audio hook to trigger playback actions +- Step 5: Add keyboard event filtering to prevent conflicts with other shortcuts +- Step 6: Test multimedia key detection across different platforms +- Step 7: Add help text to Player component showing multimedia key bindings + +tests: +- Unit: + - Test multimedia key events are detected correctly + - Test key mapping functions return correct audio actions + - Test hook cleanup removes event listeners +- Integration: + - Test Play/Pause key toggles playback + - Test Next/Previous keys skip tracks (placeholder for future) + - Test Volume Up/Down keys adjust volume + - Test keys don't trigger when input is focused + - Test keys don't trigger when player is not focused + +acceptance_criteria: +- Multimedia keys are detected and logged when pressed +- Play/Pause key toggles audio playback +- Volume Up/Down keys adjust volume level +- Keys work when Player component is focused +- Keys don't interfere with other keyboard shortcuts +- Help text displays multimedia key bindings + +validation: +- Press multimedia keys while Player is focused and verify playback responds +- Check console logs for detected multimedia key events +- Verify Up/Down keys adjust volume display in Player component +- Verify Space key still works for play/pause +- Test in different terminal emulators (iTerm2, Terminal.app, etc.) + +notes: +- Multimedia key detection may vary by platform and terminal emulator +- Common multimedia keys: Space (Play/Pause), ArrowUp (Volume Up), ArrowDown (Volume Down) +- Some terminals don't pass multimedia keys to application +- May need to use platform-specific APIs or terminal emulator-specific key codes +- Reference: @opentui/solid keyboard event types and existing useKeyboard hook patterns diff --git a/tasks/audio-playback-fix/03-implement-platform-media-integration.md b/tasks/audio-playback-fix/03-implement-platform-media-integration.md new file mode 100644 index 0000000..274322a --- /dev/null +++ b/tasks/audio-playback-fix/03-implement-platform-media-integration.md @@ -0,0 +1,66 @@ +# 03. Implement platform-specific media stream integration + +meta: + id: audio-playback-fix-03 + feature: audio-playback-fix + priority: P2 + depends_on: [] + tags: [implementation, platform-integration, media-apis] + +objective: +- Register audio player with platform-specific media frameworks +- Enable OS media controls (notification center, lock screen, multimedia keys) +- Support macOS AVFoundation, Windows Media Foundation, and Linux PulseAudio/GStreamer + +deliverables: +- Platform-specific media registration module in `src/utils/media-registry.ts` +- Integration with audio hook to register/unregister media streams +- Platform detection and conditional registration logic +- Documentation of supported platforms and media APIs + +steps: +- Step 1: Research platform-specific media API integration options +- Step 2: Create `MediaRegistry` class with platform detection +- Step 3: Implement macOS AVFoundation integration (AVPlayer + AVAudioSession) +- Step 4: Implement Windows Media Foundation integration (MediaSession + PlaybackInfo) +- Step 5: Implement Linux PulseAudio/GStreamer integration (Mpris or libpulse) +- Step 6: Integrate with audio hook to register media stream on play +- Step 7: Unregister media stream on stop or dispose +- Step 8: Handle platform-specific limitations and fallbacks +- Step 9: Test media registration across platforms + +tests: +- Unit: + - Test platform detection returns correct platform name + - Test MediaRegistry.register() calls platform-specific APIs + - Test MediaRegistry.unregister() cleans up platform resources +- Integration: + - Test audio player appears in macOS notification center + - Test audio player appears in Windows media controls + - Test audio player appears in Linux media player notifications + - Test media controls update with playback position + - Test multimedia keys control playback through media APIs + +acceptance_criteria: +- Audio player appears in platform media controls (notification center, lock screen) +- Media controls update with current track info and playback position +- Multimedia keys work through media APIs (not just terminal) +- Media registration works on macOS, Windows, and Linux +- Media unregistration properly cleans up resources +- No memory leaks from media stream registration + +validation: +- On macOS: Check notification center for audio player notification +- On Windows: Check media controls in taskbar/notification area +- On Linux: Check media player notifications in desktop environment +- Test multimedia keys work with system media player (not just terminal) +- Monitor memory usage for leaks + +notes: +- Platform-specific media APIs are complex and may have limitations +- macOS AVFoundation: Use AVPlayer with AVAudioSession for media registration +- Windows Media Foundation: Use MediaSession API and PlaybackInfo for media controls +- Linux: Use Mpris (Media Player Remote Interface Specification) or libpulse +- May need additional platform-specific dependencies or native code +- Fallback to terminal multimedia key handling if platform APIs unavailable +- Reference: Platform-specific media API documentation and examples diff --git a/tasks/audio-playback-fix/04-add-media-key-listeners.md b/tasks/audio-playback-fix/04-add-media-key-listeners.md new file mode 100644 index 0000000..13cd8b4 --- /dev/null +++ b/tasks/audio-playback-fix/04-add-media-key-listeners.md @@ -0,0 +1,63 @@ +# 04. Add media key listeners to audio hook + +meta: + id: audio-playback-fix-04 + feature: audio-playback-fix + priority: P2 + depends_on: [] + tags: [implementation, integration, event-handling] + +objective: +- Integrate multimedia key handling with existing audio hook +- Route multimedia key events to appropriate audio control actions +- Ensure proper cleanup of event listeners + +deliverables: +- Updated `useAudio()` hook with multimedia key event handling +- Media key event listener registration in audio hook +- Integration with multimedia key detection hook +- Proper cleanup of event listeners on component unmount + +steps: +- Step 1: Import multimedia key detection hook into audio hook +- Step 2: Register multimedia key event listener in audio hook +- Step 3: Map multimedia key events to audio control actions (play/pause, seek, volume) +- Step 4: Add event listener cleanup on hook dispose +- Step 5: Test event listener cleanup with multiple component instances +- Step 6: Add error handling for failed multimedia key events +- Step 7: Test multimedia key events trigger correct audio actions + +tests: +- Unit: + - Test multimedia key events are captured in audio hook + - Test events are mapped to correct audio control actions + - Test event listeners are properly cleaned up + - Test multiple audio hook instances don't conflict +- Integration: + - Test multimedia keys control playback from any component + - Test multimedia keys work when player is not focused + - Test multimedia keys don't interfere with other keyboard shortcuts + - Test event listeners are removed when audio hook is disposed + +acceptance_criteria: +- Multimedia key events are captured by audio hook +- Multimedia keys trigger correct audio control actions +- Event listeners are properly cleaned up on unmount +- No duplicate event listeners when components re-render +- No memory leaks from event listeners +- Error handling prevents crashes from invalid events + +validation: +- Use multimedia keys and verify audio responds correctly +- Unmount and remount audio hook to test cleanup +- Check for memory leaks with browser dev tools or system monitoring +- Verify event listener count is correct after cleanup +- Test with multiple Player components to ensure no conflicts + +notes: +- Audio hook is a singleton, so event listeners should be registered once +- Multimedia key detection hook should be reused to avoid duplicate listeners +- Event listener cleanup should use onCleanup from solid-js +- Reference: src/hooks/useAudio.ts for event listener patterns +- Multimedia keys may only work when terminal is focused (platform limitation) +- Consider adding platform-specific key codes for better compatibility diff --git a/tasks/audio-playback-fix/05-test-multimedia-controls.md b/tasks/audio-playback-fix/05-test-multimedia-controls.md new file mode 100644 index 0000000..0c45ae6 --- /dev/null +++ b/tasks/audio-playback-fix/05-test-multimedia-controls.md @@ -0,0 +1,78 @@ +# 05. Test multimedia controls across platforms + +meta: + id: audio-playback-fix-05 + feature: audio-playback-fix + priority: P1 + depends_on: [] + tags: [testing, integration, cross-platform] + +objective: +- Comprehensive testing of volume/speed controls and multimedia key support +- Verify platform-specific media integration works correctly +- Validate all controls across different audio backends + +deliverables: +- Test suite for volume/speed controls in `src/utils/audio-player.test.ts` +- Integration tests for multimedia key handling in `src/hooks/useMultimediaKeys.test.ts` +- Platform-specific integration tests in `src/utils/media-registry.test.ts` +- Test coverage report showing all features tested + +steps: +- Step 1: Run existing unit tests for audio player backends +- Step 2: Add volume control tests (setVolume, volume clamp, persistence) +- Step 3: Add speed control tests (setSpeed, speed clamp, persistence) +- Step 4: Create integration test for multimedia key handling +- Step 5: Test volume/speed controls with Player component UI +- Step 6: Test multimedia keys with Player component UI +- Step 7: Test platform-specific media integration on each platform +- Step 8: Test all controls across mpv, ffplay, and afplay backends +- Step 9: Document any platform-specific limitations or workarounds + +tests: +- Unit: + - Test volume control methods in all backends + - Test speed control methods in all backends + - Test volume clamp logic (0-1 range) + - Test speed clamp logic (0.25-3 range) + - Test multimedia key detection + - Test event listener cleanup +- Integration: + - Test volume control via Player component UI + - Test speed control via Player component UI + - Test multimedia keys via keyboard + - Test volume/speed persistence across pause/resume + - Test volume/speed persistence across track changes +- Cross-platform: + - Test volume/speed controls on macOS + - Test volume/speed controls on Linux + - Test volume/speed controls on Windows + - Test multimedia keys on each platform + - Test media registration on each platform + +acceptance_criteria: +- All unit tests pass with >90% code coverage +- All integration tests pass +- Volume controls work correctly on all platforms +- Speed controls work correctly on all platforms +- Multimedia keys work on all platforms +- Media controls appear on all supported platforms +- All audio backends (mpv, ffplay, afplay) work correctly +- No regressions in existing audio functionality + +validation: +- Run full test suite: `bun test` +- Check test coverage: `bun test --coverage` +- Manually test volume controls on each platform +- Manually test speed controls on each platform +- Manually test multimedia keys on each platform +- Verify media controls appear on each platform +- Check for any console errors or warnings + +notes: +- Test suite should cover all audio backend implementations +- Integration tests should verify UI controls work correctly +- Platform-specific tests should run on actual platform if possible +- Consider using test doubles for platform-specific APIs +- Document any platform-specific issues or limitations found +- Reference: Test patterns from existing test files in src/utils/ diff --git a/tasks/audio-playback-fix/README.md b/tasks/audio-playback-fix/README.md new file mode 100644 index 0000000..b5c2895 --- /dev/null +++ b/tasks/audio-playback-fix/README.md @@ -0,0 +1,26 @@ +# Audio Playback Fix + +Objective: Fix volume and speed controls and add multimedia key support with platform media stream integration + +Status legend: [ ] todo, [~] in-progress, [x] done + +Tasks +- [ ] 01 — Fix volume and speed controls in audio backends → `01-fix-volume-speed-controls.md` +- [ ] 02 — Add multimedia key detection and handling → `02-add-multimedia-key-detection.md` +- [ ] 03 — Implement platform-specific media stream integration → `03-implement-platform-media-integration.md` +- [ ] 04 — Add media key listeners to audio hook → `04-add-media-key-listeners.md` +- [ ] 05 — Test multimedia controls across platforms → `05-test-multimedia-controls.md` + +Dependencies +- 01 depends on 02 +- 02 depends on 03 +- 03 depends on 04 +- 04 depends on 05 + +Exit criteria +- Volume controls change playback volume in real-time +- Speed controls change playback speed in real-time +- Multimedia keys (Space, Arrow keys, Volume keys, Media keys) control playback +- Audio player appears in system media controls +- System multimedia keys trigger appropriate playback actions +- All controls work across mpv, ffplay, and afplay backends