From 15a11de3b189f0c22d205498d49a2c3bc8f11dee Mon Sep 17 00:00:00 2001 From: farzan Date: Fri, 18 Jul 2025 09:27:00 +0530 Subject: [PATCH 1/4] add Docker configuration and MinIO setup for development environment --- .dockerignore | 69 +++++++++++++++++++++++++++++++ DockerfileClient.txt | 22 ++++++++++ DockerfileServer.txt | 19 +++++++++ apps/server/src/index.ts | 4 +- docker-compose.dev.yml | 42 +++++++++++++++++++ docker-compose.yaml | 88 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 DockerfileClient.txt create mode 100644 DockerfileServer.txt create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ce7fdda0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,69 @@ +# Dependencies +**/node_modules +**/dist +**/build +**/.next +**/out + +# Logs +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* +**/lerna-debug.log* + +# Runtime data +**/pids +**/*.pid +**/*.seed +**/*.pid.lock + +# Coverage directory used by tools like istanbul +**/coverage +**/*.lcov + +# nyc test coverage +**/.nyc_output + +# Git +**/.git +**/.gitignore + +# Docker +**/.dockerignore +**/Dockerfile* +**/docker-compose*.yml + +# IDEs +**/.vscode +**/.idea +**/*.swp +**/*.swo + +# OS +**/.DS_Store +**/Thumbs.db + +# Build artifacts +**/tsconfig.tsbuildinfo +**/.turbo + +# Environment files +**/.env +**/.env.local +**/.env.development.local +**/.env.test.local +**/.env.production.local + +# Temporary files +**/tmp +**/temp + +# Test files +**/__tests__ +**/*.test.* +**/*.spec.* + +# Audio files (these should be uploaded separately) +**/*.mp3 +**/*.wav +**/*.ogg diff --git a/DockerfileClient.txt b/DockerfileClient.txt new file mode 100644 index 00000000..9e809c02 --- /dev/null +++ b/DockerfileClient.txt @@ -0,0 +1,22 @@ +# apps/client/Dockerfile +FROM oven/bun:1.1.38 + +WORKDIR /app + +# Copy full monorepo to Docker +COPY . . + +WORKDIR /app/apps/client + +# Install deps using workspaces +RUN bun install + +# Set NODE_ENV for production build +ENV NODE_ENV=production + +# Build the Next.js application +RUN bun run build + +EXPOSE 3000 + +CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/DockerfileServer.txt b/DockerfileServer.txt new file mode 100644 index 00000000..eec8dc22 --- /dev/null +++ b/DockerfileServer.txt @@ -0,0 +1,19 @@ +# apps/server/Dockerfile +FROM oven/bun:1.2.18 + +WORKDIR /app + +# Copy full monorepo to Docker +COPY . . + +WORKDIR /app/apps/server + +# Install deps using workspaces +RUN bun install + +# Optional: if you use tsc, vite, etc. +# RUN bun run build + +EXPOSE 3001 + +CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4619dfbe..82aff1fc 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -16,8 +16,8 @@ import { getActiveRooms } from "./routes/active"; // Bun.serve with WebSocket support const server = Bun.serve({ - hostname: "0.0.0.0", - port: 8080, + hostname: process.env.HOST || "0.0.0.0", + port: parseInt(process.env.PORT || "8080"), async fetch(req, server) { const url = new URL(req.url); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..d8c00c63 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,42 @@ +services: + # MinIO - S3 compatible object storage for development + minio: + image: minio/minio:latest + container_name: beatsync-minio-dev + ports: + - "9000:9000" # API port + - "9001:9001" # Console port + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin123 + MINIO_DOMAIN: localhost + volumes: + - minio_dev_data:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + + # MinIO Client to create bucket automatically + minio-setup: + image: minio/mc:latest + container_name: beatsync-minio-setup-dev + depends_on: + - minio + entrypoint: > + /bin/sh -c " + sleep 10; + /usr/bin/mc alias set beatsync http://minio:9000 minioadmin minioadmin123; + /usr/bin/mc mb beatsync/beatsync-audio --ignore-existing; + /usr/bin/mc anonymous set download beatsync/beatsync-audio; + echo 'MinIO setup complete! Bucket: beatsync-audio'; + echo 'Console: http://localhost:9001 (minioadmin/minioadmin123)'; + echo 'API: http://localhost:9000'; + exit 0; + " + +volumes: + minio_dev_data: + driver: local diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..0eb191d5 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,88 @@ +services: + # MinIO - S3 compatible object storage + minio: + image: minio/minio:latest + container_name: beatsync-minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin123 + MINIO_DOMAIN: localhost + volumes: + - minio_data:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + networks: + - beatsync + + # MinIO Client to create bucket automatically + minio-create-bucket: + image: minio/mc:latest + container_name: beatsync-minio-setup + depends_on: + - minio + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set beatsync http://minio:9000 minioadmin minioadmin123; + /usr/bin/mc mb beatsync/beatsync-audio --ignore-existing; + /usr/bin/mc anonymous set download beatsync/beatsync-audio; + exit 0; + " + networks: + - beatsync + + server: + build: + context: . + dockerfile: ./DockerfileServer + container_name: beatsync-server + ports: + - "3001:3001" + environment: + - NODE_ENV=production + - PORT=3001 + - HOST=0.0.0.0 + - S3_BUCKET_NAME=beatsync-audio + - S3_PUBLIC_URL=http://minio:9000/beatsync-audio + - S3_ENDPOINT=http://minio:9000 + - S3_ACCESS_KEY_ID=minioadmin + - S3_SECRET_ACCESS_KEY=minioadmin123 + depends_on: + - minio + - minio-create-bucket + networks: + - beatsync + + client: + build: + context: . + dockerfile: ./DockerfileClient + container_name: beatsync-client + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - NEXT_PUBLIC_API_URL=http://localhost:3001 + - NEXT_PUBLIC_WS_URL=ws://localhost:3001 + - NEXT_PUBLIC_NEXT_URL=http://localhost:3000 + - NEXT_PUBLIC_YOUTUBE_API_KEY=${NEXT_PUBLIC_YOUTUBE_API_KEY:-} + - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY:-} + depends_on: + - minio + - minio-create-bucket + networks: + - beatsync + +networks: + beatsync: + driver: bridge + +volumes: + minio_data: + driver: local From 8ae36d804dcd9efd67c5da2f83bb9b236620746a Mon Sep 17 00:00:00 2001 From: farzan Date: Fri, 18 Jul 2025 11:14:57 +0530 Subject: [PATCH 2/4] feat(youtube): implement YouTube player, queue, and search components - Added YouTubePlayer component for playing videos with state management. - Created YouTubeQueue component to manage and display a list of queued videos. - Developed YouTubeSearch component for searching and adding videos to the queue. - Integrated global store for managing YouTube video states and actions. - Implemented error handling and loading states in the search functionality. - Added default audio file upload functionality to S3 storage. --- apps/client/next.config.ts | 10 + apps/client/src/components/UnifiedPlayer.tsx | 672 ++++++++++++++++++ .../src/components/dashboard/Bottom.tsx | 6 +- apps/client/src/components/dashboard/Left.tsx | 49 +- apps/client/src/components/dashboard/Main.tsx | 77 +- .../client/src/components/dashboard/Right.tsx | 56 +- apps/client/src/components/room/Player.tsx | 254 ------- .../src/components/room/WebSocketManager.tsx | 35 + .../src/components/youtube/YouTubePlayer.tsx | 144 ++++ .../src/components/youtube/YouTubeQueue.tsx | 157 ++++ .../src/components/youtube/YouTubeSearch.tsx | 302 ++++++++ apps/client/src/store/global.tsx | 371 ++++++++++ apps/server/src/lib/upload-defaults.ts | 90 +++ apps/server/src/managers/RoomManager.ts | 78 ++ apps/server/src/routes/websocketHandlers.ts | 169 ++++- bun.lock | 16 + package.json | 2 + packages/shared/types/WSBroadcast.ts | 44 +- packages/shared/types/WSRequest.ts | 72 +- packages/shared/types/basic.ts | 10 + 20 files changed, 2313 insertions(+), 301 deletions(-) create mode 100644 apps/client/src/components/UnifiedPlayer.tsx delete mode 100644 apps/client/src/components/room/Player.tsx create mode 100644 apps/client/src/components/youtube/YouTubePlayer.tsx create mode 100644 apps/client/src/components/youtube/YouTubeQueue.tsx create mode 100644 apps/client/src/components/youtube/YouTubeSearch.tsx create mode 100644 apps/server/src/lib/upload-defaults.ts diff --git a/apps/client/next.config.ts b/apps/client/next.config.ts index c69d831d..e90bd993 100644 --- a/apps/client/next.config.ts +++ b/apps/client/next.config.ts @@ -20,6 +20,16 @@ const nextConfig: NextConfig = { }, // This is required to support PostHog trailing slash API requests skipTrailingSlashRedirect: true, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "i.ytimg.com", + port: "", + pathname: "/**", + }, + ], + }, }; export default nextConfig; diff --git a/apps/client/src/components/UnifiedPlayer.tsx b/apps/client/src/components/UnifiedPlayer.tsx new file mode 100644 index 00000000..56114f3c --- /dev/null +++ b/apps/client/src/components/UnifiedPlayer.tsx @@ -0,0 +1,672 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Play, + Pause, + SkipBack, + SkipForward, + Volume2, + VolumeX, + Repeat, + Shuffle, + Maximize2, + Minimize2, + Youtube, + Music, + Info +} from "lucide-react"; +import { Button } from "./ui/button"; +import { Slider } from "./ui/slider"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { useGlobalStore } from "@/store/global"; +import { Card } from "./ui/card"; +import { motion, AnimatePresence } from "motion/react"; +import Image from "next/image"; +import { YouTubePlayer } from "./youtube/YouTubePlayer"; +import { extractFileNameFromUrl } from "@/lib/utils"; + +export const UnifiedPlayer = () => { + const [isVideoExpanded, setIsVideoExpanded] = useState(false); + const [localVolume, setLocalVolume] = useState(75); + const [isMuted, setIsMuted] = useState(false); + const [livePosition, setLivePosition] = useState(0); + const [youtubeDuration, setYoutubeDuration] = useState(0); + const [youtubePosition, setYoutubePosition] = useState(0); + + // Global state + const currentMode = useGlobalStore((state) => state.currentMode); + const isPlaying = useGlobalStore((state) => state.isPlaying); + const isShuffled = useGlobalStore((state) => state.isShuffled); + const repeatMode = useGlobalStore((state) => state.repeatMode); + const isInitingSystem = useGlobalStore((state) => state.isInitingSystem); + + // YouTube state + const selectedYouTubeId = useGlobalStore((state) => state.selectedYouTubeId); + const youtubeSources = useGlobalStore((state) => state.youtubeSources); + const youtubePlayer = useGlobalStore((state) => state.youtubePlayer); + const isYouTubePlayerReady = useGlobalStore((state) => state.isYouTubePlayerReady); + + // Library state + const selectedAudioId = useGlobalStore((state) => state.selectedAudioUrl); + const audioSources = useGlobalStore((state) => state.audioSources); + const currentTime = useGlobalStore((state) => state.currentTime); + const duration = useGlobalStore((state) => state.duration); + const getCurrentTrackPosition = useGlobalStore((state) => state.getCurrentTrackPosition); + + // Actions + const broadcastPlay = useGlobalStore((state) => state.broadcastPlay); + const broadcastPause = useGlobalStore((state) => state.broadcastPause); + const skipToNextTrack = useGlobalStore((state) => state.skipToNextTrack); + const skipToPreviousTrack = useGlobalStore((state) => state.skipToPreviousTrack); + const broadcastPlayYouTube = useGlobalStore((state) => state.broadcastPlayYouTube); + const broadcastPauseYouTube = useGlobalStore((state) => state.broadcastPauseYouTube); + const skipToNextYouTubeVideo = useGlobalStore((state) => state.skipToNextYouTubeVideo); + const skipToPreviousYouTubeVideo = useGlobalStore((state) => state.skipToPreviousYouTubeVideo); + const broadcastSeekYouTube = useGlobalStore((state) => state.broadcastSeekYouTube); + const setIsShuffled = useGlobalStore((state) => state.setIsShuffled); + const setRepeatMode = useGlobalStore((state) => state.setRepeatMode); + const setVolume = useGlobalStore((state) => state.setVolume); + const getVolume = useGlobalStore((state) => state.getVolume); + + // Get current content info + const currentVideo = youtubeSources.find(source => source.videoId === selectedYouTubeId); + const currentAudio = audioSources.find(source => source.url === selectedAudioId); + + + // Helper function to format time + const formatTime = (seconds: number) => { + if (!seconds || !isFinite(seconds)) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // Update live position for both library and YouTube modes + useEffect(() => { + if (currentMode === 'library' && isPlaying) { + const interval = setInterval(() => { + const position = getCurrentTrackPosition(); + setLivePosition(position); + }, 100); // Update every 100ms for smooth progress + + return () => clearInterval(interval); + } else if (currentMode === 'youtube' && isPlaying && youtubePlayer) { + const interval = setInterval(() => { + try { + const currentTime = youtubePlayer.getCurrentTime(); + const duration = youtubePlayer.getDuration(); + setYoutubePosition(currentTime); + setYoutubeDuration(duration); + } catch { + // YouTube player might not be ready yet + console.log("YouTube player not ready for time tracking"); + } + }, 100); // Update every 100ms for smooth progress + + return () => clearInterval(interval); + } else { + setLivePosition(currentTime); + if (currentMode === 'youtube') { + setYoutubePosition(0); + } + } + }, [currentMode, isPlaying, getCurrentTrackPosition, currentTime, youtubePlayer]); + + // Player controls + const handlePlayPause = useCallback(() => { + console.log("UnifiedPlayer: Play/Pause clicked", { + currentMode, + isPlaying, + selectedAudioId, + currentAudio: !!currentAudio, + isInitingSystem, + audioSourcesLength: audioSources.length + }); + + if (isInitingSystem) { + console.log("UnifiedPlayer: System still initializing, cannot play"); + return; + } + + if (currentMode === 'youtube' && youtubePlayer && isYouTubePlayerReady) { + if (isPlaying) { + // Pause and broadcast to sync with other clients + broadcastPauseYouTube(); + } else { + // Play and broadcast to sync with other clients + const currentTime = youtubePlayer.getCurrentTime(); + broadcastPlayYouTube(currentTime); + } + } else if (currentMode === 'library') { + if (isPlaying) { + console.log("UnifiedPlayer: Broadcasting pause"); + broadcastPause(); + } else { + console.log("UnifiedPlayer: Broadcasting play"); + broadcastPlay(); + } + } + }, [currentMode, isPlaying, selectedAudioId, currentAudio, isInitingSystem, audioSources.length, youtubePlayer, isYouTubePlayerReady, broadcastPauseYouTube, broadcastPlayYouTube, broadcastPause, broadcastPlay]); + + const handlePrevious = useCallback(() => { + if (currentMode === 'youtube') { + skipToPreviousYouTubeVideo(); + } else if (currentMode === 'library') { + skipToPreviousTrack(); + } + }, [currentMode, skipToPreviousYouTubeVideo, skipToPreviousTrack]); + + const handleNext = useCallback(() => { + if (currentMode === 'youtube') { + skipToNextYouTubeVideo(); + } else if (currentMode === 'library') { + skipToNextTrack(); + } + }, [currentMode, skipToNextYouTubeVideo, skipToNextTrack]); + + const handleVolumeChange = useCallback((newVolume: number[]) => { + const vol = newVolume[0]; + setLocalVolume(vol); + setIsMuted(vol === 0); + + if (currentMode === 'youtube' && youtubePlayer && isYouTubePlayerReady) { + youtubePlayer.setVolume(vol); + } else if (currentMode === 'library') { + // Set the actual audio volume using the global store + setVolume(vol); + } + }, [currentMode, youtubePlayer, isYouTubePlayerReady, setVolume]); + + const handleMute = useCallback(() => { + if (isMuted) { + setIsMuted(false); + if (currentMode === 'youtube' && youtubePlayer && isYouTubePlayerReady) { + youtubePlayer.setVolume(localVolume); + } else if (currentMode === 'library') { + setVolume(localVolume); + } + } else { + setIsMuted(true); + if (currentMode === 'youtube' && youtubePlayer && isYouTubePlayerReady) { + youtubePlayer.setVolume(0); + } else if (currentMode === 'library') { + setVolume(0); + } + } + }, [isMuted, currentMode, youtubePlayer, isYouTubePlayerReady, localVolume, setVolume]); + + const handleShuffle = useCallback(() => { + setIsShuffled(!isShuffled); + }, [isShuffled, setIsShuffled]); + + const handleRepeat = useCallback(() => { + const modes = ['none', 'all', 'one'] as const; + const currentIndex = modes.indexOf(repeatMode); + const nextIndex = (currentIndex + 1) % modes.length; + setRepeatMode(modes[nextIndex]); + }, [repeatMode, setRepeatMode]); + + const handleProgressChange = (newValue: number[]) => { + const percentage = newValue[0]; + + if (currentMode === 'youtube' && youtubeDuration > 0) { + const newTime = (percentage / 100) * youtubeDuration; + if (youtubePlayer && isYouTubePlayerReady) { + youtubePlayer.seekTo(newTime, true); + broadcastSeekYouTube(newTime); + } + } else if (currentMode === 'library' && duration > 0) { + // Calculate the target time from percentage + const newTime = (percentage / 100) * duration; + console.log(`Library seeking to ${newTime}s (${percentage}%)`); + + // Use broadcastPlay with the new time to seek in library mode + broadcastPlay(newTime); + } + }; + + // Set initial volume when YouTube player is ready + useEffect(() => { + if (currentMode === 'youtube' && youtubePlayer && isYouTubePlayerReady) { + youtubePlayer.setVolume(isMuted ? 0 : localVolume); + } + }, [youtubePlayer, isYouTubePlayerReady, localVolume, isMuted, currentMode]); + + // Initialize local volume from global store + useEffect(() => { + const currentVolume = getVolume(); + setLocalVolume(currentVolume); + }, [getVolume]); + + // Add keyboard event listeners for media controls + useEffect(() => { + const handleKeyPress = (event: KeyboardEvent) => { + // Prevent handling if user is typing in an input field + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + switch (event.code) { + case 'Space': + event.preventDefault(); + handlePlayPause(); + break; + case 'MediaPlayPause': + event.preventDefault(); + handlePlayPause(); + break; + case 'MediaPlay': + event.preventDefault(); + if (!isPlaying) { + handlePlayPause(); + } + break; + case 'MediaPause': + event.preventDefault(); + if (isPlaying) { + handlePlayPause(); + } + break; + case 'MediaTrackNext': + case 'ArrowRight': + if (event.ctrlKey || event.code === 'MediaTrackNext') { + event.preventDefault(); + handleNext(); + } + break; + case 'MediaTrackPrevious': + case 'ArrowLeft': + if (event.ctrlKey || event.code === 'MediaTrackPrevious') { + event.preventDefault(); + handlePrevious(); + } + break; + case 'KeyM': + if (event.ctrlKey) { + event.preventDefault(); + handleMute(); + } + break; + case 'KeyS': + if (event.ctrlKey) { + event.preventDefault(); + handleShuffle(); + } + break; + case 'KeyR': + if (event.ctrlKey) { + event.preventDefault(); + handleRepeat(); + } + break; + case 'ArrowUp': + if (event.ctrlKey) { + event.preventDefault(); + const newVolume = Math.min(100, localVolume + 5); + handleVolumeChange([newVolume]); + } + break; + case 'ArrowDown': + if (event.ctrlKey) { + event.preventDefault(); + const newVolume = Math.max(0, localVolume - 5); + handleVolumeChange([newVolume]); + } + break; + } + }; + + // Add event listener + document.addEventListener('keydown', handleKeyPress); + + // Cleanup + return () => { + document.removeEventListener('keydown', handleKeyPress); + }; + }, [handlePlayPause, handleNext, handlePrevious, handleMute, handleShuffle, handleRepeat, handleVolumeChange, isPlaying, localVolume]); + + return ( + +
+ {/* Video Display (YouTube Mode Only) */} + + {currentMode === 'youtube' && selectedYouTubeId && isVideoExpanded && ( + +
+ +
+ + {/* Video Overlay Controls */} +
+ +
+
+ )} +
+ + {/* Player Layout */} +
+ {/* Video Thumbnail/Player (Minimized) */} + {currentMode === 'youtube' && selectedYouTubeId && !isVideoExpanded && ( +
+ + +
+ )} + + {/* Track Info and Controls Container */} +
+ {/* Track Info */} +
+ {!isVideoExpanded && ( + <> + {currentMode === 'youtube' && currentVideo ? ( + <> + {currentVideo.thumbnail && !selectedYouTubeId && ( +
+ {currentVideo.title} +
+ )} +
+

+ {currentVideo.title} +

+

+ {currentVideo.channel} +

+
+
+ + YouTube +
+ + ) : currentMode === 'library' && currentAudio ? ( + <> +
+ +
+
+

+ {currentAudio ? extractFileNameFromUrl(currentAudio.url) : 'Unknown Track'} +

+

+ {isInitingSystem ? 'System initializing...' : isPlaying ? 'Playing' : 'Ready to play'} +

+
+
+ + Library +
+ + ) : ( + <> +
+ +
+
+

+ {currentMode === 'library' ? 'Select a track' : 'No video selected'} +

+

+ {currentMode === 'library' ? 'Upload music to get started' : 'Search for YouTube videos'} +

+
+
+ {currentMode === 'library' ? ( + <> + + Library + + ) : ( + <> + + YouTube + + )} +
+ + )} + + )} + + {/* Expanded mode track info */} + {isVideoExpanded && ( +
+
+ {currentVideo?.thumbnail && ( +
+ {currentVideo.title} +
+ )} +
+

+ {currentVideo?.title || 'No video selected'} +

+

+ {currentVideo?.channel || 'Search for YouTube videos'} +

+
+
+
+ + YouTube +
+
+ )} +
+ + {/* Controls */} +
+ {/* Progress Bar */} + {((currentMode === 'library' && currentAudio) || (currentMode === 'youtube' && currentVideo)) && ( +
+
+ + {currentMode === 'library' + ? formatTime(livePosition) + : formatTime(youtubePosition) + } + +
+ 0 ? (livePosition / duration) * 100 : 0) + : (youtubeDuration > 0 ? (youtubePosition / youtubeDuration) * 100 : 0) + ]} + onValueChange={handleProgressChange} + max={100} + step={0.1} + className="w-full cursor-pointer" + /> +
+ + {currentMode === 'library' + ? formatTime(duration) + : formatTime(youtubeDuration) + } + +
+
+ )} + + {/* Primary Controls */} +
+
+ + + + + + + + + + + {/* Keyboard Shortcuts Info */} + + + + + + +
+
Keyboard Shortcuts
+
Space Play/Pause
+
Ctrl+← Previous
+
Ctrl+→ Next
+
Ctrl+↑ Volume Up
+
Ctrl+↓ Volume Down
+
Ctrl+M Mute
+
Ctrl+S Shuffle
+
Ctrl+R Repeat
+
+
+
+
+
+ + {/* Volume Control */} +
+ + +
+ +
+ + + {isMuted ? 0 : localVolume} + +
+
+
+
+
+
+
+ ); +}; diff --git a/apps/client/src/components/dashboard/Bottom.tsx b/apps/client/src/components/dashboard/Bottom.tsx index 1cb7b8b1..e2d80cac 100644 --- a/apps/client/src/components/dashboard/Bottom.tsx +++ b/apps/client/src/components/dashboard/Bottom.tsx @@ -1,11 +1,11 @@ import { motion } from "motion/react"; -import { Player } from "../room/Player"; +import { UnifiedPlayer } from "../UnifiedPlayer"; export const Bottom = () => { return ( -
- +
+
); diff --git a/apps/client/src/components/dashboard/Left.tsx b/apps/client/src/components/dashboard/Left.tsx index 5ea03804..695ec226 100644 --- a/apps/client/src/components/dashboard/Left.tsx +++ b/apps/client/src/components/dashboard/Left.tsx @@ -1,29 +1,22 @@ "use client"; import { cn } from "@/lib/utils"; -import { Library, Search } from "lucide-react"; +import { Library, Music, Search, Youtube } from "lucide-react"; import { motion } from "motion/react"; import { AudioUploaderMinimal } from "../AudioUploaderMinimal"; import { Button } from "../ui/button"; import { Separator } from "../ui/separator"; import { AudioControls } from "./AudioControls"; +import { useGlobalStore } from "@/store/global"; +import Link from "next/link"; interface LeftProps { className?: string; } export const Left = ({ className }: LeftProps) => { - // const shareRoom = () => { - // try { - // navigator.share({ - // title: "Join my BeatSync room", - // text: `Join my BeatSync room with code: ${roomId}`, - // url: window.location.href, - // }); - // } catch { - // copyRoomId(); - // } - // }; + const currentMode = useGlobalStore((state) => state.currentMode); + const setCurrentMode = useGlobalStore((state) => state.setCurrentMode); return ( { )} > {/* Header section */} - {/*
+
@@ -43,7 +36,7 @@ export const Left = ({ className }: LeftProps) => {
- */} +

Your Library @@ -52,14 +45,34 @@ export const Left = ({ className }: LeftProps) => { {/* Navigation menu */} - + + + - + diff --git a/apps/client/src/components/dashboard/Main.tsx b/apps/client/src/components/dashboard/Main.tsx index 2377da97..a89868ca 100644 --- a/apps/client/src/components/dashboard/Main.tsx +++ b/apps/client/src/components/dashboard/Main.tsx @@ -1,8 +1,17 @@ import { cn } from "@/lib/utils"; import { motion } from "motion/react"; import { Queue } from "../Queue"; +import { useGlobalStore } from "@/store/global"; +import { YouTubeQueue } from "../youtube/YouTubeQueue"; +import { YouTubeSearch } from "../youtube/YouTubeSearch"; +import { Button } from "../ui/button"; +import { Search, List } from "lucide-react"; +import { useState } from "react"; export const Main = () => { + const currentMode = useGlobalStore((state) => state.currentMode); + const [youtubeTab, setYoutubeTab] = useState<'search' | 'queue'>('search'); + return ( { )} > - {/*

BeatSync

*/} - + {currentMode === 'library' ? ( + <> +
+

Music Library

+

Upload and manage your audio files

+
+ + + ) : ( + <> +
+

YouTube Player

+

Search and play YouTube videos synchronized across all devices

+
+ + {/* YouTube Tabs */} +
+
+ + +
+ + {/* Tab Content */} +
+ {youtubeTab === 'search' && ( +
+

Search YouTube

+ +
+ )} + + + {youtubeTab === 'queue' && ( +
+

Video Queue

+ +
+ )} +
+
+ + {/* YouTube Player */} + {/*
+

Current Video

+
+ +
+
*/} + + )}
); diff --git a/apps/client/src/components/dashboard/Right.tsx b/apps/client/src/components/dashboard/Right.tsx index 4762fed6..2ac73af1 100644 --- a/apps/client/src/components/dashboard/Right.tsx +++ b/apps/client/src/components/dashboard/Right.tsx @@ -1,13 +1,16 @@ import { cn } from "@/lib/utils"; -import { Info } from "lucide-react"; +import { Info, Youtube, Music } from "lucide-react"; import { motion } from "motion/react"; import { UserGrid } from "../room/UserGrid"; +import { useGlobalStore } from "@/store/global"; interface RightProps { className?: string; } export const Right = ({ className }: RightProps) => { + const currentMode = useGlobalStore((state) => state.currentMode); + return ( {
-
- - What is this? -
-

- This grid simulates a spatial audio environment. The headphone - icon (🎧) is a listening source. The circles represent other - devices in the room. -

-

- { - "Drag the headphone icon around and hear how the volume changes on each device. Isn't it cool!" - } -

+ {currentMode === 'library' ? ( + <> +
+ + Music Library Mode +
+

+ Upload your own audio files to play synchronized music across all connected devices in the room. +

+

+ Use the spatial audio grid above to control how audio sounds on different devices. +

+ + ) : ( + <> +
+ + YouTube Mode +
+

+ Add YouTube videos by URL or video ID to play synchronized across all devices. +

+

+ All users will see and hear the same video at the same time. +

+ + )} + +
+
+ + Spatial Audio Grid +
+

+ The grid above simulates spatial audio. Drag the headphone icon (🎧) around to hear how volume changes on each device. +

+
diff --git a/apps/client/src/components/room/Player.tsx b/apps/client/src/components/room/Player.tsx deleted file mode 100644 index 56d5ffd6..00000000 --- a/apps/client/src/components/room/Player.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { cn, formatTime } from "@/lib/utils"; - -import { useGlobalStore } from "@/store/global"; -import { - Pause, - Play, - Repeat, - Shuffle, - SkipBack, - SkipForward, -} from "lucide-react"; -import { usePostHog } from "posthog-js/react"; -import { useCallback, useEffect, useState } from "react"; -import { Slider } from "../ui/slider"; - -export const Player = () => { - const posthog = usePostHog(); - const broadcastPlay = useGlobalStore((state) => state.broadcastPlay); - const broadcastPause = useGlobalStore((state) => state.broadcastPause); - const isPlaying = useGlobalStore((state) => state.isPlaying); - const getCurrentTrackPosition = useGlobalStore( - (state) => state.getCurrentTrackPosition - ); - const selectedAudioId = useGlobalStore((state) => state.selectedAudioUrl); - const audioSources = useGlobalStore((state) => state.audioSources); - const currentTime = useGlobalStore((state) => state.currentTime); - const skipToNextTrack = useGlobalStore((state) => state.skipToNextTrack); - const skipToPreviousTrack = useGlobalStore( - (state) => state.skipToPreviousTrack - ); - const isShuffled = useGlobalStore((state) => state.isShuffled); - const toggleShuffle = useGlobalStore((state) => state.toggleShuffle); - - // Local state for slider - const [sliderPosition, setSliderPosition] = useState(0); - const [trackDuration, setTrackDuration] = useState(0); - const [isDragging, setIsDragging] = useState(false); - - const getAudioDuration = useGlobalStore((state) => state.getAudioDuration); - - // Find the selected audio source and its duration - useEffect(() => { - if (!selectedAudioId) return; - - const audioSource = audioSources.find( - (source) => source.url === selectedAudioId - ); - if (audioSource) { - setTrackDuration(getAudioDuration({ url: audioSource.url })); - // Reset slider position when track changes - setSliderPosition(0); - } - }, [selectedAudioId, audioSources, getAudioDuration]); - - // Sync with currentTime when it changes (e.g., after pausing) - useEffect(() => { - if (!isPlaying) { - setSliderPosition(currentTime); - } - }, [currentTime, isPlaying]); - - // Update slider position during playback - useEffect(() => { - if (!isPlaying) return; - - const interval = setInterval(() => { - if (!isDragging) { - const currentPosition = getCurrentTrackPosition(); - setSliderPosition(currentPosition); - } - }, 100); // Update every 100ms - - return () => clearInterval(interval); - }, [isPlaying, getCurrentTrackPosition, isDragging]); - - // Handle slider change - const handleSliderChange = useCallback((value: number[]) => { - const position = value[0]; - setIsDragging(true); - setSliderPosition(position); - }, []); - - // Handle slider release - seek to that position - const handleSliderCommit = useCallback( - (value: number[]) => { - const newPosition = value[0]; - setIsDragging(false); - // If currently playing, broadcast play at new position - // If paused, just update position without playing - if (isPlaying) { - broadcastPlay(newPosition); - } else { - setSliderPosition(newPosition); - } - - // Log scrub event - posthog.capture("scrub_confirm", { - position: newPosition, - track_id: selectedAudioId, - track_duration: trackDuration, - }); - }, - [ - broadcastPlay, - isPlaying, - setSliderPosition, - posthog, - selectedAudioId, - trackDuration, - ] - ); - - const handlePlay = useCallback(() => { - if (isPlaying) { - broadcastPause(); - posthog.capture("pause_track", { track_id: selectedAudioId }); - } else { - broadcastPlay(sliderPosition); - posthog.capture("play_track", { - position: sliderPosition, - track_id: selectedAudioId, - }); - } - }, [ - isPlaying, - broadcastPause, - broadcastPlay, - sliderPosition, - posthog, - selectedAudioId, - ]); - - const handleSkipBack = useCallback(() => { - if (!isShuffled) { - skipToPreviousTrack(); - posthog.capture("skip_previous", { - from_track_id: selectedAudioId, - }); - } - }, [skipToPreviousTrack, isShuffled, posthog, selectedAudioId]); - - const handleSkipForward = useCallback(() => { - skipToNextTrack(); - posthog.capture("skip_next", { - from_track_id: selectedAudioId, - }); - }, [skipToNextTrack, posthog, selectedAudioId]); - - const handleShuffle = useCallback(() => { - toggleShuffle(); - posthog.capture("toggle_shuffle", { - shuffle_enabled: !isShuffled, - queue_size: audioSources.length, - }); - }, [toggleShuffle, posthog, isShuffled, audioSources.length]); - - // Handle keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only trigger if space is pressed and we're not in an input field - if ( - e.code === "Space" && - !( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - (e.target as HTMLElement).isContentEditable - ) - ) { - e.preventDefault(); - handlePlay(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [handlePlay]); - - return ( -
-
-
- - - - - -
-
- - {formatTime(sliderPosition)} - - - - {formatTime(trackDuration)} - -
-
-
- ); -}; diff --git a/apps/client/src/components/room/WebSocketManager.tsx b/apps/client/src/components/room/WebSocketManager.tsx index 4265711e..f8df3563 100644 --- a/apps/client/src/components/room/WebSocketManager.tsx +++ b/apps/client/src/components/room/WebSocketManager.tsx @@ -72,6 +72,17 @@ export const WebSocketManager = ({ (state) => state.handleSetAudioSources ); + // YouTube-related state and actions + const handleSetYouTubeSources = useGlobalStore( + (state) => state.handleSetYouTubeSources + ); + const setCurrentModeLocal = useGlobalStore((state) => state.setCurrentModeLocal); + const setSelectedAudioUrl = useGlobalStore((state) => state.setSelectedAudioUrl); + const setSelectedYouTubeId = useGlobalStore((state) => state.setSelectedYouTubeId); + const schedulePlayYouTube = useGlobalStore((state) => state.schedulePlayYouTube); + const schedulePauseYouTube = useGlobalStore((state) => state.schedulePauseYouTube); + const scheduleSeekYouTube = useGlobalStore((state) => state.scheduleSeekYouTube); + // Use the NTP heartbeat hook const { startHeartbeat, stopHeartbeat, markNTPResponseReceived } = useNtpHeartbeat({ @@ -154,6 +165,14 @@ export const WebSocketManager = ({ setConnectedClients(event.clients); } else if (event.type === "SET_AUDIO_SOURCES") { handleSetAudioSources({ sources: event.sources }); + } else if (event.type === "SET_YOUTUBE_SOURCES") { + handleSetYouTubeSources({ sources: event.sources }); + } else if (event.type === "SET_CURRENT_MODE") { + setCurrentModeLocal(event.mode); + } else if (event.type === "SET_SELECTED_AUDIO") { + setSelectedAudioUrl(event.audioUrl); + } else if (event.type === "SET_SELECTED_YOUTUBE") { + setSelectedYouTubeId(event.videoId); } } else if (response.type === "SCHEDULED_ACTION") { // handle scheduling action @@ -170,6 +189,22 @@ export const WebSocketManager = ({ schedulePause({ targetServerTime: serverTimeToExecute, }); + } else if (scheduledAction.type === "PLAY_YOUTUBE") { + schedulePlayYouTube({ + trackTimeSeconds: scheduledAction.trackTimeSeconds, + targetServerTime: serverTimeToExecute, + videoId: scheduledAction.videoId, + }); + } else if (scheduledAction.type === "PAUSE_YOUTUBE") { + schedulePauseYouTube({ + targetServerTime: serverTimeToExecute, + }); + } else if (scheduledAction.type === "SEEK_YOUTUBE") { + scheduleSeekYouTube({ + trackTimeSeconds: scheduledAction.trackTimeSeconds, + targetServerTime: serverTimeToExecute, + videoId: scheduledAction.videoId, + }); } else if (scheduledAction.type === "SPATIAL_CONFIG") { processSpatialConfig(scheduledAction); if (!isSpatialAudioEnabled) { diff --git a/apps/client/src/components/youtube/YouTubePlayer.tsx b/apps/client/src/components/youtube/YouTubePlayer.tsx new file mode 100644 index 00000000..610f8bbe --- /dev/null +++ b/apps/client/src/components/youtube/YouTubePlayer.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useGlobalStore } from "@/store/global"; +import { useCallback, useEffect, useRef, useState } from "react"; +import YouTube, { YouTubeProps, YouTubeEvent } from "react-youtube"; + +interface YouTubePlayerProps { + videoId?: string; + className?: string; +} + +export const YouTubePlayer = ({ + videoId, + className = "" +}: YouTubePlayerProps) => { + const playerRef = useRef(null); + const [playerState, setPlayerState] = useState(-1); + + // Global store state and actions + const selectedYouTubeId = useGlobalStore((state) => state.selectedYouTubeId); + const isYouTubePlayerReady = useGlobalStore((state) => state.isYouTubePlayerReady); + const setYouTubePlayerReady = useGlobalStore((state) => state.setYouTubePlayerReady); + const setYouTubePlayer = useGlobalStore((state) => state.setYouTubePlayer); + const playNextYouTubeVideo = useGlobalStore((state) => state.playNextYouTubeVideo); + + // Use selectedYouTubeId from store if no videoId prop is provided + const activeVideoId = videoId || selectedYouTubeId; + + // Player options + const opts: YouTubeProps['opts'] = { + height: '100%', + width: '100%', + playerVars: { + autoplay: 0, // Controlled programmatically + controls: 1, // Show YouTube controls for now + disablekb: 0, // Enable keyboard controls + fs: 1, // Enable fullscreen + iv_load_policy: 3, // Hide annotations + modestbranding: 1, // Minimal YouTube branding + rel: 0, // Don't show related videos + showinfo: 0, // Hide video info + origin: typeof window !== 'undefined' ? window.location.origin : process.env.NEXT_PUBLIC_NEXT_URL, // Fix CORS issue + enablejsapi: 1, // Enable JavaScript API + }, + }; + + // Handle player ready + const handlePlayerReady = useCallback((event: YouTubeEvent) => { + console.log("YouTube player ready"); + playerRef.current = event.target; + setYouTubePlayer(event.target); + setYouTubePlayerReady(true); + }, [setYouTubePlayer, setYouTubePlayerReady]); + + // Handle state changes + const handleStateChange = useCallback((event: YouTubeEvent) => { + const state = event.data; + setPlayerState(state); + + console.log("YouTube player state changed:", state); + + // YouTube player states: + // -1 (unstarted) + // 0 (ended) + // 1 (playing) + // 2 (paused) + // 3 (buffering) + // 5 (video cued) + + if (isYouTubePlayerReady && playerRef.current) { + // Only update isPlaying if this is a user-initiated state change + // Don't update for programmatic changes (from sync) + + // Handle specific state transitions + switch (state) { + case 1: // Playing + console.log("YouTube player is playing"); + // Only update isPlaying if it's not already playing (to avoid sync conflicts) + const currentIsPlaying = useGlobalStore.getState().isPlaying; + if (!currentIsPlaying) { + useGlobalStore.setState({ isPlaying: true }); + } + break; + case 2: // Paused + console.log("YouTube player is paused"); + // Only update isPlaying if it's currently playing (to avoid sync conflicts) + const currentIsPlayingPause = useGlobalStore.getState().isPlaying; + if (currentIsPlayingPause) { + useGlobalStore.setState({ isPlaying: false }); + } + break; + case 0: // Ended + console.log("YouTube video ended, playing next video"); + useGlobalStore.setState({ isPlaying: false }); + playNextYouTubeVideo(); + break; + } + } + }, [isYouTubePlayerReady, playNextYouTubeVideo]); + + // Handle errors + const handleError = useCallback((event: { data: number; target: YouTubeEvent['target'] }) => { + console.error("YouTube player error:", event); + }, []); + + // Clean up when component unmounts + useEffect(() => { + return () => { + if (playerRef.current) { + setYouTubePlayer(null); + setYouTubePlayerReady(false); + } + }; + }, [setYouTubePlayer, setYouTubePlayerReady]); + + if (!activeVideoId) { + return ( +
+

No YouTube video selected

+
+ ); + } + + return ( +
+ + {/* Show player state for debugging */} + {process.env.NODE_ENV === 'development' && ( +
+ Player State: {playerState} | Ready: {isYouTubePlayerReady.toString()} +
+ )} +
+ ); +}; diff --git a/apps/client/src/components/youtube/YouTubeQueue.tsx b/apps/client/src/components/youtube/YouTubeQueue.tsx new file mode 100644 index 00000000..2141035d --- /dev/null +++ b/apps/client/src/components/youtube/YouTubeQueue.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { useGlobalStore } from "@/store/global"; +import { AnimatePresence, motion } from "motion/react"; +import { Play, Pause, Youtube, Trash2 } from "lucide-react"; +import { usePostHog } from "posthog-js/react"; +import { Button } from "../ui/button"; + +interface YouTubeQueueProps { + className?: string; +} + +export const YouTubeQueue = ({ className, ...rest }: YouTubeQueueProps) => { + const posthog = usePostHog(); + + // Global store state + const youtubeSources = useGlobalStore((state) => state.youtubeSources); + const selectedYouTubeId = useGlobalStore((state) => state.selectedYouTubeId); + const isYouTubePlayerReady = useGlobalStore((state) => state.isYouTubePlayerReady); + const isPlaying = useGlobalStore((state) => state.isPlaying); + const setSelectedYouTubeId = useGlobalStore((state) => state.setSelectedYouTubeId); + const broadcastPlayYouTube = useGlobalStore((state) => state.broadcastPlayYouTube); + const removeYouTubeSource = useGlobalStore((state) => state.removeYouTubeSource); + + // Remove the unused playNextVideo function since we moved it to the global store + + const handleItemClick = (videoId: string) => { + if (videoId === selectedYouTubeId) { + // If clicking the currently selected video, toggle play/pause + if (isYouTubePlayerReady) { + // Note: We'll need to track playing state for YouTube + // For now, just broadcast play + broadcastPlayYouTube(0); + posthog.capture("play_youtube_video", { video_id: videoId }); + } + } else { + // Select new video + posthog.capture("select_youtube_video", { + video_id: videoId, + previous_video_id: selectedYouTubeId, + }); + + setSelectedYouTubeId(videoId); + // Auto-play when selecting a new video + if (isYouTubePlayerReady) { + broadcastPlayYouTube(0); + } + } + }; + + const handleRemove = (videoId: string, e: React.MouseEvent) => { + e.stopPropagation(); // Prevent triggering the item click + + removeYouTubeSource(videoId); + + posthog.capture("remove_youtube_video", { + video_id: videoId, + }); + }; + + if (youtubeSources.length === 0) { + return ( +
+ +

No YouTube videos added yet

+

+ Add a YouTube URL above to get started +

+
+ ); + } + + return ( +
+
+ + {youtubeSources.map((source, index) => { + const isSelected = source.videoId === selectedYouTubeId; + const isPlayingThis = isSelected && isPlaying; + + return ( + handleItemClick(source.videoId)} + > + {/* Play/Pause Icon */} +
+
+ {isPlayingThis ? ( + + ) : ( + + )} +
+
+ + {/* Video Info */} +
+
+ +

+ {source.title} +

+
+

+ Video ID: {source.videoId} +

+

+ Added by {source.addedBy} +

+
+ + {/* Remove Button */} + + + {/* Selected Indicator */} + {isSelected && ( +
+ )} + + ); + })} + +
+
+ ); +}; diff --git a/apps/client/src/components/youtube/YouTubeSearch.tsx b/apps/client/src/components/youtube/YouTubeSearch.tsx new file mode 100644 index 00000000..af360595 --- /dev/null +++ b/apps/client/src/components/youtube/YouTubeSearch.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { useState } from "react"; +import { Search, Play, Plus, Grid, List, Eye } from "lucide-react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Card, CardContent } from "../ui/card"; +import { useGlobalStore } from "@/store/global"; +import Image from "next/image"; + +interface SearchResult { + id: string; + title: string; + channelTitle: string; + thumbnail: string; + duration: string; + viewCount: string; + publishedAt: string; +} + +interface YouTubeSearchItem { + id: { + videoId: string; + }; + snippet: { + title: string; + channelTitle: string; + publishedAt: string; + thumbnails: { + default?: { url: string }; + medium?: { url: string }; + high?: { url: string }; + }; + }; +} + +interface YouTubeSearchResponse { + items?: YouTubeSearchItem[]; + error?: { + message: string; + }; +} + +export const YouTubeSearch = () => { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [error, setError] = useState(null); + + const addYouTubeSource = useGlobalStore((state) => state.addYouTubeSource); + const setSelectedYouTubeId = useGlobalStore((state) => state.setSelectedYouTubeId); + + // YouTube search function + const handleSearch = async () => { + if (!query.trim()) return; + + setIsLoading(true); + setError(null); + + try { + const response = await fetch('https://www.googleapis.com/youtube/v3/search?' + new URLSearchParams({ + q: query, + part: 'snippet', + type: 'video', + videoCategoryId: '10', // Music category + key: process.env.NEXT_PUBLIC_YOUTUBE_API_KEY!, // 🔐 consider hiding in .env + API proxy + maxResults: '10', + })); + + const data: YouTubeSearchResponse = await response.json(); + + if (data.error) { + throw new Error(data.error.message || 'YouTube API Error'); + } + + // Transform YouTube API response to our SearchResult format + const transformedResults: SearchResult[] = data.items?.map((item: YouTubeSearchItem) => ({ + id: item.id.videoId, + title: item.snippet.title, + channelTitle: item.snippet.channelTitle, + thumbnail: item.snippet.thumbnails.medium?.url || item.snippet.thumbnails.default?.url || '', + duration: 'N/A', // YouTube Search API doesn't provide duration, would need additional API call + viewCount: 'N/A', // YouTube Search API doesn't provide view count, would need additional API call + publishedAt: new Date(item.snippet.publishedAt).toLocaleDateString(), + })) || []; + + setResults(transformedResults); + } catch (error) { + console.error('YouTube API Error:', error); + setError(error instanceof Error ? error.message : 'Failed to search YouTube videos'); + setResults([]); + } finally { + setIsLoading(false); + } + } + + const handlePlay = (video: SearchResult) => { + addYouTubeSource({ + videoId: video.id, + title: video.title, + channel: video.channelTitle, + duration: video.duration, + thumbnail: video.thumbnail + }); + setSelectedYouTubeId(video.id); + }; + + const handleAddToQueue = (video: SearchResult) => { + addYouTubeSource({ + videoId: video.id, + title: video.title, + channel: video.channelTitle, + duration: video.duration, + thumbnail: video.thumbnail + }); + }; + + return ( +
+ {/* Search Input */} +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="pl-10 bg-neutral-800/50 border-neutral-700 text-white placeholder:text-neutral-500" + /> +
+ +
+ + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* View Mode Toggle */} + {results.length > 0 && ( +
+ View: + + + + {results.length} results + +
+ )} + + {/* Results */} +
+ {results.map((video) => ( + + + {viewMode === 'grid' ? ( +
+ {/* Thumbnail */} +
+ {video.title} +
+ + +
+
+ {video.duration} +
+
+ + {/* Video Info */} +
+

+ {video.title} +

+

{video.channelTitle}

+
+ + + {video.viewCount} + + + {video.publishedAt} +
+
+
+ ) : ( +
+ {/* Thumbnail */} +
+ {video.title} +
+ {video.duration} +
+
+ + {/* Video Info */} +
+

+ {video.title} +

+

{video.channelTitle}

+
+ + + {video.viewCount} + + + {video.publishedAt} +
+
+ + {/* Actions */} +
+ + +
+
+ )} +
+
+ ))} +
+ + {/* Empty State */} + {results.length === 0 && !isLoading && ( +
+ +

Search for YouTube videos to add to your queue

+
+ )} +
+ ); +}; + diff --git a/apps/client/src/store/global.tsx b/apps/client/src/store/global.tsx index f41726cd..c328b3c7 100644 --- a/apps/client/src/store/global.tsx +++ b/apps/client/src/store/global.tsx @@ -15,6 +15,7 @@ import { PositionType, SpatialConfigType, NTP_CONSTANTS, + YouTubeSourceType, } from "@beatsync/shared"; import { toast } from "sonner"; import { create } from "zustand"; @@ -24,6 +25,17 @@ import { extractFileNameFromUrl } from "@/lib/utils"; export const MAX_NTP_MEASUREMENTS = NTP_CONSTANTS.MAX_MEASUREMENTS; +// YouTube Player interface +interface YouTubePlayerInstance { + playVideo: () => void; + pauseVideo: () => void; + seekTo: (seconds: number, allowSeekAhead?: boolean) => void; + getCurrentTime: () => number; + getDuration: () => number; + setVolume: (volume: number) => void; + getVolume: () => number; +} + // https://webaudioapi.com/book/Web_Audio_API_Boris_Smus_html/ch02.html interface AudioPlayerState { @@ -45,6 +57,15 @@ interface GlobalStateValues { hasUserStartedSystem: boolean; // Track if user has clicked "Start System" at least once selectedAudioUrl: string; + // YouTube Sources + youtubeSources: YouTubeSourceType[]; // YouTube playlist order, server-synced + selectedYouTubeId: string; + youtubePlayer: YouTubePlayerInstance | null; // YouTube player instance + isYouTubePlayerReady: boolean; + + // Current mode: library or youtube + currentMode: "library" | "youtube"; + // Websocket socket: WebSocket | null; lastMessageReceivedTime: number | null; @@ -77,6 +98,7 @@ interface GlobalStateValues { // Shuffle state isShuffled: boolean; + repeatMode: "none" | "all" | "one"; reconnectionInfo: { isReconnecting: boolean; currentAttempt: number; @@ -102,6 +124,38 @@ interface GlobalState extends GlobalStateValues { setSocket: (socket: WebSocket) => void; broadcastPlay: (trackTimeSeconds?: number) => void; broadcastPause: () => void; + + // YouTube methods + handleSetYouTubeSources: ({ sources }: { sources: YouTubeSourceType[] }) => void; + setSelectedYouTubeId: (videoId: string) => void; + addYouTubeSource: (source: YouTubeSourceType) => void; + removeYouTubeSource: (videoId: string) => void; + setYouTubePlayer: (player: YouTubePlayerInstance | null) => void; + setYouTubePlayerReady: (ready: boolean) => void; + broadcastPlayYouTube: (trackTimeSeconds?: number) => void; + broadcastPauseYouTube: () => void; + broadcastSeekYouTube: (trackTimeSeconds: number) => void; + skipToNextYouTubeVideo: () => void; + skipToPreviousYouTubeVideo: () => void; + playNextYouTubeVideo: () => void; + setCurrentMode: (mode: "library" | "youtube") => void; + setCurrentModeLocal: (mode: "library" | "youtube") => void; + schedulePlayYouTube: (data: { + trackTimeSeconds: number; + targetServerTime: number; + videoId: string; + }) => void; + schedulePauseYouTube: (data: { targetServerTime: number }) => void; + scheduleSeekYouTube: (data: { + trackTimeSeconds: number; + targetServerTime: number; + videoId: string; + }) => void; + setRepeatMode: (mode: "none" | "all" | "one") => void; + setIsShuffled: (shuffled: boolean) => void; + setVolume: (volume: number) => void; + getVolume: () => number; + startSpatialAudio: () => void; sendStopSpatialAudio: () => void; setSpatialConfig: (config: SpatialConfigType) => void; @@ -141,6 +195,13 @@ const initialState: GlobalStateValues = { audioSources: [], audioCache: new Map(), + // YouTube Sources + youtubeSources: [], + selectedYouTubeId: "", + youtubePlayer: null, + isYouTubePlayerReady: false, + currentMode: "library", + // Audio playback state isPlaying: false, currentTime: 0, @@ -150,6 +211,7 @@ const initialState: GlobalStateValues = { // Spatial audio isShuffled: false, + repeatMode: "none", isSpatialAudioEnabled: false, isDraggingListeningSource: false, listeningSourcePosition: { x: GRID.SIZE / 2, y: GRID.SIZE / 2 }, @@ -964,5 +1026,314 @@ export const useGlobalStore = create((set, get) => { initializeAudioExclusively(); }, setReconnectionInfo: (info) => set({ reconnectionInfo: info }), + + // YouTube methods + handleSetYouTubeSources: ({ sources }) => { + const state = get(); + set({ youtubeSources: sources }); + + // If no YouTube video is selected and we have sources, select the first one + if (!state.selectedYouTubeId && sources.length > 0) { + set({ selectedYouTubeId: sources[0].videoId }); + } + + // If the currently selected video is no longer in the sources, clear it + if (state.selectedYouTubeId && !sources.some(s => s.videoId === state.selectedYouTubeId)) { + set({ selectedYouTubeId: "" }); + } + }, + + setSelectedYouTubeId: (videoId) => set({ selectedYouTubeId: videoId }), + + addYouTubeSource: (source) => { + const state = get(); + const { socket } = getSocket(state); + + // Don't add to local state - wait for server response + // This ensures all clients get the same synchronized state + + // Broadcast to server + sendWSRequest({ + ws: socket, + request: { + type: ClientActionEnum.enum.ADD_YOUTUBE_SOURCE, + source, + }, + }); + }, + + removeYouTubeSource: (videoId) => { + const state = get(); + const { socket } = getSocket(state); + + // Don't remove from local state - wait for server response + // This ensures all clients get the same synchronized state + + // Broadcast to server + sendWSRequest({ + ws: socket, + request: { + type: ClientActionEnum.enum.REMOVE_YOUTUBE_SOURCE, + videoId, + }, + }); + }, + + setYouTubePlayer: (player) => set({ youtubePlayer: player }), + + setYouTubePlayerReady: (ready) => set({ isYouTubePlayerReady: ready }), + + broadcastPlayYouTube: (trackTimeSeconds?: number) => { + const state = get(); + const { socket } = getSocket(state); + + // Use selected YouTube video or fall back to first video source + let videoId = state.selectedYouTubeId; + if (!videoId && state.youtubeSources.length > 0) { + videoId = state.youtubeSources[0].videoId; + // Update the selected video + set({ selectedYouTubeId: videoId }); + } + + if (!videoId) { + console.error("Cannot broadcast YouTube play: No video available"); + return; + } + + sendWSRequest({ + ws: socket, + request: { + type: ClientActionEnum.enum.PLAY_YOUTUBE, + trackTimeSeconds: trackTimeSeconds ?? 0, + videoId: videoId, + }, + }); + }, + + broadcastPauseYouTube: () => { + const state = get(); + const { socket } = getSocket(state); + + // Use selected YouTube video or fall back to first video source + let videoId = state.selectedYouTubeId; + if (!videoId && state.youtubeSources.length > 0) { + videoId = state.youtubeSources[0].videoId; + // Update the selected video + set({ selectedYouTubeId: videoId }); + } + + if (!videoId) { + console.error("Cannot broadcast YouTube pause: No video available"); + return; + } + + sendWSRequest({ + ws: socket, + request: { + type: ClientActionEnum.enum.PAUSE_YOUTUBE, + trackTimeSeconds: 0, // YouTube pause doesn't need precise timing + videoId: videoId, + }, + }); + }, + + broadcastSeekYouTube: (trackTimeSeconds) => { + const state = get(); + const { socket } = getSocket(state); + + // Use selected YouTube video or fall back to first video source + let videoId = state.selectedYouTubeId; + if (!videoId && state.youtubeSources.length > 0) { + videoId = state.youtubeSources[0].videoId; + // Update the selected video + set({ selectedYouTubeId: videoId }); + } + + if (!videoId) { + console.error("Cannot broadcast YouTube seek: No video available"); + return; + } + + sendWSRequest({ + ws: socket, + request: { + type: ClientActionEnum.enum.SEEK_YOUTUBE, + trackTimeSeconds, + videoId: videoId, + }, + }); + }, + + skipToNextYouTubeVideo: () => { + const state = get(); + const currentIndex = state.youtubeSources.findIndex( + s => s.videoId === state.selectedYouTubeId + ); + + if (currentIndex === -1 || state.youtubeSources.length === 0) return; + + let nextIndex; + if (state.isShuffled) { + // Generate random index that's not the current one + do { + nextIndex = Math.floor(Math.random() * state.youtubeSources.length); + } while (nextIndex === currentIndex && state.youtubeSources.length > 1); + } else { + // Handle repeat mode + if (state.repeatMode === "one") { + nextIndex = currentIndex; // Stay on same video + } else if (state.repeatMode === "all") { + nextIndex = (currentIndex + 1) % state.youtubeSources.length; + } else { + // No repeat + nextIndex = currentIndex + 1; + if (nextIndex >= state.youtubeSources.length) { + // End of playlist + return; + } + } + } + + const nextVideo = state.youtubeSources[nextIndex]; + if (nextVideo) { + set({ selectedYouTubeId: nextVideo.videoId }); + // Auto-play the next video + get().broadcastPlayYouTube(0); + } + }, + + skipToPreviousYouTubeVideo: () => { + const state = get(); + const currentIndex = state.youtubeSources.findIndex( + s => s.videoId === state.selectedYouTubeId + ); + + if (currentIndex === -1 || state.youtubeSources.length === 0) return; + + let prevIndex; + if (state.isShuffled) { + // Generate random index that's not the current one + do { + prevIndex = Math.floor(Math.random() * state.youtubeSources.length); + } while (prevIndex === currentIndex && state.youtubeSources.length > 1); + } else { + // Handle repeat mode + if (state.repeatMode === "one") { + prevIndex = currentIndex; // Stay on same video + } else if (state.repeatMode === "all") { + prevIndex = currentIndex - 1; + if (prevIndex < 0) { + prevIndex = state.youtubeSources.length - 1; // Wrap to end + } + } else { + // No repeat + prevIndex = currentIndex - 1; + if (prevIndex < 0) { + // Beginning of playlist + return; + } + } + } + + const prevVideo = state.youtubeSources[prevIndex]; + if (prevVideo) { + set({ selectedYouTubeId: prevVideo.videoId }); + // Auto-play the previous video + get().broadcastPlayYouTube(0); + } + }, + + playNextYouTubeVideo: () => { + // This is called when a video ends + get().skipToNextYouTubeVideo(); + }, + + setCurrentMode: (mode) => { + const state = get(); + const { socket } = getSocket(state); + + set({ currentMode: mode }); + + sendWSRequest({ + ws: socket, + request: { + type: ClientActionEnum.enum.SET_MODE, + mode, + }, + }); + }, + + setCurrentModeLocal: (mode) => { + set({ currentMode: mode }); + }, + + schedulePlayYouTube: ({ trackTimeSeconds, targetServerTime, videoId }) => { + const state = get(); + const waitTimeSeconds = getWaitTimeSeconds(state, targetServerTime); + + console.log(`Scheduling YouTube play at ${waitTimeSeconds}s from now`); + + setTimeout(() => { + const currentState = get(); + if (currentState.selectedYouTubeId === videoId && currentState.youtubePlayer && currentState.isYouTubePlayerReady) { + try { + // Seek to position and play + currentState.youtubePlayer.seekTo(trackTimeSeconds, true); + currentState.youtubePlayer.playVideo(); + set({ isPlaying: true }); + console.log(`YouTube video played at ${trackTimeSeconds}s`); + } catch (error) { + console.error("Error playing YouTube video:", error); + } + } + }, waitTimeSeconds * 1000); + }, + + schedulePauseYouTube: ({ targetServerTime }) => { + const state = get(); + const waitTimeSeconds = getWaitTimeSeconds(state, targetServerTime); + + console.log(`Scheduling YouTube pause at ${waitTimeSeconds}s from now`); + + setTimeout(() => { + const currentState = get(); + if (currentState.youtubePlayer && currentState.isYouTubePlayerReady) { + try { + currentState.youtubePlayer.pauseVideo(); + set({ isPlaying: false }); + console.log("YouTube video paused"); + } catch (error) { + console.error("Error pausing YouTube video:", error); + } + } + }, waitTimeSeconds * 1000); + }, + + scheduleSeekYouTube: ({ trackTimeSeconds, targetServerTime, videoId }) => { + const state = get(); + const waitTimeSeconds = getWaitTimeSeconds(state, targetServerTime); + + console.log(`Scheduling YouTube seek at ${waitTimeSeconds}s from now`); + + setTimeout(() => { + const currentState = get(); + if (currentState.selectedYouTubeId === videoId && currentState.youtubePlayer && currentState.isYouTubePlayerReady) { + try { + currentState.youtubePlayer.seekTo(trackTimeSeconds, true); + console.log(`YouTube video seeked to ${trackTimeSeconds}s`); + } catch (error) { + console.error("Error seeking YouTube video:", error); + } + } + }, waitTimeSeconds * 1000); + }, + + setRepeatMode: (mode) => set({ repeatMode: mode }), + + setIsShuffled: (shuffled) => set({ isShuffled: shuffled }), + + setVolume: (volume) => set({ volume }), + + getVolume: () => get().volume, }; }); diff --git a/apps/server/src/lib/upload-defaults.ts b/apps/server/src/lib/upload-defaults.ts new file mode 100644 index 00000000..d9d4d861 --- /dev/null +++ b/apps/server/src/lib/upload-defaults.ts @@ -0,0 +1,90 @@ +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { config } from "dotenv"; +import { readFile } from "fs/promises"; +import { join } from "path"; + +config(); + +const S3_CONFIG = { + BUCKET_NAME: process.env.S3_BUCKET_NAME!, + ENDPOINT: process.env.S3_ENDPOINT!, + ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID!, + SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY!, +}; + +const r2Client = new S3Client({ + region: "auto", + endpoint: S3_CONFIG.ENDPOINT, + credentials: { + accessKeyId: S3_CONFIG.ACCESS_KEY_ID, + secretAccessKey: S3_CONFIG.SECRET_ACCESS_KEY, + }, + forcePathStyle: true, +}); + +async function uploadDefaultAudioFiles() { + console.log("🎵 Uploading default audio files to MinIO"); + console.log("=========================================="); + + // Path to the client's public folder + const publicPath = join(__dirname, "../../../client/public"); + + // List of audio files to upload + const audioFiles = [ + "DROELOE x San Holo - Lines of the Broken (ft. CUT).mp3", + "INZO x ILLUSIO - Just A Mirage.mp3", + "Jacob Tillberg - Feel You.mp3", + "joyful - chess (slowed).mp3", + "STVCKS - Don't Be Scared.mp3", + "Tom Reev, Assix & Jason Gewalt - Where It Hurts.mp3", + "trndsttr.mp3", + ]; + + let uploadedCount = 0; + let failedCount = 0; + + for (const fileName of audioFiles) { + try { + console.log(`📂 Uploading: ${fileName}`); + + const filePath = join(publicPath, fileName); + const fileContent = await readFile(filePath); + + const command = new PutObjectCommand({ + Bucket: S3_CONFIG.BUCKET_NAME, + Key: `default/${fileName}`, + Body: fileContent, + ContentType: "audio/mpeg", + Metadata: { + uploadedAt: new Date().toISOString(), + type: "default-audio", + }, + }); + + await r2Client.send(command); + console.log(`✅ Uploaded: ${fileName}`); + uploadedCount++; + + } catch (error) { + console.error(`❌ Failed to upload ${fileName}:`, error); + failedCount++; + } + } + + console.log(""); + console.log("📊 Upload Summary:"); + console.log(` ✅ Successful: ${uploadedCount}`); + console.log(` ❌ Failed: ${failedCount}`); + console.log(` 📦 Total: ${audioFiles.length}`); + + if (uploadedCount > 0) { + console.log(""); + console.log("🌐 Default audio files are now available at:"); + audioFiles.slice(0, uploadedCount).forEach(file => { + console.log(` ${process.env.S3_PUBLIC_URL}/default/${file}`); + }); + } +} + +// Run the upload +uploadDefaultAudioFiles().catch(console.error); diff --git a/apps/server/src/managers/RoomManager.ts b/apps/server/src/managers/RoomManager.ts index f21c9d50..e404e1e7 100644 --- a/apps/server/src/managers/RoomManager.ts +++ b/apps/server/src/managers/RoomManager.ts @@ -2,12 +2,16 @@ import { ClientType, epochNow, AudioSourceType, + YouTubeSourceType, PositionType, WSBroadcastType, NTP_CONSTANTS, RoomType, PlayActionType, PauseActionType, + PlayYouTubeActionType, + PauseYouTubeActionType, + SeekYouTubeActionType, } from "@beatsync/shared"; import { GRID } from "@beatsync/shared/types/basic"; import { Server, ServerWebSocket } from "bun"; @@ -21,6 +25,10 @@ import { z } from "zod"; interface RoomData { audioSources: AudioSourceType[]; + youtubeSources: YouTubeSourceType[]; + currentMode: "library" | "youtube"; + selectedAudioUrl: string; + selectedYouTubeId: string; clients: Map; roomId: string; intervalId?: NodeJS.Timeout; @@ -42,6 +50,10 @@ type RoomPlaybackState = z.infer; export class RoomManager { private clients = new Map(); private audioSources: AudioSourceType[] = []; + private youtubeSources: YouTubeSourceType[] = []; + private currentMode: "library" | "youtube" = "library"; + private selectedAudioUrl: string = ""; + private selectedYouTubeId: string = ""; private listeningSource: PositionType; private intervalId?: NodeJS.Timeout; private cleanupTimer?: NodeJS.Timeout; @@ -166,6 +178,10 @@ export class RoomManager { getState(): RoomData { return { audioSources: this.audioSources, + youtubeSources: this.youtubeSources, + currentMode: this.currentMode, + selectedAudioUrl: this.selectedAudioUrl, + selectedYouTubeId: this.selectedYouTubeId, clients: this.clients, roomId: this.roomId, intervalId: this.intervalId, @@ -560,4 +576,66 @@ export class RoomManager { console.log(`💔 Stopped heartbeat checking for room ${this.roomId}`); } } + + // YouTube methods + addYouTubeSource(source: YouTubeSourceType): void { + this.youtubeSources.push(source); + } + + removeYouTubeSource(videoId: string): void { + this.youtubeSources = this.youtubeSources.filter(s => s.videoId !== videoId); + // If we're removing the currently selected video, clear selection + if (this.selectedYouTubeId === videoId) { + this.selectedYouTubeId = ""; + } + } + + setCurrentMode(mode: "library" | "youtube"): void { + this.currentMode = mode; + } + + setSelectedAudio(audioUrl: string): void { + this.selectedAudioUrl = audioUrl; + } + + setSelectedYouTube(videoId: string): void { + this.selectedYouTubeId = videoId; + } + + updatePlaybackSchedulePlayYouTube( + playSchema: PlayYouTubeActionType, + serverTimeToExecute: number + ): void { + this.playbackState = { + type: "playing", + audioSource: playSchema.videoId, // Use videoId as the "audio source" for YouTube + trackPositionSeconds: playSchema.trackTimeSeconds, + serverTimeToExecute: serverTimeToExecute, + }; + } + + updatePlaybackSchedulePauseYouTube( + pauseSchema: PauseYouTubeActionType, + serverTimeToExecute: number + ): void { + this.playbackState = { + type: "paused", + audioSource: pauseSchema.videoId, + trackPositionSeconds: pauseSchema.trackTimeSeconds, + serverTimeToExecute: serverTimeToExecute, + }; + } + + updatePlaybackScheduleSeekYouTube( + seekSchema: SeekYouTubeActionType, + serverTimeToExecute: number + ): void { + // For seek, we update the track position but keep the current play/pause state + this.playbackState = { + ...this.playbackState, + audioSource: seekSchema.videoId, + trackPositionSeconds: seekSchema.trackTimeSeconds, + serverTimeToExecute: serverTimeToExecute, + }; + } } diff --git a/apps/server/src/routes/websocketHandlers.ts b/apps/server/src/routes/websocketHandlers.ts index 00f51b9e..e79b3f44 100644 --- a/apps/server/src/routes/websocketHandlers.ts +++ b/apps/server/src/routes/websocketHandlers.ts @@ -41,22 +41,70 @@ export const handleOpen = (ws: ServerWebSocket, server: Server) => { room.addClient(ws); // Send audio sources to the newly joined client if any exist - const { audioSources } = room.getState(); - if (audioSources.length > 0) { + const roomState = room.getState(); + if (roomState.audioSources.length > 0) { console.log( - `Sending ${audioSources.length} audio source(s) to newly joined client ${ws.data.username}` + `Sending ${roomState.audioSources.length} audio source(s) to newly joined client ${ws.data.username}` ); const audioSourcesMessage: WSBroadcastType = { type: "ROOM_EVENT", event: { type: "SET_AUDIO_SOURCES", - sources: audioSources, + sources: roomState.audioSources, }, }; // Send directly to the WebSocket since this is a broadcast-type message sent to a single client ws.send(JSON.stringify(audioSourcesMessage)); } + // Send YouTube sources to the newly joined client if any exist + if (roomState.youtubeSources.length > 0) { + console.log( + `Sending ${roomState.youtubeSources.length} YouTube source(s) to newly joined client ${ws.data.username}` + ); + const youtubeSourcesMessage: WSBroadcastType = { + type: "ROOM_EVENT", + event: { + type: "SET_YOUTUBE_SOURCES", + sources: roomState.youtubeSources, + }, + }; + ws.send(JSON.stringify(youtubeSourcesMessage)); + } + + // Send current mode to newly joined client + const currentModeMessage: WSBroadcastType = { + type: "ROOM_EVENT", + event: { + type: "SET_CURRENT_MODE", + mode: roomState.currentMode, + }, + }; + ws.send(JSON.stringify(currentModeMessage)); + + // Send current selections to newly joined client + if (roomState.selectedAudioUrl) { + const selectedAudioMessage: WSBroadcastType = { + type: "ROOM_EVENT", + event: { + type: "SET_SELECTED_AUDIO", + audioUrl: roomState.selectedAudioUrl, + }, + }; + ws.send(JSON.stringify(selectedAudioMessage)); + } + + if (roomState.selectedYouTubeId) { + const selectedYouTubeMessage: WSBroadcastType = { + type: "ROOM_EVENT", + event: { + type: "SET_SELECTED_YOUTUBE", + videoId: roomState.selectedYouTubeId, + }, + }; + ws.send(JSON.stringify(selectedYouTubeMessage)); + } + const message = createClientUpdate(roomId); sendBroadcast({ server, roomId, message }); }; @@ -101,18 +149,27 @@ export const handleMessage = async ( return; } else if ( parsedMessage.type === ClientActionEnum.enum.PLAY || - parsedMessage.type === ClientActionEnum.enum.PAUSE + parsedMessage.type === ClientActionEnum.enum.PAUSE || + parsedMessage.type === ClientActionEnum.enum.PLAY_YOUTUBE || + parsedMessage.type === ClientActionEnum.enum.PAUSE_YOUTUBE || + parsedMessage.type === ClientActionEnum.enum.SEEK_YOUTUBE ) { const room = globalManager.getRoom(roomId); if (!room) return; const serverTimeToExecute = epochNow() + SCHEDULE_TIME_MS; - // Update playback state + // Update playback state based on action type if (parsedMessage.type === ClientActionEnum.enum.PLAY) { room.updatePlaybackSchedulePlay(parsedMessage, serverTimeToExecute); } else if (parsedMessage.type === ClientActionEnum.enum.PAUSE) { room.updatePlaybackSchedulePause(parsedMessage, serverTimeToExecute); + } else if (parsedMessage.type === ClientActionEnum.enum.PLAY_YOUTUBE) { + room.updatePlaybackSchedulePlayYouTube(parsedMessage, serverTimeToExecute); + } else if (parsedMessage.type === ClientActionEnum.enum.PAUSE_YOUTUBE) { + room.updatePlaybackSchedulePauseYouTube(parsedMessage, serverTimeToExecute); + } else if (parsedMessage.type === ClientActionEnum.enum.SEEK_YOUTUBE) { + room.updatePlaybackScheduleSeekYouTube(parsedMessage, serverTimeToExecute); } sendBroadcast({ @@ -127,6 +184,106 @@ export const handleMessage = async ( }); return; + } else if ( + parsedMessage.type === ClientActionEnum.enum.ADD_YOUTUBE_SOURCE + ) { + const room = globalManager.getRoom(roomId); + if (!room) return; + + room.addYouTubeSource(parsedMessage.source); + + // Broadcast updated YouTube sources to all clients + sendBroadcast({ + server, + roomId, + message: { + type: "ROOM_EVENT", + event: { + type: "SET_YOUTUBE_SOURCES", + sources: room.getState().youtubeSources, + }, + }, + }); + } else if ( + parsedMessage.type === ClientActionEnum.enum.REMOVE_YOUTUBE_SOURCE + ) { + const room = globalManager.getRoom(roomId); + if (!room) return; + + room.removeYouTubeSource(parsedMessage.videoId); + + // Broadcast updated YouTube sources to all clients + sendBroadcast({ + server, + roomId, + message: { + type: "ROOM_EVENT", + event: { + type: "SET_YOUTUBE_SOURCES", + sources: room.getState().youtubeSources, + }, + }, + }); + } else if ( + parsedMessage.type === ClientActionEnum.enum.SET_MODE + ) { + const room = globalManager.getRoom(roomId); + if (!room) return; + + room.setCurrentMode(parsedMessage.mode); + + // Broadcast mode change to all clients + sendBroadcast({ + server, + roomId, + message: { + type: "ROOM_EVENT", + event: { + type: "SET_CURRENT_MODE", + mode: parsedMessage.mode, + }, + }, + }); + } else if ( + parsedMessage.type === ClientActionEnum.enum.SET_SELECTED_AUDIO + ) { + const room = globalManager.getRoom(roomId); + if (!room) return; + + room.setSelectedAudio(parsedMessage.audioUrl); + + // Broadcast selection change to all clients + sendBroadcast({ + server, + roomId, + message: { + type: "ROOM_EVENT", + event: { + type: "SET_SELECTED_AUDIO", + audioUrl: parsedMessage.audioUrl, + }, + }, + }); + } else if ( + parsedMessage.type === ClientActionEnum.enum.SET_SELECTED_YOUTUBE + ) { + const room = globalManager.getRoom(roomId); + if (!room) return; + + room.setSelectedYouTube(parsedMessage.videoId); + + // Broadcast selection change to all clients + sendBroadcast({ + server, + roomId, + message: { + type: "ROOM_EVENT", + event: { + type: "SET_SELECTED_YOUTUBE", + videoId: parsedMessage.videoId, + }, + }, + }); } else if ( parsedMessage.type === ClientActionEnum.enum.START_SPATIAL_AUDIO ) { diff --git a/bun.lock b/bun.lock index bb6bab4d..cfa5b319 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "name": "beatsync", "dependencies": { "@aws-sdk/s3-request-presigner": "^3.828.0", + "@types/youtube": "^0.1.2", + "react-youtube": "^10.1.0", "turbo": "^2.4.4", }, "devDependencies": { @@ -536,6 +538,8 @@ "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@types/youtube": ["@types/youtube@0.1.2", "", {}, "sha512-n1/KqusanheyQRWHamNZv8K3kydlRqyEsZEKxMTeNWXQTC15lZprITCUt+WgL1vAIvKHCjPBIWz/gf/KQLsB3g=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.26.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.26.1", "@typescript-eslint/type-utils": "8.26.1", "@typescript-eslint/utils": "8.26.1", "@typescript-eslint/visitor-keys": "8.26.1", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-2X3mwqsj9Bd3Ciz508ZUtoQQYpOhU/kWoUqIf49H8Z0+Vbh6UF/y0OEYp0Q0axOGzaBGs7QxRwq0knSQ8khQNA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.26.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.26.1", "@typescript-eslint/types": "8.26.1", "@typescript-eslint/typescript-estree": "8.26.1", "@typescript-eslint/visitor-keys": "8.26.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-w6HZUV4NWxqd8BdeFf81t07d7/YV9s7TCWrQQbG5uhuvGUAW+fq1usZ1Hmz9UPNLniFnD8GLSsDpjP0hm1S4lQ=="], @@ -932,6 +936,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.29.2", "", { "os": "win32", "cpu": "x64" }, "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA=="], + "load-script": ["load-script@1.0.0", "", {}, "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.get": ["lodash.get@4.4.2", "", {}, "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="], @@ -1048,6 +1054,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-youtube": ["react-youtube@10.1.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "prop-types": "15.8.1", "youtube-player": "5.5.2" }, "peerDependencies": { "react": ">=0.14.1" } }, "sha512-ZfGtcVpk0SSZtWCSTYOQKhfx5/1cfyEW1JN/mugGNfAxT3rmVJeMbGpA9+e78yG21ls5nc/5uZJETE3cm3knBg=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], @@ -1102,6 +1110,8 @@ "sinon": ["sinon@21.0.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^13.0.5", "@sinonjs/samsam": "^8.0.1", "diff": "^7.0.0", "supports-color": "^7.2.0" } }, "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw=="], + "sister": ["sister@3.0.2", "", {}, "sha512-p19rtTs+NksBRKW9qn0UhZ8/TUI9BPw9lmtHny+Y3TinWlOa9jWh9xB0AtPSdmOy49NJJJSSe0Ey4C7h0TrcYA=="], + "sonner": ["sonner@2.0.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1222,6 +1232,8 @@ "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], + "youtube-player": ["youtube-player@5.5.2", "", { "dependencies": { "debug": "^2.6.6", "load-script": "^1.0.0", "sister": "^3.0.0" } }, "sha512-ZGtsemSpXnDky2AUYWgxjaopgB+shFHgXVpiJFeNB5nWEugpW1KWYDaHKuLqh2b67r24GtP6HoSW5swvf0fFIQ=="], + "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="], @@ -1390,6 +1402,8 @@ "sharp/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "youtube-player/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -1464,6 +1478,8 @@ "server/@types/bun/bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], + "youtube-player/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], diff --git a/package.json b/package.json index 13cb4287..fde5ff90 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ }, "dependencies": { "@aws-sdk/s3-request-presigner": "^3.828.0", + "@types/youtube": "^0.1.2", + "react-youtube": "^10.1.0", "turbo": "^2.4.4" } } diff --git a/packages/shared/types/WSBroadcast.ts b/packages/shared/types/WSBroadcast.ts index d580ddb2..e793b89a 100644 --- a/packages/shared/types/WSBroadcast.ts +++ b/packages/shared/types/WSBroadcast.ts @@ -1,6 +1,12 @@ import { z } from "zod"; -import { PauseActionSchema, PlayActionSchema } from "./WSRequest"; -import { AudioSourceSchema, PositionSchema } from "./basic"; +import { + PauseActionSchema, + PlayActionSchema, + PlayYouTubeActionSchema, + PauseYouTubeActionSchema, + SeekYouTubeActionSchema +} from "./WSRequest"; +import { AudioSourceSchema, PositionSchema, YouTubeSourceSchema } from "./basic"; // ROOM EVENTS @@ -26,11 +32,42 @@ const SetAudioSourcesSchema = z.object({ }); export type SetAudioSourcesType = z.infer; +// Set YouTube sources +const SetYouTubeSourcesSchema = z.object({ + type: z.literal("SET_YOUTUBE_SOURCES"), + sources: z.array(YouTubeSourceSchema), +}); +export type SetYouTubeSourcesType = z.infer; + +// Set current mode +const SetCurrentModeSchema = z.object({ + type: z.literal("SET_CURRENT_MODE"), + mode: z.enum(["library", "youtube"]), +}); +export type SetCurrentModeType = z.infer; + +// Set selected items +const SetSelectedAudioSchema = z.object({ + type: z.literal("SET_SELECTED_AUDIO"), + audioUrl: z.string(), +}); +export type SetSelectedAudioType = z.infer; + +const SetSelectedYouTubeSchema = z.object({ + type: z.literal("SET_SELECTED_YOUTUBE"), + videoId: z.string(), +}); +export type SetSelectedYouTubeType = z.infer; + const RoomEventSchema = z.object({ type: z.literal("ROOM_EVENT"), event: z.discriminatedUnion("type", [ ClientChangeMessageSchema, SetAudioSourcesSchema, + SetYouTubeSourcesSchema, + SetCurrentModeSchema, + SetSelectedAudioSchema, + SetSelectedYouTubeSchema, ]), }); @@ -57,6 +94,9 @@ export const ScheduledActionSchema = z.object({ scheduledAction: z.discriminatedUnion("type", [ PlayActionSchema, PauseActionSchema, + PlayYouTubeActionSchema, + PauseYouTubeActionSchema, + SeekYouTubeActionSchema, SpatialConfigSchema, StopSpatialAudioSchema, ]), diff --git a/packages/shared/types/WSRequest.ts b/packages/shared/types/WSRequest.ts index 5ca545f3..019d868e 100644 --- a/packages/shared/types/WSRequest.ts +++ b/packages/shared/types/WSRequest.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { PositionSchema } from "./basic"; +import { PositionSchema, YouTubeSourceSchema } from "./basic"; export const ClientSchema = z.object({ username: z.string(), clientId: z.string(), @@ -16,6 +16,14 @@ export const ClientActionEnum = z.enum([ "SET_LISTENING_SOURCE", "MOVE_CLIENT", "SYNC", // Client joins late, requests sync + "PLAY_YOUTUBE", + "PAUSE_YOUTUBE", + "SEEK_YOUTUBE", + "SET_MODE", + "ADD_YOUTUBE_SOURCE", + "REMOVE_YOUTUBE_SOURCE", + "SET_SELECTED_AUDIO", + "SET_SELECTED_YOUTUBE", ]); export const NTPRequestPacketSchema = z.object({ @@ -66,6 +74,50 @@ const ClientRequestSyncSchema = z.object({ }); export type ClientRequestSyncType = z.infer; +// YouTube action schemas +export const PlayYouTubeActionSchema = z.object({ + type: z.literal(ClientActionEnum.enum.PLAY_YOUTUBE), + trackTimeSeconds: z.number(), + videoId: z.string(), +}); + +export const PauseYouTubeActionSchema = z.object({ + type: z.literal(ClientActionEnum.enum.PAUSE_YOUTUBE), + videoId: z.string(), + trackTimeSeconds: z.number(), +}); + +export const SeekYouTubeActionSchema = z.object({ + type: z.literal(ClientActionEnum.enum.SEEK_YOUTUBE), + videoId: z.string(), + trackTimeSeconds: z.number(), +}); + +const SetModeActionSchema = z.object({ + type: z.literal(ClientActionEnum.enum.SET_MODE), + mode: z.enum(["library", "youtube"]), +}); + +const AddYouTubeSourceActionSchema = z.object({ + type: z.literal(ClientActionEnum.enum.ADD_YOUTUBE_SOURCE), + source: YouTubeSourceSchema, +}); + +const RemoveYouTubeSourceActionSchema = z.object({ + type: z.literal(ClientActionEnum.enum.REMOVE_YOUTUBE_SOURCE), + videoId: z.string(), +}); + +const SetSelectedAudioActionSchema = z.object({ + type: z.literal(ClientActionEnum.enum.SET_SELECTED_AUDIO), + audioUrl: z.string(), +}); + +const SetSelectedYouTubeActionSchema = z.object({ + type: z.literal(ClientActionEnum.enum.SET_SELECTED_YOUTUBE), + videoId: z.string(), +}); + export const WSRequestSchema = z.discriminatedUnion("type", [ PlayActionSchema, PauseActionSchema, @@ -76,9 +128,27 @@ export const WSRequestSchema = z.discriminatedUnion("type", [ SetListeningSourceSchema, MoveClientSchema, ClientRequestSyncSchema, + PlayYouTubeActionSchema, + PauseYouTubeActionSchema, + SeekYouTubeActionSchema, + SetModeActionSchema, + AddYouTubeSourceActionSchema, + RemoveYouTubeSourceActionSchema, + SetSelectedAudioActionSchema, + SetSelectedYouTubeActionSchema, ]); export type WSRequestType = z.infer; export type PlayActionType = z.infer; export type PauseActionType = z.infer; export type ReorderClientType = z.infer; export type SetListeningSourceType = z.infer; + +// Export YouTube action types +export type PlayYouTubeActionType = z.infer; +export type PauseYouTubeActionType = z.infer; +export type SeekYouTubeActionType = z.infer; +export type SetModeActionType = z.infer; +export type AddYouTubeSourceActionType = z.infer; +export type RemoveYouTubeSourceActionType = z.infer; +export type SetSelectedAudioActionType = z.infer; +export type SetSelectedYouTubeActionType = z.infer; diff --git a/packages/shared/types/basic.ts b/packages/shared/types/basic.ts index 6529515c..a62a4ff6 100644 --- a/packages/shared/types/basic.ts +++ b/packages/shared/types/basic.ts @@ -17,3 +17,13 @@ export const AudioSourceSchema = z.object({ url: z.string(), }); export type AudioSourceType = z.infer; + +export const YouTubeSourceSchema = z.object({ + videoId: z.string(), + title: z.string(), + channel: z.string().optional(), + duration: z.string().optional(), + thumbnail: z.string().optional(), + addedBy: z.string().optional(), +}); +export type YouTubeSourceType = z.infer; From a82afe6378d42f59070c7d81bdbfe5954afe6c71 Mon Sep 17 00:00:00 2001 From: farzan Date: Fri, 18 Jul 2025 23:43:39 +0530 Subject: [PATCH 3/4] update Dockerfile names --- DockerfileClient.txt => DockerfileClient | 0 DockerfileServer.txt => DockerfileServer | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename DockerfileClient.txt => DockerfileClient (100%) rename DockerfileServer.txt => DockerfileServer (100%) diff --git a/DockerfileClient.txt b/DockerfileClient similarity index 100% rename from DockerfileClient.txt rename to DockerfileClient diff --git a/DockerfileServer.txt b/DockerfileServer similarity index 100% rename from DockerfileServer.txt rename to DockerfileServer From 5a413bdfc54301b1618db4b9ab918a90f518cf0e Mon Sep 17 00:00:00 2001 From: farzan Date: Sat, 19 Jul 2025 19:24:01 +0530 Subject: [PATCH 4/4] refactor: remove volume control from UnifiedPlayer and related global state management --- apps/client/src/components/UnifiedPlayer.tsx | 93 +------------------ .../src/components/room/WebSocketManager.tsx | 38 +++++++- apps/client/src/store/global.tsx | 10 -- apps/server/src/config.ts | 2 - 4 files changed, 40 insertions(+), 103 deletions(-) diff --git a/apps/client/src/components/UnifiedPlayer.tsx b/apps/client/src/components/UnifiedPlayer.tsx index 56114f3c..ed2c7a1f 100644 --- a/apps/client/src/components/UnifiedPlayer.tsx +++ b/apps/client/src/components/UnifiedPlayer.tsx @@ -6,8 +6,6 @@ import { Pause, SkipBack, SkipForward, - Volume2, - VolumeX, Repeat, Shuffle, Maximize2, @@ -28,8 +26,6 @@ import { extractFileNameFromUrl } from "@/lib/utils"; export const UnifiedPlayer = () => { const [isVideoExpanded, setIsVideoExpanded] = useState(false); - const [localVolume, setLocalVolume] = useState(75); - const [isMuted, setIsMuted] = useState(false); const [livePosition, setLivePosition] = useState(0); const [youtubeDuration, setYoutubeDuration] = useState(0); const [youtubePosition, setYoutubePosition] = useState(0); @@ -66,8 +62,6 @@ export const UnifiedPlayer = () => { const broadcastSeekYouTube = useGlobalStore((state) => state.broadcastSeekYouTube); const setIsShuffled = useGlobalStore((state) => state.setIsShuffled); const setRepeatMode = useGlobalStore((state) => state.setRepeatMode); - const setVolume = useGlobalStore((state) => state.setVolume); - const getVolume = useGlobalStore((state) => state.getVolume); // Get current content info const currentVideo = youtubeSources.find(source => source.videoId === selectedYouTubeId); @@ -165,37 +159,6 @@ export const UnifiedPlayer = () => { } }, [currentMode, skipToNextYouTubeVideo, skipToNextTrack]); - const handleVolumeChange = useCallback((newVolume: number[]) => { - const vol = newVolume[0]; - setLocalVolume(vol); - setIsMuted(vol === 0); - - if (currentMode === 'youtube' && youtubePlayer && isYouTubePlayerReady) { - youtubePlayer.setVolume(vol); - } else if (currentMode === 'library') { - // Set the actual audio volume using the global store - setVolume(vol); - } - }, [currentMode, youtubePlayer, isYouTubePlayerReady, setVolume]); - - const handleMute = useCallback(() => { - if (isMuted) { - setIsMuted(false); - if (currentMode === 'youtube' && youtubePlayer && isYouTubePlayerReady) { - youtubePlayer.setVolume(localVolume); - } else if (currentMode === 'library') { - setVolume(localVolume); - } - } else { - setIsMuted(true); - if (currentMode === 'youtube' && youtubePlayer && isYouTubePlayerReady) { - youtubePlayer.setVolume(0); - } else if (currentMode === 'library') { - setVolume(0); - } - } - }, [isMuted, currentMode, youtubePlayer, isYouTubePlayerReady, localVolume, setVolume]); - const handleShuffle = useCallback(() => { setIsShuffled(!isShuffled); }, [isShuffled, setIsShuffled]); @@ -226,19 +189,6 @@ export const UnifiedPlayer = () => { } }; - // Set initial volume when YouTube player is ready - useEffect(() => { - if (currentMode === 'youtube' && youtubePlayer && isYouTubePlayerReady) { - youtubePlayer.setVolume(isMuted ? 0 : localVolume); - } - }, [youtubePlayer, isYouTubePlayerReady, localVolume, isMuted, currentMode]); - - // Initialize local volume from global store - useEffect(() => { - const currentVolume = getVolume(); - setLocalVolume(currentVolume); - }, [getVolume]); - // Add keyboard event listeners for media controls useEffect(() => { const handleKeyPress = (event: KeyboardEvent) => { @@ -286,7 +236,7 @@ export const UnifiedPlayer = () => { case 'KeyM': if (event.ctrlKey) { event.preventDefault(); - handleMute(); + // Volume control removed } break; case 'KeyS': @@ -304,15 +254,13 @@ export const UnifiedPlayer = () => { case 'ArrowUp': if (event.ctrlKey) { event.preventDefault(); - const newVolume = Math.min(100, localVolume + 5); - handleVolumeChange([newVolume]); + // Volume control removed } break; case 'ArrowDown': if (event.ctrlKey) { event.preventDefault(); - const newVolume = Math.max(0, localVolume - 5); - handleVolumeChange([newVolume]); + // Volume control removed } break; } @@ -325,7 +273,7 @@ export const UnifiedPlayer = () => { return () => { document.removeEventListener('keydown', handleKeyPress); }; - }, [handlePlayPause, handleNext, handlePrevious, handleMute, handleShuffle, handleRepeat, handleVolumeChange, isPlaying, localVolume]); + }, [handlePlayPause, handleNext, handlePrevious, handleShuffle, handleRepeat, isPlaying]); return ( @@ -622,9 +570,6 @@ export const UnifiedPlayer = () => {
Space Play/Pause
Ctrl+← Previous
Ctrl+→ Next
-
Ctrl+↑ Volume Up
-
Ctrl+↓ Volume Down
-
Ctrl+M Mute
Ctrl+S Shuffle
Ctrl+R Repeat
@@ -632,36 +577,6 @@ export const UnifiedPlayer = () => {

- - {/* Volume Control */} -
- - -
- -
- - - {isMuted ? 0 : localVolume} - -
diff --git a/apps/client/src/components/room/WebSocketManager.tsx b/apps/client/src/components/room/WebSocketManager.tsx index f8df3563..a4891b51 100644 --- a/apps/client/src/components/room/WebSocketManager.tsx +++ b/apps/client/src/components/room/WebSocketManager.tsx @@ -7,9 +7,11 @@ import { epochNow, NTPResponseMessageType, WSResponseSchema, + ClientType, } from "@beatsync/shared"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useWebSocketReconnection } from "@/hooks/useWebSocketReconnection"; +import { toast } from "sonner"; // Helper function for NTP response handling const handleNTPResponse = (response: NTPResponseMessageType) => { @@ -43,9 +45,13 @@ export const WebSocketManager = ({ roomId, username, }: WebSocketManagerProps) => { + // Track previous clients to detect new connections + const previousClientsRef = useRef([]); + // Room state const isLoadingRoom = useRoomStore((state) => state.isLoadingRoom); const setUserId = useRoomStore((state) => state.setUserId); + const userId = useRoomStore((state) => state.userId); // WebSocket and audio state const setSocket = useGlobalStore((state) => state.setSocket); @@ -103,6 +109,34 @@ export const WebSocketManager = ({ createConnection: () => createConnection(), }); + // Function to handle client changes and show toast for new users + const handleClientChange = (newClients: ClientType[]) => { + const previousClients = previousClientsRef.current; + + // Only show notifications if we have previous clients (not on initial load) + if (previousClients.length > 0) { + // Find new clients by comparing client IDs + const previousClientIds = new Set(previousClients.map(client => client.clientId)); + const newUsers = newClients.filter(client => + !previousClientIds.has(client.clientId) && client.clientId !== userId + ); + + // Show toast for each new user + newUsers.forEach(newUser => { + toast.success(`${newUser.username} joined the room! 🎵`, { + duration: 3000, + position: "top-right", + }); + }); + } + + // Update the previous clients reference + previousClientsRef.current = newClients; + + // Update the global state + setConnectedClients(newClients); + }; + const createConnection = () => { const SOCKET_URL = `${process.env.NEXT_PUBLIC_WS_URL}?roomId=${roomId}&username=${username}`; console.log("Creating new WS connection to", SOCKET_URL); @@ -162,7 +196,7 @@ export const WebSocketManager = ({ console.log("Room event:", event); if (event.type === "CLIENT_CHANGE") { - setConnectedClients(event.clients); + handleClientChange(event.clients); } else if (event.type === "SET_AUDIO_SOURCES") { handleSetAudioSources({ sources: event.sources }); } else if (event.type === "SET_YOUTUBE_SOURCES") { diff --git a/apps/client/src/store/global.tsx b/apps/client/src/store/global.tsx index c328b3c7..e15b519a 100644 --- a/apps/client/src/store/global.tsx +++ b/apps/client/src/store/global.tsx @@ -32,8 +32,6 @@ interface YouTubePlayerInstance { seekTo: (seconds: number, allowSeekAhead?: boolean) => void; getCurrentTime: () => number; getDuration: () => number; - setVolume: (volume: number) => void; - getVolume: () => number; } // https://webaudioapi.com/book/Web_Audio_API_Boris_Smus_html/ch02.html @@ -90,7 +88,6 @@ interface GlobalStateValues { isPlaying: boolean; currentTime: number; duration: number; - volume: number; // Tracking properties playbackStartTime: number; @@ -153,8 +150,6 @@ interface GlobalState extends GlobalStateValues { }) => void; setRepeatMode: (mode: "none" | "all" | "one") => void; setIsShuffled: (shuffled: boolean) => void; - setVolume: (volume: number) => void; - getVolume: () => number; startSpatialAudio: () => void; sendStopSpatialAudio: () => void; @@ -235,7 +230,6 @@ const initialState: GlobalStateValues = { // These need to be initialized to prevent type errors audioPlayer: null, duration: 0, - volume: 0.5, reconnectionInfo: { isReconnecting: false, currentAttempt: 0, @@ -1331,9 +1325,5 @@ export const useGlobalStore = create((set, get) => { setRepeatMode: (mode) => set({ repeatMode: mode }), setIsShuffled: (shuffled) => set({ isShuffled: shuffled }), - - setVolume: (volume) => set({ volume }), - - getVolume: () => get().volume, }; }); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 9a779f16..9fcc6dc3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,8 +6,6 @@ // Audio settings export const AUDIO_LOW = 0.15; export const AUDIO_HIGH = 1.0; -export const VOLUME_UP_RAMP_TIME = 0.5; -export const VOLUME_DOWN_RAMP_TIME = 0.5; // Scheduling settings export const SCHEDULE_TIME_MS = 750;