diff --git a/.brain/1-agent-smith/b-features/07-floating-video-player/07-floating-video-player.md b/.brain/1-agent-smith/b-features/07-floating-video-player/07-floating-video-player.md deleted file mode 100644 index e9c6ea21..00000000 --- a/.brain/1-agent-smith/b-features/07-floating-video-player/07-floating-video-player.md +++ /dev/null @@ -1,176 +0,0 @@ -# Feature Task Plan - -## Feature: Floating Video Player - -## Task: Create a floating video player component that follows users while scrolling - -## Status: ā­• Planning - -## Last Updated: 2024-05-01 - -## Related Documentation: -- Feature Index: ../../../../docs/features/floating-video-player/floating-video-player.index.md -- Technical Details Doc: ../../../../docs/features/floating-video-player/technical-details.md - -## 1. Overview - -Create a responsive floating video player component that transitions a YouTube video to a smaller player in the bottom-right corner when a user scrolls past the original media section to the testimonials section. The floating player should maintain the same video playback state while allowing the user to continue reading testimonials. - -## 2. Codebase Analysis - -### 2.1. Key Files & Modules - -* `src/shared-components/sections/Bio/Bio.tsx`: Main Bio component that contains the FeaturedMedia and Testimonials sections -* `src/shared-components/sections/Bio/Bio.constants.ts`: Contains the MEDIA_ITEMS array with YouTube video data -* `src/shared-components/sections/Bio/Bio.styles.ts`: Contains the styled components for the Bio section -* `src/shared-components/sections/Bio/sub-components/FeaturedMedia/FeaturedMedia.tsx`: Component that renders the YouTube videos -* `src/shared-components/sections/Bio/sub-components/Testimonials/Testimonials.tsx`: Testimonials section that appears below the media section -* `src/utils/animations/migration-helpers.ts`: Contains animation utilities that might be useful - -### 2.2. Dependencies - -* `framer-motion`: Current animation library used in the project -* `styled-components`: CSS-in-JS library used for styling components -* `react`: Core React library with hooks like useState, useEffect, useRef - -### 2.3. Potential Concerns - -* Ensuring that the floating player maintains the same video that is currently playing -* Handling the transition smoothly without interrupting video playback -* Ensuring the floating player doesn't interfere with other UI elements -* [ ] Mark as addressed - -## 3. Architectural Considerations - -### 3.1. Selected Paradigm - -* React Context + Hooks - A Context can maintain the state of which video is playing across components, while hooks can manage intersection observation and animation states. -* [ ] Confirmed with the user - -### 3.2. Selected Design Patterns - -* Observer Pattern (via Intersection Observer API) - To detect when the user scrolls past the original video player. -* [ ] Confirmed with the user -* Provider Pattern - To share state about the currently playing video across components. -* [ ] Confirmed with the user - -### 3.3. Architectural Considerations & Rationale - -The floating video player feature requires tracking which video is currently playing and the scroll position relative to the video and testimonials sections. We'll use the Intersection Observer API to efficiently detect when the original video player leaves the viewport, triggering the floating player to appear. - -Using a React Context makes sense here since we need to share state (current playing video) between separate components (original media player and floating player). This maintains a single source of truth about which video is playing. - -We'll create: -1. A `VideoPlayerContext` to track which video is currently playing -2. A custom hook `useFloatingVideo` that manages the floating player state, position, and animations -3. A `FloatingVideoPlayer` component that renders the minimized player when appropriate - -This architecture separates concerns, promotes reusability, and maintains a clean component structure without unnecessary prop drilling. - -* [ ] Confirmed with the user - -## 4. Project Task List Foresight - -### 4.1. Downstream Impacts - -* The floating video player could be reused for other video content elsewhere in the website -* This feature could be extended to support other media types in the future -* [ ] Reviewed and confirmed no negative impacts - -### 4.2. Future-Proofing Considerations - -* Design the video context and hooks to be generic enough for future reuse -* Allow for customization of the floating player size, position, and behavior -* Include options to close/dismiss the floating player -* [ ] Discussed with the user and incorporated feedback - -## 5. Testing Strategy - -### 5.1. Available Testing Options - -* `[x] Unit Tests` - * Location: `src/__tests__/unit` - * Command to run all tests: `npm test` or `npm run test:unit` - * Command to run a single test: `npm test -- -t "test name"` - * Relevant Knowledge: `Read .brain/knowledge/unit-testing-guide` (if applicable) -* `[x] Integration Tests` - * Location: `src/__tests__/integration` - * Command to run all tests: `npm run test:integration` - * Command to run a single test: `npm run test:integration -- -t "test name"` - * Relevant Knowledge: `Read .brain/knowledge/integration-testing-guide` (if applicable) -* `[ ] End-to-End (E2E) Tests` - * Location: Not applicable for this feature - * Command to run all tests: N/A - * Command to run a single test: N/A - * Relevant Knowledge: N/A -* `[x] Visual Regression Tests (Storybook)` - * Location: `src/shared-components/organisms/FloatingVideoPlayer/FloatingVideoPlayer.stories.tsx` - * Command to run tests: `npm run storybook` - * Relevant Knowledge: `.brain/knowledge/storybook-visual-testing-guide` -* `[x] Storybook Interaction Tests` - * Location: `src/shared-components/organisms/FloatingVideoPlayer/FloatingVideoPlayer.stories.tsx` - * Command to run tests: `npm run storybook:test` - * Relevant Knowledge: `.brain/knowledge/storybook-interaction-testing-guide` - -### 5.2. Selected Testing Approach - -We will focus on unit tests, Storybook visual tests, and Storybook interaction tests for this feature. Unit tests will validate the core logic of the context and hooks, while Storybook tests will confirm the visual appearance and interactions of the component. This approach provides good coverage without overcomplicating the testing process. - -* [ ] Confirmed testing approach aligns with project standards. - -## 6. MECE Task Breakdown & TDD Plan - -### 6.1. Subtask 1: Create VideoPlayerContext - -* `[ ]` Task completed. -* `[ ]` Test cases: - * Test that the context can store and update the currently playing video - * Test that the context provides the correct initial state - * Test that the context properly updates when a new video is selected -* `[ ]` Test cases reviewed and approved. -* Relevant Knowledge: `React Context API` -* Testing Type: Unit - -### 6.2. Subtask 2: Implement useFloatingVideo custom hook - -* `[ ]` Task completed. -* `[ ]` Test cases: - * Test hook returns correct state based on intersection observer - * Test hook properly calculates position for the floating player - * Test transition states are correctly updated based on scroll position -* `[ ]` Test cases reviewed and approved. -* Relevant Knowledge: `Intersection Observer API, React Hooks` -* Testing Type: Unit - -### 6.3. Subtask 3: Create FloatingVideoPlayer component - -* `[ ]` Task completed. -* `[ ]` Test cases: - * Test component renders correctly when a video is playing - * Test component doesn't render when no video is playing - * Test appearance/disappearance animations work correctly - * Test the floating player maintains the same video that was playing in the original player -* `[ ]` Test cases reviewed and approved. -* Relevant Knowledge: `Framer Motion, Styled Components` -* Testing Type: Storybook Visual/Interaction - -### 6.4. Subtask 4: Integrate FloatingVideoPlayer with FeaturedMedia and Bio components - -* `[ ]` Task completed. -* `[ ]` Test cases: - * Test integration between FeaturedMedia and FloatingVideoPlayer components - * Test that playing a video in FeaturedMedia and scrolling down triggers the floating player - * Test that the floating player updates when a different video is selected -* `[ ]` Test cases reviewed and approved. -* Testing Type: Integration - -### 6.5. Subtask 5: Add controls and UX improvements to FloatingVideoPlayer - -* `[ ]` Task completed. -* `[ ]` Test cases: - * Test close button properly dismisses the floating player - * Test maximize button returns user to the original video location - * Test that player remains visible while scrolling through testimonials - * Test that player is properly responsive on different screen sizes -* `[ ]` Test cases reviewed and approved. -* Testing Type: Storybook Interaction \ No newline at end of file diff --git a/public/audio/music-ducked/aluzion-fields.mp3 b/public/audio/music-ducked/aluzion-fields.mp3 new file mode 100644 index 00000000..251d5127 Binary files /dev/null and b/public/audio/music-ducked/aluzion-fields.mp3 differ diff --git a/public/audio/music-ducked/booty-dance-of-the-sugar-plum-fairy.mp3 b/public/audio/music-ducked/booty-dance-of-the-sugar-plum-fairy.mp3 new file mode 100644 index 00000000..9f8162df Binary files /dev/null and b/public/audio/music-ducked/booty-dance-of-the-sugar-plum-fairy.mp3 differ diff --git a/public/audio/music-ducked/casual-zombie-gameplay-iphone.mp3 b/public/audio/music-ducked/casual-zombie-gameplay-iphone.mp3 new file mode 100644 index 00000000..afa62d7a Binary files /dev/null and b/public/audio/music-ducked/casual-zombie-gameplay-iphone.mp3 differ diff --git a/public/audio/music-ducked/chill-out-ya-merry-gentleman.mp3 b/public/audio/music-ducked/chill-out-ya-merry-gentleman.mp3 new file mode 100644 index 00000000..29b1737d Binary files /dev/null and b/public/audio/music-ducked/chill-out-ya-merry-gentleman.mp3 differ diff --git a/public/audio/music-ducked/epic-battle-game-opening-credits.mp3 b/public/audio/music-ducked/epic-battle-game-opening-credits.mp3 new file mode 100644 index 00000000..fd1d1277 Binary files /dev/null and b/public/audio/music-ducked/epic-battle-game-opening-credits.mp3 differ diff --git a/public/audio/music-ducked/exotic-traveling-game-cut-scene-dark.mp3 b/public/audio/music-ducked/exotic-traveling-game-cut-scene-dark.mp3 new file mode 100644 index 00000000..07ce962d Binary files /dev/null and b/public/audio/music-ducked/exotic-traveling-game-cut-scene-dark.mp3 differ diff --git a/public/audio/music-ducked/exotic-traveling-game-cut-scene-light.mp3 b/public/audio/music-ducked/exotic-traveling-game-cut-scene-light.mp3 new file mode 100644 index 00000000..8dc3693c Binary files /dev/null and b/public/audio/music-ducked/exotic-traveling-game-cut-scene-light.mp3 differ diff --git a/public/audio/music-ducked/frenetic-puzzle-game-gameplay.mp3 b/public/audio/music-ducked/frenetic-puzzle-game-gameplay.mp3 new file mode 100644 index 00000000..d5681ed6 Binary files /dev/null and b/public/audio/music-ducked/frenetic-puzzle-game-gameplay.mp3 differ diff --git a/public/audio/music-ducked/hop-trippin-the-bells.mp3 b/public/audio/music-ducked/hop-trippin-the-bells.mp3 new file mode 100644 index 00000000..c7cdac9d Binary files /dev/null and b/public/audio/music-ducked/hop-trippin-the-bells.mp3 differ diff --git a/public/audio/music-ducked/identity-conflict-z-chamber-trio.mp3 b/public/audio/music-ducked/identity-conflict-z-chamber-trio.mp3 new file mode 100644 index 00000000..0f0f6334 Binary files /dev/null and b/public/audio/music-ducked/identity-conflict-z-chamber-trio.mp3 differ diff --git a/public/audio/music-ducked/it-s-a-wonderful-life-for-kings.mp3 b/public/audio/music-ducked/it-s-a-wonderful-life-for-kings.mp3 new file mode 100644 index 00000000..4ad90529 Binary files /dev/null and b/public/audio/music-ducked/it-s-a-wonderful-life-for-kings.mp3 differ diff --git a/public/audio/music-ducked/lielexlium.mp3 b/public/audio/music-ducked/lielexlium.mp3 new file mode 100644 index 00000000..38adbd2f Binary files /dev/null and b/public/audio/music-ducked/lielexlium.mp3 differ diff --git a/public/audio/music-ducked/organica-for-solo-violin.mp3 b/public/audio/music-ducked/organica-for-solo-violin.mp3 new file mode 100644 index 00000000..8f4e0150 Binary files /dev/null and b/public/audio/music-ducked/organica-for-solo-violin.mp3 differ diff --git a/public/audio/music-ducked/reality-tunnel.mp3 b/public/audio/music-ducked/reality-tunnel.mp3 new file mode 100644 index 00000000..8e10df07 Binary files /dev/null and b/public/audio/music-ducked/reality-tunnel.mp3 differ diff --git a/public/audio/music-ducked/requiem-in-memory-of-a-dear-friend.mp3 b/public/audio/music-ducked/requiem-in-memory-of-a-dear-friend.mp3 new file mode 100644 index 00000000..5fafc9a4 Binary files /dev/null and b/public/audio/music-ducked/requiem-in-memory-of-a-dear-friend.mp3 differ diff --git a/public/audio/music-ducked/scarlet-harvest.mp3 b/public/audio/music-ducked/scarlet-harvest.mp3 new file mode 100644 index 00000000..f3474831 Binary files /dev/null and b/public/audio/music-ducked/scarlet-harvest.mp3 differ diff --git a/public/audio/music-ducked/sci-fi-first-person-shooter-gameplay.mp3 b/public/audio/music-ducked/sci-fi-first-person-shooter-gameplay.mp3 new file mode 100644 index 00000000..ddcf4ed2 Binary files /dev/null and b/public/audio/music-ducked/sci-fi-first-person-shooter-gameplay.mp3 differ diff --git a/public/audio/music-ducked/sci-fi-first-person-shooter-opening-credits.mp3 b/public/audio/music-ducked/sci-fi-first-person-shooter-opening-credits.mp3 new file mode 100644 index 00000000..017105c1 Binary files /dev/null and b/public/audio/music-ducked/sci-fi-first-person-shooter-opening-credits.mp3 differ diff --git a/public/audio/music-ducked/sonata-no.1-for-string-orchestra.mp3 b/public/audio/music-ducked/sonata-no.1-for-string-orchestra.mp3 new file mode 100644 index 00000000..49859bd2 Binary files /dev/null and b/public/audio/music-ducked/sonata-no.1-for-string-orchestra.mp3 differ diff --git a/public/audio/music-ducked/warrior-prepares-for-battle-game-cut-scene.mp3 b/public/audio/music-ducked/warrior-prepares-for-battle-game-cut-scene.mp3 new file mode 100644 index 00000000..8c17b7c0 Binary files /dev/null and b/public/audio/music-ducked/warrior-prepares-for-battle-game-cut-scene.mp3 differ diff --git a/public/audio/music-ducked/where-roads-end-mixed-chamber-ensemble.mp3 b/public/audio/music-ducked/where-roads-end-mixed-chamber-ensemble.mp3 new file mode 100644 index 00000000..db27dea2 Binary files /dev/null and b/public/audio/music-ducked/where-roads-end-mixed-chamber-ensemble.mp3 differ diff --git a/scripts/process-audio.js b/scripts/process-audio.js new file mode 100755 index 00000000..4688a7ad --- /dev/null +++ b/scripts/process-audio.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * Processes all MP3s in /public/audio/music and outputs ducked versions to /public/audio/music-ducked + * Applies EBU loudness normalization, dynamic range compression, and aggressive volume reduction (-18dB). + * Prints LUFS of original and ducked for validation. + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { promisify } = require('util'); + +const readdir = promisify(fs.readdir); +const mkdir = promisify(fs.mkdir); + +const SOURCE_DIR = path.join(process.cwd(), 'public', 'audio', 'music'); +const TARGET_DIR = path.join(process.cwd(), 'public', 'audio', 'music-ducked'); + +// Ducking config per track +const duckingConfig = { + 'reality-tunnel.mp3': { category: 'electronic', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'frenetic-puzzle-game-gameplay.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'lielexlium.mp3': { category: 'electronic', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'organica-for-solo-violin.mp3': { category: 'classical', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'epic-battle-game-opening-credits.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'exotic-traveling-game-cut-scene-light.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'exotic-traveling-game-cut-scene-dark.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'warrior-prepares-for-battle-game-cut-scene.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'booty-dance-of-the-sugar-plum-fairy.mp3': { category: 'electronic', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'where-roads-end-mixed-chamber-ensemble.mp3': { category: 'classical', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'hop-trippin-the-bells.mp3': { category: 'electronic', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'casual-zombie-gameplay-iphone.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'requiem-in-memory-of-a-dear-friend.mp3': { category: 'classical', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'sci-fi-first-person-shooter-opening-credits.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'sonata-no.1-for-string-orchestra.mp3': { category: 'classical', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'aluzion-fields.mp3': { category: 'electronic', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'sci-fi-first-person-shooter-gameplay.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'identity-conflict-z-chamber-trio.mp3': { category: 'classical', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'it-s-a-wonderful-life-for-kings.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'chill-out-ya-merry-gentleman.mp3': { category: 'electronic', compressionFilter: 'dynaudnorm=f=250:g=8' }, + 'scarlet-harvest.mp3': { category: 'film', compressionFilter: 'dynaudnorm=f=250:g=8' }, +}; + +// Check for ffmpeg +try { + execSync('ffmpeg -version', { stdio: 'ignore' }); +} catch (err) { + console.error('āŒ ffmpeg is not installed or not in PATH.'); + process.exit(1); +} + +// Measure LUFS using loudnorm in analysis mode +function getLUFS(filePath) { + try { + const output = execSync( + `ffmpeg -hide_banner -i "${filePath}" -af loudnorm=I=-23:LRA=11:TP=-1.5:print_format=summary -f null -`, + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + ); + const match = output.match(/Input Integrated:\s*(-?\d+(\.\d+)?) LUFS/); + return match ? parseFloat(match[1]) : null; + } catch { + return null; + } +} + +async function processAudioFiles() { + console.log('šŸŽµ Ducking and compressing audio files...'); + + await mkdir(TARGET_DIR, { recursive: true }); + + let files = await readdir(SOURCE_DIR); + files = files.filter(f => f.endsWith('.mp3')); + + if (files.length === 0) { + console.log('āš ļø No MP3 files found.'); + return; + } + + let success = 0; + let fail = 0; + + for (const file of files) { + const sourcePath = path.join(SOURCE_DIR, file); + const targetPath = path.join(TARGET_DIR, file); + const config = duckingConfig[file]; + + if (!config) { + console.warn(`āš ļø Skipping ${file} — no config`); + continue; + } + + // TESTING: Aggressive ducking to actually hear volume change + const filter = `loudnorm=I=-24:LRA=7:TP=-2,${config.compressionFilter},volume=-18dB`; + + try { + console.log(`šŸŽ§ Processing: ${file}`); + execSync(`ffmpeg -y -i "${sourcePath}" -af "${filter}" "${targetPath}"`, { stdio: 'ignore' }); + + const originalLUFS = getLUFS(sourcePath); + const duckedLUFS = getLUFS(targetPath); + + console.log(` šŸŽš Original LUFS: ${originalLUFS ?? 'N/A'} → Ducked LUFS: ${duckedLUFS ?? 'N/A'}`); + success++; + } catch (err) { + console.error(`āŒ Failed: ${file} — ${err.message}`); + fail++; + } + } + + console.log('\nāœ… Success:', success); + console.log('āŒ Failures:', fail); +} + +processAudioFiles().catch(err => { + console.error(`āŒ Unhandled error: ${err.message}`); + process.exit(1); +}); diff --git a/src/components/ClientLayout.tsx b/src/components/ClientLayout.tsx index 4015e72f..4560030b 100644 --- a/src/components/ClientLayout.tsx +++ b/src/components/ClientLayout.tsx @@ -54,13 +54,12 @@ export default function ClientLayout({ return ( ({ main: { backgroundColor: 'var(--background-light)', transition: 'background-color 200ms ease', flexGrow: 1, - minHeight: `calc(100vh - ${HEADER_HEIGHT + FOOTER_HEIGHT}px)`, + minHeight: `calc(100vh - ${HEADER_HEIGHT}px)`, }, root: { backgroundColor: 'var(--background-light)', diff --git a/src/shared-components/molecules/PillControlBar.tsx b/src/shared-components/molecules/PillControlBar.tsx index 991f6df9..9521eff7 100644 --- a/src/shared-components/molecules/PillControlBar.tsx +++ b/src/shared-components/molecules/PillControlBar.tsx @@ -1,19 +1,25 @@ "use client"; -import { useMantineTheme, Flex, Group, ActionIcon, Slider, Box } from '@mantine/core'; -import { LuHeadphones, LuMusic, LuListMusic, LuChevronDown } from 'react-icons/lu'; +import { useMantineTheme, Flex, Group, ActionIcon, Slider, Box, Tooltip, Switch } from '@mantine/core'; +import { LuMusic, LuListMusic, LuChevronDown } from 'react-icons/lu'; +import { MdRecordVoiceOver, MdMusicNote, MdVoiceOverOff, MdMusicOff } from 'react-icons/md'; interface PillControlBarProps { - voiceVolume: number; - musicVolume: number; - onVoiceVolumeChange: (v: number) => void; - onMusicVolumeChange: (v: number) => void; + voiceVolume?: number; + musicVolume?: number; + onVoiceVolumeChange?: (v: number) => void; + onMusicVolumeChange?: (v: number) => void; onPlaylistClick: () => void; onMinimizeClick: () => void; isNarrationEnabled: boolean; isMusicEnabled: boolean; + isNarrationPlaying?: boolean; + isMusicPlaying?: boolean; onToggleNarration: () => void; onToggleMusic: () => void; + controlMode?: 'music' | 'narration'; + onControlModeToggle?: () => void; + showControlModeToggle?: boolean; colorScheme?: 'light' | 'dark'; sliderWidth?: number; gap?: number; @@ -28,8 +34,13 @@ export const PillControlBar = ({ onMinimizeClick, isNarrationEnabled, isMusicEnabled, + isNarrationPlaying = false, + isMusicPlaying = false, onToggleNarration, onToggleMusic, + controlMode = 'music', + onControlModeToggle, + showControlModeToggle = false, colorScheme = 'light', sliderWidth = 80, gap = 6, @@ -78,23 +89,59 @@ export const PillControlBar = ({ - + {isNarrationEnabled ? (isNarrationPlaying ? : ) : } - + {voiceVolume !== undefined && onVoiceVolumeChange && ( + + )} + {/* Control Mode Toggle Button (New) -> Switch */} + {showControlModeToggle && onControlModeToggle && ( + + } // Icon for narration (on) + offLabel={} // Icon for music (off) + aria-label="Toggle playback mode between music and narration" + styles={{ + track: { + backgroundColor: controlMode === 'music' ? theme.colors.blue[6] : undefined, // Set inactive track to blue + borderColor: controlMode === 'music' ? theme.colors.blue[6] : undefined, // Match border + }, + }} + /> + {/* + + */} + + )} + {/* Music */} - + {isMusicEnabled ? (isMusicPlaying ? : ) : } - {/* Playlist */} diff --git a/src/shared-components/organisms/Footer/Footer.hook.ts b/src/shared-components/organisms/Footer/Footer.hook.ts index 8cfc1f65..d15ab00f 100644 --- a/src/shared-components/organisms/Footer/Footer.hook.ts +++ b/src/shared-components/organisms/Footer/Footer.hook.ts @@ -3,7 +3,7 @@ import { useColorScheme } from '@mantine/hooks'; import { useMantineTheme } from '@mantine/core'; import { SocialLink, SoundCloudTrack } from './Footer.types'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useDualAudio } from './components/dual-audio/DualAudioContext'; import { musicPlaylist } from './components/dual-audio/playlists/musicPlaylist'; import { AudioTrack } from './components/dual-audio/DualAudio.types'; @@ -255,6 +255,7 @@ export const useFooter = ( export const useFooterStatefulLogic = () => { const dualAudio = useDualAudio(); const footerUI = useFooterUI({}); + const [lastPlayingState, setLastPlayingState] = useState<'music' | 'narration' | 'both' | 'none'>('none'); const { musicAudioRef, @@ -322,14 +323,66 @@ export const useFooterStatefulLogic = () => { const handlePlayPause = useCallback(() => { startUserInteraction?.(); - if (isMusicPlaying || isVoicePlaying) { - if (isMusicEnabled) pauseMusic(); - if (isNarrationEnabled) pauseVoice(); + + console.log('handlePlayPause Triggered', { + isMusicEnabled, + isNarrationEnabled, + isMusicPlaying, + isVoicePlaying, + }); + + const musicWasPlaying = isMusicEnabled && isMusicPlaying; + const voiceWasPlaying = isNarrationEnabled && isVoicePlaying; + const anythingWasPlaying = musicWasPlaying || voiceWasPlaying; + + if (anythingWasPlaying) { + // --- PAUSE LOGIC --- + let stateToStore: 'music' | 'narration' | 'both' | 'none' = 'none'; + if (musicWasPlaying && voiceWasPlaying) { + stateToStore = 'both'; + } else if (musicWasPlaying) { + stateToStore = 'music'; + } else if (voiceWasPlaying) { + stateToStore = 'narration'; + } + setLastPlayingState(stateToStore); // Store state before pausing + + // Perform pause actions + if (musicWasPlaying) pauseMusic(); + if (voiceWasPlaying) pauseVoice(); + } else { - if (isMusicEnabled) playMusic(); - if (isNarrationEnabled) playVoice(); + // --- PLAY LOGIC --- + // Play based on the state stored just before the last pause + switch (lastPlayingState) { + case 'music': + if (isMusicEnabled) playMusic(); + break; + case 'narration': + if (isNarrationEnabled) playVoice(); + break; + case 'both': + if (isMusicEnabled) playMusic(); + if (isNarrationEnabled) playVoice(); + break; + case 'none': // Nothing was playing before, or initial state + default: + // Fallback: Play based on current *enabled* status if nothing was playing before + if (isMusicEnabled && isNarrationEnabled) { + playMusic(); // Play both if both enabled + playVoice(); + } else if (isMusicEnabled) { + playMusic(); // Play music if only music enabled + } else if (isNarrationEnabled) { + playVoice(); // Play voice if only narration enabled + } + // If both disabled, do nothing + break; + } + // Optional: Reset lastPlayingState after playing? + // setLastPlayingState('none'); // Let's not reset for now, keep memory until next pause. } - }, [isMusicPlaying, isVoicePlaying, isMusicEnabled, isNarrationEnabled, pauseMusic, pauseVoice, playMusic, playVoice, startUserInteraction]); + }, [isMusicEnabled, isNarrationEnabled, isMusicPlaying, isVoicePlaying, pauseMusic, pauseVoice, playMusic, playVoice, startUserInteraction, lastPlayingState]); const handleSeek = useCallback((newProgress: number) => { startUserInteraction?.(); @@ -391,5 +444,6 @@ export const useFooterStatefulLogic = () => { handleNextMusicTrack, handlePrevMusicTrack, miniPlayerDisplayTitle, + lastPlayingState, }; }; \ No newline at end of file diff --git a/src/shared-components/organisms/Footer/Footer.tsx b/src/shared-components/organisms/Footer/Footer.tsx index 0890d579..30454f8b 100644 --- a/src/shared-components/organisms/Footer/Footer.tsx +++ b/src/shared-components/organisms/Footer/Footer.tsx @@ -124,13 +124,13 @@ export const Footer = ({ socialLinks }: FooterProps) => { currentTime={activeTiming.current} duration={activeTiming.duration} onPlayToggle={handlePlayPause} - onSeekMusic={(progress) => { + onSeekMusic={(progress: number) => { if (musicDuration > 0) { const time = (progress / 100) * musicDuration; seekMusic(time); } }} - onSeekNarration={(progress) => { + onSeekNarration={(progress: number) => { if (voiceDuration > 0) { const time = (progress / 100) * voiceDuration; seekVoice(time); diff --git a/src/shared-components/organisms/Footer/Footer.ui.styles.ts b/src/shared-components/organisms/Footer/Footer.ui.styles.ts index 3dd6cc56..170fed31 100644 --- a/src/shared-components/organisms/Footer/Footer.ui.styles.ts +++ b/src/shared-components/organisms/Footer/Footer.ui.styles.ts @@ -77,7 +77,7 @@ export const getFooterContainerStyle = ( borderTop: `1px solid ${colors.border}`, color: colors.text, boxShadow: isMiniMode ? '0 -1px 3px rgba(0,0,0,0.1)' : '0 -2px 10px rgba(0,0,0,0.15)', - height: 'auto', + height: isMiniMode ? 'auto' : (isExpanded ? 'auto' : '90px'), minHeight: isMiniMode ? '64px' : '90px', transition: 'height 0.3s ease', overflow: 'hidden', diff --git a/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.styles.ts b/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.styles.ts index b788d606..9fe0351c 100644 --- a/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.styles.ts +++ b/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.styles.ts @@ -1,12 +1,17 @@ import styled from 'styled-components'; -import { rem } from '@mantine/core'; +import { rem, MantineColorScheme } from '@mantine/core'; -export const MiniPlayerContainer = styled.div` +interface MiniPlayerContainerProps { + $colorScheme: MantineColorScheme; +} + +export const MiniPlayerContainer = styled.div` display: flex; flex-direction: column; justify-content: center; width: 100%; - background-color: ${({ theme }) => theme.colors.dark[8]}; + background-color: ${({ theme, $colorScheme }) => + $colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0]}; padding-bottom: env(safe-area-inset-bottom, 0px); `; diff --git a/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.tsx b/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.tsx index 43c091bc..6e91d1cc 100644 --- a/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.tsx +++ b/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.tsx @@ -28,7 +28,7 @@ export const MiniPlayer = ({ const displayArtwork = artworkUrl || currentTrack?.artwork; return ( - + {isMobile ? ( <> diff --git a/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.types.ts b/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.types.ts index 76edfd49..87854542 100644 --- a/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.types.ts +++ b/src/shared-components/organisms/Footer/components/MiniPlayer/MiniPlayer.types.ts @@ -1,4 +1,5 @@ import { RefObject } from 'react'; +import { MantineColorScheme } from '@mantine/core'; // import { SoundCloudTrack } from '../../Footer.types'; // Remove old type import { AudioTrack } from '../dual-audio/DualAudio.types'; // Import new type @@ -8,7 +9,7 @@ export interface MiniPlayerProps { progress: number; colors: any; progressBarRef: RefObject; - colorScheme: string; + colorScheme: MantineColorScheme; onPlayToggle: () => void; onMinimizeToggle: () => void; startUserInteraction?: () => void; // Make optional if not always passed diff --git a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.hook.ts b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.hook.ts index e69ca8a3..b87f3dcb 100644 --- a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.hook.ts +++ b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.hook.ts @@ -54,11 +54,27 @@ export function useStandardPlayerMobile(props: StandardPlayerProps) { // Always use music playlist navigation for arrows const handlePrevTrack = () => { props.startUserInteraction?.(); - props.onPrevTrack(); + if (controlMode === 'narration') { + const newTime = Math.max(0, props.voiceCurrentTime - 10); + if (props.voiceDuration > 0) { + const newProgress = (newTime / props.voiceDuration) * 100; + props.onSeekNarration(newProgress); + } + } else { + props.onPrevTrack(); + } }; const handleNextTrack = () => { props.startUserInteraction?.(); - props.onNextTrack(); + if (controlMode === 'narration') { + const newTime = Math.min(props.voiceDuration, props.voiceCurrentTime + 10); + if (props.voiceDuration > 0) { + const newProgress = (newTime / props.voiceDuration) * 100; + props.onSeekNarration(newProgress); + } + } else { + props.onNextTrack(); + } }; return { diff --git a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.logic.ts b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.logic.ts index 04fbc51b..9da25a20 100644 --- a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.logic.ts +++ b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.logic.ts @@ -2,8 +2,8 @@ import { StandardPlayerProps } from './StandardPlayer.types'; export function getDisplayTitle(controlMode: 'music' | 'narration', isMusicEnabled: boolean, isNarrationEnabled: boolean, activeMusicTrack: any, activeVoiceTrack: any) { if (controlMode === 'narration') { - if (isMusicEnabled && isNarrationEnabled && activeMusicTrack?.title) { - return `Narration + ${activeMusicTrack.title}`; + if (activeVoiceTrack?.title) { + return `Narration + ${activeVoiceTrack.title}`; } else { return 'Narration'; } diff --git a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.tsx b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.tsx index 447dfd36..a402423e 100644 --- a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.tsx +++ b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.mobile.tsx @@ -1,7 +1,10 @@ "use client"; -import { Box, Text, ActionIcon, Group, Tooltip, Flex, Center, Button } from '@mantine/core'; -import { LuChevronDown, LuPlay, LuPause, LuSkipBack, LuSkipForward, LuListMusic, LuMic, LuMusic, LuHeadphones, LuVolume1, LuVolume2 } from 'react-icons/lu'; +import { Box, Text, ActionIcon, Group, Tooltip, Flex, Center } from '@mantine/core'; +import { LuChevronDown, LuListMusic, LuMic, LuMusic, LuVolume1, LuVolume2 } from 'react-icons/lu'; +import { IoIosPlayCircle } from 'react-icons/io'; +import { IoPauseCircleSharp, IoPlaySkipBackCircleOutline, IoPlaySkipForwardCircleOutline } from 'react-icons/io5'; +import { TbRewindBackward10, TbRewindForward10 } from 'react-icons/tb'; import { formatTime } from '../../Footer.logic'; import { StandardPlayerProps } from './StandardPlayer.types'; import { TrackArtwork } from '../TrackArtwork'; @@ -12,7 +15,6 @@ import { getDisplayTitle, getDisplayArtist } from './StandardPlayer.mobile.logic import { getPlayerContainerStyle, getArtworkBoxStyle, - getToggleButtonBoxStyle, getProgressBarBoxStyle, getProgressBarContainerStyle, getTimeTextStyle, @@ -20,6 +22,7 @@ import { getButtonStyles, getBottomRowStyle, } from './StandardPlayer.mobile.styles'; +// import { isIOSMobile } from '@utils/platform'; // Remove this line export const StandardPlayerMobile = (props: StandardPlayerProps) => { const { @@ -69,6 +72,7 @@ export const StandardPlayerMobile = (props: StandardPlayerProps) => { const displayTitle = getDisplayTitle(controlMode, isMusicEnabled, isNarrationEnabled, activeMusicTrack, activeVoiceTrack); const displayArtist = getDisplayArtist(isMusicEnabled, isNarrationEnabled, activeMusicTrack, activeVoiceTrack); const artworkSize = 56; + // const isIOS = isIOSMobile(); // Remove this line return ( { size={artworkSize + 10} iconSize={(artworkSize + 10) * 0.5} /> - - {showToggle && ( - - )} - - + {displayTitle} @@ -114,41 +103,45 @@ export const StandardPlayerMobile = (props: StandardPlayerProps) => { )} - - - - - - + + + +
+ + - {isEffectivelyPlaying ? : } + {controlMode === 'narration' ? : } - - - - - - - - + + + {isEffectivelyPlaying ? : } + + + + {controlMode === 'narration' ? : } + + + +
+ {displayTrackAvailable ? ( @@ -166,12 +159,9 @@ export const StandardPlayerMobile = (props: StandardPlayerProps) => { )} +
{ startUserInteraction?.(); onVoiceVolumeChange(v); } : () => { }} - onMusicVolumeChange={controlMode !== 'narration' ? (v) => { startUserInteraction?.(); onMusicVolumeChange(v); } : () => { }} onPlaylistClick={onPlaylistToggle} onMinimizeClick={onMinimizeToggle} isNarrationEnabled={isNarrationEnabled} @@ -185,7 +175,7 @@ export const StandardPlayerMobile = (props: StandardPlayerProps) => { playVoice && playVoice(); } } else { - toggleNarration(); + toggleNarration && toggleNarration(); } }} onToggleMusic={() => { @@ -200,9 +190,14 @@ export const StandardPlayerMobile = (props: StandardPlayerProps) => { toggleMusic(); } }} + controlMode={controlMode} + onControlModeToggle={toggleControlMode} + showControlModeToggle={showToggle} colorScheme={colorScheme === 'dark' ? 'dark' : 'light'} sliderWidth={56} gap={4} + isNarrationPlaying={isVoicePlaying} + isMusicPlaying={isMusicPlaying} />
diff --git a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.types.ts b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.types.ts index a2609f91..085fce2f 100644 --- a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.types.ts +++ b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.types.ts @@ -8,7 +8,7 @@ export interface StandardPlayerProps { progress: number; colors: any; progressBarRef: RefObject; - colorScheme: string; + colorScheme: 'light' | 'dark'; currentTime: number; duration: number; onPlayToggle: () => void; diff --git a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.hook.ts b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.hook.ts index 5dfb77f5..22085b4d 100644 --- a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.hook.ts +++ b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.hook.ts @@ -9,6 +9,7 @@ export function useStandardPlayerWeb(props: StandardPlayerProps) { const progressBarContainerRef = useRef(null); const { ref: artworkBoxRef, height: artworkBoxHeight } = useElementSize(); const [controlMode, setControlMode] = useState<'music' | 'narration'>(props.isMusicEnabled ? 'music' : 'narration'); + const [layeredAudioMessage, setLayeredAudioMessage] = useState(null); const [isMusicHovered, setIsMusicHovered] = useState(false); const [isNarrationHovered, setIsNarrationHovered] = useState(false); @@ -33,12 +34,24 @@ export function useStandardPlayerWeb(props: StandardPlayerProps) { const handlePlayPause = () => { props.startUserInteraction?.(); + // Always call the main onPlayToggle, let the parent component decide what to play/pause + props.onPlayToggle(); + // Remove the previous logic that called specific play/pause functions based on controlMode + /* if (props.isMusicEnabled && props.isNarrationEnabled) { - props.onPlayToggle(); - props.onPlayToggle(); - } else { - props.onPlayToggle(); + // If both are enabled, toggle both based on combined state might be complex. + // For now, let's just toggle the active one based on controlMode. + if (controlMode === 'music') { + props.isMusicPlaying ? props.pauseMusic?.() : props.playMusic?.(); + } else { + props.isVoicePlaying ? props.pauseVoice?.() : props.playVoice?.(); + } + } else if (props.isMusicEnabled) { + props.isMusicPlaying ? props.pauseMusic?.() : props.playMusic?.(); + } else if (props.isNarrationEnabled) { + props.isVoicePlaying ? props.pauseVoice?.() : props.playVoice?.(); } + */ }; const handleProgressBarClick = (event: React.MouseEvent) => { @@ -55,6 +68,25 @@ export function useStandardPlayerWeb(props: StandardPlayerProps) { } }; + // Narration seek handlers + const handleRewindNarration = () => { + if (props.voiceDuration <= 0) return; + props.startUserInteraction?.(); + const currentTime = props.voiceCurrentTime; + const newTime = Math.max(0, currentTime - 10); + const newProgress = (newTime / props.voiceDuration) * 100; + props.onSeekNarration(newProgress); + }; + + const handleForwardNarration = () => { + if (props.voiceDuration <= 0) return; + props.startUserInteraction?.(); + const currentTime = props.voiceCurrentTime; + const newTime = Math.min(props.voiceDuration, currentTime + 10); + const newProgress = (newTime / props.voiceDuration) * 100; + props.onSeekNarration(newProgress); + }; + let combinedTitle = ''; if (props.isNarrationEnabled && props.activeVoiceTrack?.title) { combinedTitle = props.activeVoiceTrack.title; @@ -89,12 +121,12 @@ export function useStandardPlayerWeb(props: StandardPlayerProps) { artworkBoxHeight, controlMode, setControlMode, + toggleControlMode, + showToggle, isMusicHovered, setIsMusicHovered, isNarrationHovered, setIsNarrationHovered, - toggleControlMode, - showToggle, isMusicActive, isNarrationActive, isEffectivelyPlaying, @@ -104,9 +136,12 @@ export function useStandardPlayerWeb(props: StandardPlayerProps) { displayTrackAvailable, handlePlayPause, handleProgressBarClick, + handleRewindNarration, + handleForwardNarration, displayTitle, displayArtist, iconProps, + layeredAudioMessage, }; } diff --git a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.styles.ts b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.styles.ts index 5579fd74..47315545 100644 --- a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.styles.ts +++ b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.styles.ts @@ -116,4 +116,19 @@ export const minimizeBtnRoot = (theme: MantineTheme, colorScheme: string, colors export const flexShrink0: CSSProperties = { flexShrink: 0 }; -// Add other extracted style objects here, e.g. artworkBoxStyle, cardStyle, buttonStyle, etc. \ No newline at end of file +// Add other extracted style objects here, e.g. artworkBoxStyle, cardStyle, buttonStyle, etc. + +// Add new styles for the toggle switch +export const controlToggleGroup = (theme: MantineTheme, colorScheme: 'light' | 'dark'): React.CSSProperties => ({ + cursor: 'pointer', + padding: `calc(${theme.spacing.xs} / 2)`, + borderRadius: theme.radius.xl, + backgroundColor: colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[2], + transition: 'background-color 0.2s ease', +}); + +export const controlToggleIcon = (theme: MantineTheme, colorScheme: 'light' | 'dark', isActive: boolean): React.CSSProperties => ({ + border: `1px solid ${isActive ? 'transparent' : (colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[4])}`, + boxShadow: isActive ? theme.shadows.xs : 'none', + transition: 'all 0.2s ease', +}); \ No newline at end of file diff --git a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.tsx b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.tsx index 2cff0e3e..0e2bd9db 100644 --- a/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.tsx +++ b/src/shared-components/organisms/Footer/components/StandardPlayer/StandardPlayer.web.tsx @@ -1,10 +1,14 @@ "use client"; -import { Box, Text, ActionIcon, Group, Slider, Tooltip, Button, Flex } from '@mantine/core'; +import { Box, Text, ActionIcon, Group, Slider, Tooltip, Button, Flex, Alert } from '@mantine/core'; import { LuChevronDown, LuPlay, LuPause, LuSkipBack, LuSkipForward, LuListMusic, LuMic, LuMusic, - LuHeadphones, LuVolume1, LuVolume2 + LuHeadphones, LuVolume1, LuVolume2, LuVolumeX } from 'react-icons/lu'; +import { MdRecordVoiceOver, MdVoiceOverOff, MdMusicOff } from 'react-icons/md'; +import { TbRewindBackward10, TbRewindForward10 } from 'react-icons/tb'; +import { IoPlaySharp } from 'react-icons/io5'; +import { IoMdPause } from 'react-icons/io'; import { useMantineTheme } from '@mantine/core'; import { useElementSize } from '@mantine/hooks'; import { StandardPlayerProps } from './StandardPlayer.types'; @@ -30,7 +34,9 @@ import { playlistBtn, playlistBtnRoot, minimizeBtnRoot, - flexShrink0 + flexShrink0, + controlToggleGroup, + controlToggleIcon } from './StandardPlayer.web.styles'; import { useStandardPlayerWeb } from './StandardPlayer.web.hook'; import { formatTime } from '../../Footer.logic'; @@ -46,12 +52,12 @@ export const StandardPlayerWeb = (props: StandardPlayerProps) => { artworkBoxHeight, controlMode, setControlMode, + toggleControlMode, + showToggle, isMusicHovered, setIsMusicHovered, isNarrationHovered, setIsNarrationHovered, - toggleControlMode, - showToggle, isMusicActive, isNarrationActive, isEffectivelyPlaying, @@ -64,6 +70,9 @@ export const StandardPlayerWeb = (props: StandardPlayerProps) => { displayTitle, displayArtist, iconProps, + layeredAudioMessage, + handleRewindNarration, + handleForwardNarration, } = useStandardPlayerWeb(props); const { @@ -133,29 +142,72 @@ export const StandardPlayerWeb = (props: StandardPlayerProps) => { {showToggle && ( - - )} - - { startUserInteraction?.(); onPrevTrack(); }} aria-label="Previous track" disabled={!isMusicActive || !activeMusicTrack} {...iconProps} size="sm"> - + {controlMode === 'narration' ? + : + + } {isEffectivelyPlaying ? : } - - { startUserInteraction?.(); onNextTrack(); }} aria-label="Next track" disabled={!isMusicActive || !activeMusicTrack} {...iconProps} size="sm"> - + + { + startUserInteraction?.(); + if (controlMode === 'narration') { + handleForwardNarration(); + } else { + onNextTrack(); + } + }} + aria-label={controlMode === 'narration' ? "Forward 10 seconds" : "Next track"} + disabled={controlMode === 'narration' ? !isNarrationActive || !activeVoiceTrack : !isMusicActive || !activeMusicTrack} + {...iconProps} + size="sm" + > + {controlMode === 'narration' ? + : + + } @@ -191,103 +243,77 @@ export const StandardPlayerWeb = (props: StandardPlayerProps) => { - {isNarrationEnabled && ( - - - - - - - - )} - + - {isMusicEnabled && ( - - - - - - - - )} @@ -318,6 +344,11 @@ export const StandardPlayerWeb = (props: StandardPlayerProps) => { + {layeredAudioMessage && ( + + {layeredAudioMessage} + + )} ); }; \ No newline at end of file diff --git a/src/shared-components/organisms/Footer/components/dual-audio/useDualAudioController.hook.ts b/src/shared-components/organisms/Footer/components/dual-audio/useDualAudioController.hook.ts index 991ec7ec..91458782 100644 --- a/src/shared-components/organisms/Footer/components/dual-audio/useDualAudioController.hook.ts +++ b/src/shared-components/organisms/Footer/components/dual-audio/useDualAudioController.hook.ts @@ -4,7 +4,6 @@ import { useVoiceTrackLoader } from './useVoiceTrackLoader'; import { musicPlaylist } from './playlists/musicPlaylist'; import { usePathname } from 'next/navigation'; import { voiceTracks } from './playlists/voiceTracks'; -import { fadeAudioVolume } from './useDualAudioController.utils'; import { handleAudioError, getNextMusicTrack } from './useDualAudioController.logic'; export type DualAudioContextTypeWithRefs = DualAudioContextType & { @@ -12,7 +11,6 @@ export type DualAudioContextTypeWithRefs = DualAudioContextType & { voiceAudioRef: React.MutableRefObject; }; -const DUCK_VOLUME = 0.2; const FULL_VOLUME = 1; export function useDualAudioController(): DualAudioContextTypeWithRefs { @@ -37,62 +35,40 @@ export function useDualAudioController(): DualAudioContextTypeWithRefs { const musicAudioRef = useRef(null); const voiceAudioRef = useRef(null); const [isAudioReady, setIsAudioReady] = useState(false); - const [userOverrodeDuck, setUserOverrodeDuck] = useState(false); - - const getCurrentMusicVolume = useCallback(() => { - if (isMusicEnabled && isNarrationEnabled && isVoicePlaying && !userOverrodeDuck) { - return DUCK_VOLUME; - } - return musicTargetVolumeRef.current; - }, [isMusicEnabled, isNarrationEnabled, isVoicePlaying, userOverrodeDuck]); const applyMusicVolume = useCallback((targetVolume: number) => { if (musicAudioRef.current) { const clamped = Math.min(Math.max(0, targetVolume), 1); - fadeAudioVolume(musicAudioRef.current, clamped, 300); + musicAudioRef.current.volume = clamped; setMusicVolume(clamped); setIsMusicMuted(clamped === 0); } }, []); - useEffect(() => { - if (isMusicEnabled && isNarrationEnabled) { - if (isVoicePlaying) { - if (!userOverrodeDuck) { - applyMusicVolume(DUCK_VOLUME); - } else { - applyMusicVolume(musicTargetVolumeRef.current); - } - } else { - applyMusicVolume(musicTargetVolumeRef.current); - setUserOverrodeDuck(false); - } - } - }, [isVoicePlaying, isMusicEnabled, isNarrationEnabled, applyMusicVolume, userOverrodeDuck]); - useEffect(() => { const audio = musicAudioRef.current; if (!audio) return; - const reapplyVolume = () => { - applyMusicVolume(getCurrentMusicVolume()); + const reapplyUserVolume = () => { + applyMusicVolume(musicTargetVolumeRef.current); }; - audio.addEventListener('play', reapplyVolume); - audio.addEventListener('loadedmetadata', reapplyVolume); + audio.addEventListener('play', reapplyUserVolume); + audio.addEventListener('loadedmetadata', reapplyUserVolume); return () => { - audio.removeEventListener('play', reapplyVolume); - audio.removeEventListener('loadedmetadata', reapplyVolume); + audio.removeEventListener('play', reapplyUserVolume); + audio.removeEventListener('loadedmetadata', reapplyUserVolume); }; - }, [musicAudioRef, getCurrentMusicVolume, applyMusicVolume]); + }, [musicAudioRef, applyMusicVolume]); const playMusic = useCallback(() => { setMusicError(null); if (!isMusicEnabled) return; if (musicAudioRef.current) { + applyMusicVolume(musicTargetVolumeRef.current); musicAudioRef.current.play() .then(() => setIsMusicPlaying(true)) .catch(error => handleAudioError('music', error, setMusicError, setVoiceError, setIsMusicPlaying, setIsVoicePlaying)); } - }, [isMusicEnabled]); + }, [isMusicEnabled, applyMusicVolume]); const pauseMusic = useCallback(() => { musicAudioRef.current?.pause(); @@ -112,49 +88,22 @@ export function useDualAudioController(): DualAudioContextTypeWithRefs { const setMusicVolumeHandler = useCallback((volume: number) => { const clampedVolume = Math.min(Math.max(0, volume), 1); musicTargetVolumeRef.current = clampedVolume; - if (isMusicEnabled && isNarrationEnabled && isVoicePlaying) { - setUserOverrodeDuck(true); - applyMusicVolume(clampedVolume); - } else if (isMusicEnabled && (!isNarrationEnabled || !isVoicePlaying)) { + if (isMusicEnabled) { applyMusicVolume(clampedVolume); } - }, [isMusicEnabled, isNarrationEnabled, isVoicePlaying, applyMusicVolume]); + }, [isMusicEnabled, applyMusicVolume]); const playVoice = useCallback(() => { setVoiceError(null); if (!isNarrationEnabled) return; if (voiceAudioRef.current && activeVoiceTrack) { - if (isMusicEnabled && isNarrationEnabled && !isMusicPlaying && activeMusicTrack) { - try { - musicAudioRef.current?.play() - .then(() => { - setIsMusicPlaying(true); - setTimeout(() => { - voiceAudioRef.current?.play() - .then(() => setIsVoicePlaying(true)) - .catch(error => handleAudioError('voice', error, setMusicError, setVoiceError, setIsMusicPlaying, setIsVoicePlaying)); - }, 50); - }) - .catch(error => { - handleAudioError('music', error, setMusicError, setVoiceError, setIsMusicPlaying, setIsVoicePlaying); - voiceAudioRef.current?.play() - .then(() => setIsVoicePlaying(true)) - .catch(voiceError => handleAudioError('voice', voiceError, setMusicError, setVoiceError, setIsMusicPlaying, setIsVoicePlaying)); - }); - } catch (error) { - voiceAudioRef.current?.play() - .then(() => setIsVoicePlaying(true)) - .catch(error => handleAudioError('voice', error, setMusicError, setVoiceError, setIsMusicPlaying, setIsVoicePlaying)); - } - } else { - voiceAudioRef.current.play() - .then(() => setIsVoicePlaying(true)) - .catch(error => handleAudioError('voice', error, setMusicError, setVoiceError, setIsMusicPlaying, setIsVoicePlaying)); - } + voiceAudioRef.current.play() + .then(() => setIsVoicePlaying(true)) + .catch(error => handleAudioError('voice', error, setMusicError, setVoiceError, setIsMusicPlaying, setIsVoicePlaying)); } else { if (!activeVoiceTrack) setVoiceError('No voice track loaded for this page.'); } - }, [isMusicEnabled, isNarrationEnabled, isMusicPlaying, activeMusicTrack, activeVoiceTrack]); + }, [isNarrationEnabled, activeVoiceTrack]); const pauseVoice = useCallback(() => { voiceAudioRef.current?.pause(); @@ -191,26 +140,66 @@ export function useDualAudioController(): DualAudioContextTypeWithRefs { if (!audio) return; try { setMusicError(null); + + const targetSrc = isVoicePlaying + ? track.src.replace('/audio/music/', '/audio/music-ducked/') + : track.src; + const currentSrc = audio.src.replace(window.location.origin, ''); - if (track.src !== currentSrc) { - pauseMusic(); - await new Promise(res => setTimeout(res, 50)); + + if (targetSrc !== currentSrc) { + const wasPlaying = !audio.paused; + + setIsMusicPlaying(false); + setActiveMusicTrack(track); setMusicCurrentTime(0); setMusicDuration(0); - audio.src = track.src; + + console.log(`Loading NEW music track: ${targetSrc}`); + audio.src = targetSrc; + + const handleCanPlay = () => { + console.log(`Track ${targetSrc} can play.`); + applyMusicVolume(musicTargetVolumeRef.current); + + if (wasPlaying) { + console.log(`Autoplaying newly loaded track: ${targetSrc}`); + playMusic(); + } + audio.removeEventListener('canplay', handleCanPlay); + audio.removeEventListener('error', handleLoadError); + }; + + const handleLoadError = (e: Event) => { + console.error(`Error loading audio source ${targetSrc}:`, e); + setMusicError(`Error loading audio: ${targetSrc}`); + audio.removeEventListener('canplay', handleCanPlay); + audio.removeEventListener('error', handleLoadError); + }; + + audio.addEventListener('canplay', handleCanPlay); + audio.addEventListener('error', handleLoadError); + audio.load(); - const targetVol = (isMusicEnabled && isNarrationEnabled && isVoicePlaying) ? DUCK_VOLUME : musicTargetVolumeRef.current; - applyMusicVolume(targetVol); - playMusic(); + } else { setActiveMusicTrack(track); - if (!isMusicPlaying) playMusic(); + if (!isMusicPlaying) { + playMusic(); + } } } catch (error) { + console.error("Error in loadMusicTrack:", error); if (error instanceof Error) setMusicError(error.message); } - }, [pauseMusic, applyMusicVolume, isMusicEnabled, isNarrationEnabled, isVoicePlaying, playMusic, isMusicPlaying]); + }, [ + musicAudioRef, + isVoicePlaying, + isMusicPlaying, + applyMusicVolume, + playMusic, + ]); const loadVoiceTrack = useCallback((track: AudioTrack | null) => { if (voiceAudioRef.current) { @@ -250,13 +239,99 @@ export function useDualAudioController(): DualAudioContextTypeWithRefs { const playNextMusicTrack = useCallback(() => { const nextTrack = getNextMusicTrack(activeMusicTrack, isMusicLooping); if (nextTrack) { + console.log("Playing next track:", nextTrack.title); loadMusicTrack(nextTrack); - playMusic(); } - }, [activeMusicTrack, isMusicLooping, loadMusicTrack, playMusic]); + }, [activeMusicTrack, isMusicLooping, loadMusicTrack]); useVoiceTrackLoader(loadVoiceTrack, isAudioReady); + // Effect to handle live source swapping when narration state changes + useEffect(() => { + const audio = musicAudioRef.current; + // Ensure this runs only client-side and conditions are met + if (typeof window === 'undefined' || !audio || !activeMusicTrack || !isMusicEnabled || !isNarrationEnabled) { + return; + } + + // Determine the target URL based on current narration state + const targetUrl = isVoicePlaying + ? activeMusicTrack.src.replace('/audio/music/', '/audio/music-ducked/') + : activeMusicTrack.src; + + // Normalize current URL (remove origin) + let currentUrl = ''; + try { + currentUrl = audio.src ? new URL(audio.src).pathname : ''; + } catch (e) { + console.warn("Could not parse current audio src URL:", audio.src); + currentUrl = audio.src; // Fallback if URL parsing fails + } + + // Normalize target URL (assuming it's relative like /audio/...) + const normalizedTargetUrl = targetUrl.startsWith('/') ? targetUrl : '/' + targetUrl; + + + // Only swap if the target URL is different from the current one + if (normalizedTargetUrl !== currentUrl) { + console.log(`Live Swapping: Current=${currentUrl}, Target=${normalizedTargetUrl}`); + const currentTime = audio.currentTime; + const wasPlaying = !audio.paused; + + // Store previous src for error handling + const previousSrc = audio.src; + + // Set the new source + audio.src = targetUrl; + + const handleCanPlaySwap = () => { + console.log(`Live Swap: ${targetUrl} ready.`); + // Restore playback time + // Check duration to prevent seeking past the end if the new track is shorter (shouldn't happen with identical files) + if (currentTime < audio.duration) { + audio.currentTime = currentTime; + } else { + // If somehow the saved time is beyond the new duration, seek to start + audio.currentTime = 0; + } + setMusicCurrentTime(audio.currentTime); // Update state + + // Resume playback if it was playing + if (wasPlaying) { + audio.play().catch(e => { + console.error("Error resuming playback after live swap:", e); + setMusicError(`Playback error after swap: ${e}`); + // Optionally try to revert to previous source on error? + }); + } + // Cleanup listeners + audio.removeEventListener('canplay', handleCanPlaySwap); + audio.removeEventListener('error', handleErrorSwap); + }; + + const handleErrorSwap = (e: Event) => { + console.error(`Error loading swapped audio source ${targetUrl}. Current src was ${previousSrc}:`, e); + setMusicError(`Error loading swapped audio: ${targetUrl}`); + // Attempt to revert? Be cautious of loops. + // audio.src = previousSrc; + // audio.load(); + // Consider just stopping or notifying user. + + // Cleanup listeners even on error + audio.removeEventListener('canplay', handleCanPlaySwap); + audio.removeEventListener('error', handleErrorSwap); + }; + + // Add listeners for the swap + audio.addEventListener('canplay', handleCanPlaySwap); + audio.addEventListener('error', handleErrorSwap); + + // Load the new source + audio.load(); + } + + }, [isVoicePlaying, activeMusicTrack, isMusicEnabled, isNarrationEnabled, musicAudioRef]); // Add musicAudioRef + useEffect(() => { const musicAudio = musicAudioRef.current; const voiceAudio = voiceAudioRef.current; @@ -343,19 +418,17 @@ export function useDualAudioController(): DualAudioContextTypeWithRefs { const toggleNarration = useCallback(() => { setIsNarrationEnabled(prev => { const newState = !prev; - if (!newState && !isMusicEnabled) { - setIsMusicEnabled(true); - playMusic(); - } if (!newState) { pauseVoice(); if (isMusicEnabled) applyMusicVolume(musicTargetVolumeRef.current); - } else if (activeVoiceTrack) { - playVoice(); + } else { + if (activeVoiceTrack) { + playVoice(); + } } return newState; }); - }, [isMusicEnabled, pauseVoice, playVoice, playMusic, activeVoiceTrack, applyMusicVolume]); + }, [isMusicEnabled, pauseVoice, playVoice, activeVoiceTrack, applyMusicVolume]); return { isMusicPlaying,