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 b/DockerfileClient
new file mode 100644
index 00000000..9e809c02
--- /dev/null
+++ b/DockerfileClient
@@ -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 b/DockerfileServer
new file mode 100644
index 00000000..eec8dc22
--- /dev/null
+++ b/DockerfileServer
@@ -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/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/app/layout.tsx b/apps/client/src/app/layout.tsx
index 029acbd1..b36f0780 100644
--- a/apps/client/src/app/layout.tsx
+++ b/apps/client/src/app/layout.tsx
@@ -46,7 +46,7 @@ export default function RootLayout({
{children}
-
+
diff --git a/apps/client/src/components/Queue.tsx b/apps/client/src/components/Queue.tsx
index 37d90fa0..a13a6baa 100644
--- a/apps/client/src/components/Queue.tsx
+++ b/apps/client/src/components/Queue.tsx
@@ -10,6 +10,7 @@ import { AudioSourceType } from "@beatsync/shared";
import { MoreHorizontal, Pause, Play, UploadCloud } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { usePostHog } from "posthog-js/react";
+import { Button } from "./ui/button";
export const Queue = ({ className, ...rest }: React.ComponentProps<"div">) => {
const posthog = usePostHog();
@@ -80,13 +81,13 @@ export const Queue = ({ className, ...rest }: React.ComponentProps<"div">) => {
{/* Track number / Play icon */}
{/* Play/Pause button (shown on hover) */}
-
+
{isSelected && isPlaying ? (
) : (
)}
-
+
{/* Playing indicator or track number (hidden on hover) */}
@@ -133,9 +134,9 @@ export const Queue = ({ className, ...rest }: React.ComponentProps<"div">) => {
asChild
onClick={(e) => e.stopPropagation()}
>
-
+
-
+
{
+ const [isVideoExpanded, setIsVideoExpanded] = useState(false);
+ const [livePosition, setLivePosition] = useState(0);
+ const [youtubeDuration, setYoutubeDuration] = useState(0);
+ const [youtubePosition, setYoutubePosition] = useState(0);
+ const canMutate = useCanMutate();
+
+ // 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);
+
+ // 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,
+ canMutate
+ });
+
+ if (isInitingSystem) {
+ console.log("UnifiedPlayer: System still initializing, cannot play");
+ return;
+ }
+
+ if (!canMutate) {
+ console.log("UnifiedPlayer: User does not have permission to control playback");
+ toast.error("You don't have permission to control playback");
+ 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, canMutate]);
+
+ const handlePrevious = useCallback(() => {
+ if (!canMutate) {
+ toast.error("You don't have permission to control playback");
+ return;
+ }
+
+ if (currentMode === 'youtube') {
+ skipToPreviousYouTubeVideo();
+ } else if (currentMode === 'library') {
+ skipToPreviousTrack();
+ }
+ }, [currentMode, skipToPreviousYouTubeVideo, skipToPreviousTrack, canMutate]);
+
+ const handleNext = useCallback(() => {
+ if (!canMutate) {
+ toast.error("You don't have permission to control playback");
+ return;
+ }
+
+ if (currentMode === 'youtube') {
+ skipToNextYouTubeVideo();
+ } else if (currentMode === 'library') {
+ skipToNextTrack();
+ }
+ }, [currentMode, skipToNextYouTubeVideo, skipToNextTrack, canMutate]);
+
+ const handleShuffle = useCallback(() => {
+ if (!canMutate) {
+ toast.error("You don't have permission to control playback");
+ return;
+ }
+
+ setIsShuffled(!isShuffled);
+ }, [isShuffled, setIsShuffled, canMutate]);
+
+ const handleRepeat = useCallback(() => {
+ if (!canMutate) {
+ toast.error("You don't have permission to control playback");
+ return;
+ }
+
+ const modes = ['none', 'all', 'one'] as const;
+ const currentIndex = modes.indexOf(repeatMode);
+ const nextIndex = (currentIndex + 1) % modes.length;
+ setRepeatMode(modes[nextIndex]);
+ }, [repeatMode, setRepeatMode, canMutate]);
+
+ const handleProgressChange = (newValue: number[]) => {
+ if (!canMutate) {
+ toast.error("You don't have permission to control playback");
+ return;
+ }
+
+ 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);
+ }
+ };
+
+ // 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;
+ }
+
+ // Check permissions for all playback controls
+ if (!canMutate) {
+ return; // Silently ignore keyboard shortcuts if user doesn't have permission
+ }
+
+ 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();
+ // Volume control removed
+ }
+ 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();
+ // Volume control removed
+ }
+ break;
+ case 'ArrowDown':
+ if (event.ctrlKey) {
+ event.preventDefault();
+ // Volume control removed
+ }
+ break;
+ }
+ };
+
+ // Add event listener
+ document.addEventListener('keydown', handleKeyPress);
+
+ // Cleanup
+ return () => {
+ document.removeEventListener('keydown', handleKeyPress);
+ };
+ }, [handlePlayPause, handleNext, handlePrevious, handleShuffle, handleRepeat, isPlaying, canMutate]);
+
+ return (
+
+
+ {/* Video Display (YouTube Mode Only) */}
+
+ {currentMode === 'youtube' && selectedYouTubeId && isVideoExpanded && (
+
+
+
+
+
+ {/* Video Overlay Controls */}
+
+ setIsVideoExpanded(!isVideoExpanded)}
+ size="sm"
+ variant="secondary"
+ className="bg-black/60 hover:bg-black/80 backdrop-blur-sm"
+ >
+
+
+
+
+ )}
+
+
+ {/* Player Layout */}
+
+ {/* Video Thumbnail/Player (Minimized) */}
+ {currentMode === 'youtube' && selectedYouTubeId && !isVideoExpanded && (
+
+
+ setIsVideoExpanded(true)}
+ size="sm"
+ variant="secondary"
+ className="absolute top-1 right-1 bg-black/60 hover:bg-black/80 backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+
+
+
+ )}
+
+ {/* Track Info and Controls Container */}
+
+ {/* Track Info */}
+
+ {!isVideoExpanded && (
+ <>
+ {currentMode === 'youtube' && currentVideo ? (
+ <>
+ {currentVideo.thumbnail && !selectedYouTubeId && (
+
+
+
+ )}
+
+
+ {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 || 'No video selected'}
+
+
+ {currentVideo?.channel || 'Search for YouTube videos'}
+
+
+
+
+
+ YouTube
+
+
+ )}
+
+
+ {/* Controls */}
+
+ {/* Permission indicator */}
+ {!canMutate && (
+
+
+ Only admins can control playback in this room
+
+ )}
+
+ {/* 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"
+ disabled={!canMutate}
+ />
+
+
+ {currentMode === 'library'
+ ? formatTime(duration)
+ : formatTime(youtubeDuration)
+ }
+
+
+
+ )}
+
+ {/* Primary Controls */}
+
+
+
+
+
+
+
+
+
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {repeatMode === 'one' && (
+
+ 1
+
+ )}
+
+
+ {/* Keyboard Shortcuts Info */}
+
+
+
+
+
+
+
+
+
+
Keyboard Shortcuts
+ {!canMutate && (
+
ā ļø Playback controls restricted
+ )}
+
Space Play/Pause
+
Ctrl+ā Previous
+
Ctrl+ā Next
+
Ctrl+S Shuffle
+
Ctrl+R Repeat
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/client/src/components/dashboard/Bottom.tsx b/apps/client/src/components/dashboard/Bottom.tsx
index 64c22a23..92a7f319 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 b9d805db..56323f71 100644
--- a/apps/client/src/components/dashboard/Left.tsx
+++ b/apps/client/src/components/dashboard/Left.tsx
@@ -2,30 +2,23 @@
import { cn } from "@/lib/utils";
import { useRoomStore } from "@/store/room";
-import { Hash, Search } from "lucide-react";
+import { Hash, Music, Search, Youtube, Library } from "lucide-react";
import { motion } from "motion/react";
import { AudioUploaderMinimal } from "../AudioUploaderMinimal";
import { Button } from "../ui/button";
import { Separator } from "../ui/separator";
import { ConnectedUsersList } from "./ConnectedUsersList";
import { PlaybackPermissions } from "./PlaybackPermissions";
+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);
const roomId = useRoomStore((state) => state.roomId);
@@ -37,7 +30,7 @@ export const Left = ({ className }: LeftProps) => {
)}
>
{/* Header section */}
- {/*
+
@@ -45,7 +38,7 @@ export const Left = ({ className }: LeftProps) => {
-
*/}
+
{/* Navigation menu */}
@@ -53,8 +46,35 @@ export const Left = ({ className }: LeftProps) => {
Room {roomId}
-
-
+ setCurrentMode('library')}
+ >
+
+ Music Library
+
+
+ setCurrentMode('youtube')}
+ >
+
+ YouTube
+
+
+
{
Search Music
-
+
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 */}
+
+
+ setYoutubeTab('search')}
+ variant={youtubeTab === 'search' ? 'default' : 'ghost'}
+ size="sm"
+ className="flex items-center gap-2"
+ >
+
+ Search
+
+ setYoutubeTab('queue')}
+ variant={youtubeTab === 'queue' ? 'default' : 'ghost'}
+ size="sm"
+ className="flex items-center gap-2"
+ >
+
+ Queue
+
+
+
+ {/* Tab Content */}
+
+ {youtubeTab === 'search' && (
+
+
Search YouTube
+
+
+ )}
+
+
+ {youtubeTab === 'queue' && (
+
+
Video Queue
+
+
+ )}
+
+
+
+ {/* YouTube Player */}
+ {/* */}
+ >
+ )}
);
diff --git a/apps/client/src/components/dashboard/Right.tsx b/apps/client/src/components/dashboard/Right.tsx
index f46d3ee9..c0523ff7 100644
--- a/apps/client/src/components/dashboard/Right.tsx
+++ b/apps/client/src/components/dashboard/Right.tsx
@@ -1,7 +1,8 @@
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";
import { AudioControls } from "./AudioControls";
interface RightProps {
@@ -9,6 +10,8 @@ interface RightProps {
}
export const Right = ({ className }: RightProps) => {
+ const currentMode = useGlobalStore((state) => state.currentMode);
+
return (
{
-
-
- What is this?
-
-
- {
- "This grid simulates a spatial audio environment. Drag the listening source around and hear how the volume changes on each device. Works best in person."
- }
-
+ {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 2c7e6c83..00000000
--- a/apps/client/src/components/room/Player.tsx
+++ /dev/null
@@ -1,281 +0,0 @@
-import { cn, formatTime } from "@/lib/utils";
-
-import { useCanMutate, 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 canMutate = useCanMutate();
- 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[]) => {
- if (!canMutate) return;
- const position = value[0];
- setIsDragging(true);
- setSliderPosition(position);
- },
- [canMutate]
- );
-
- // Handle slider release - seek to that position
- const handleSliderCommit = useCallback(
- (value: number[]) => {
- if (!canMutate) return;
- 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,
- });
- },
- [
- canMutate,
- broadcastPlay,
- isPlaying,
- setSliderPosition,
- posthog,
- selectedAudioId,
- trackDuration,
- ]
- );
-
- const handlePlay = useCallback(() => {
- if (!canMutate) return;
- if (isPlaying) {
- broadcastPause();
- posthog.capture("pause_track", { track_id: selectedAudioId });
- } else {
- broadcastPlay(sliderPosition);
- posthog.capture("play_track", {
- position: sliderPosition,
- track_id: selectedAudioId,
- });
- }
- }, [
- canMutate,
- isPlaying,
- broadcastPause,
- broadcastPlay,
- sliderPosition,
- posthog,
- selectedAudioId,
- ]);
-
- const handleSkipBack = useCallback(() => {
- if (!canMutate) return;
- if (!isShuffled) {
- skipToPreviousTrack();
- posthog.capture("skip_previous", {
- from_track_id: selectedAudioId,
- });
- }
- }, [canMutate, skipToPreviousTrack, isShuffled, posthog, selectedAudioId]);
-
- const handleSkipForward = useCallback(() => {
- if (!canMutate) return;
- skipToNextTrack();
- posthog.capture("skip_next", {
- from_track_id: selectedAudioId,
- });
- }, [canMutate, skipToNextTrack, posthog, selectedAudioId]);
-
- const handleShuffle = useCallback(() => {
- if (!canMutate) return;
- toggleShuffle();
- posthog.capture("toggle_shuffle", {
- shuffle_enabled: !isShuffled,
- queue_size: audioSources.length,
- });
- }, [canMutate, 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();
- if (canMutate) {
- handlePlay();
- }
- }
- };
-
- window.addEventListener("keydown", handleKeyDown);
- return () => {
- window.removeEventListener("keydown", handleKeyDown);
- };
- }, [handlePlay]);
-
- return (
-
-
-
-
-
-
- {isShuffled && (
-
- )}
-
-
-
-
-
-
- {isPlaying ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
- {formatTime(sliderPosition)}
-
-
-
- {formatTime(trackDuration)}
-
-
-
-
- );
-};
diff --git a/apps/client/src/components/room/TopBar.tsx b/apps/client/src/components/room/TopBar.tsx
index 2c286ce2..f8efd286 100644
--- a/apps/client/src/components/room/TopBar.tsx
+++ b/apps/client/src/components/room/TopBar.tsx
@@ -1,11 +1,12 @@
"use client";
import { SOCIAL_LINKS } from "@/constants";
import { MAX_NTP_MEASUREMENTS, useGlobalStore } from "@/store/global";
-import { Crown, Hash, Users } from "lucide-react";
+import { Crown, Hash, Users, Copy } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { FaDiscord, FaGithub } from "react-icons/fa";
import { SyncProgress } from "../ui/SyncProgress";
+import { toast } from "sonner";
interface TopBarProps {
roomId: string;
@@ -23,6 +24,22 @@ export const TopBar = ({ roomId }: TopBarProps) => {
const currentUser = useGlobalStore((state) => state.currentUser);
const isAdmin = currentUser?.isAdmin || false;
+ // Handle room ID click to copy shareable message
+ const handleRoomIdClick = async () => {
+ const currentUrl = window.location.origin;
+ const roomUrl = `${currentUrl}/room/${roomId}`;
+ const shareableMessage = `šµ Join my Beatsync room!\n\nRoom ID: ${roomId}\nDirect link: ${roomUrl}\n\nConnect using the room ID or click the link to sync music together!`;
+
+ try {
+ await navigator.clipboard.writeText(shareableMessage);
+ toast.success("Room invite copied to clipboard!");
+ } catch (err) {
+ // Fallback for browsers that don't support clipboard API
+ console.error("Failed to copy to clipboard:", err);
+ toast.error("Failed to copy to clipboard");
+ }
+ };
+
// Show minimal nav bar when synced and not loading
if (!isLoadingAudio && isSynced) {
return (
@@ -85,7 +102,14 @@ export const TopBar = ({ roomId }: TopBarProps) => {
- {roomId}
+
+ {roomId}
+
+
@@ -109,23 +133,23 @@ export const TopBar = ({ roomId }: TopBarProps) => {
{/* Discord icon */}
-
-
+
{/* GitHub icon in the top right */}
-
-
+
);
diff --git a/apps/client/src/components/room/WebSocketManager.tsx b/apps/client/src/components/room/WebSocketManager.tsx
index 33eeb23e..ff8f976b 100644
--- a/apps/client/src/components/room/WebSocketManager.tsx
+++ b/apps/client/src/components/room/WebSocketManager.tsx
@@ -9,8 +9,10 @@ import {
epochNow,
NTPResponseMessageType,
WSResponseSchema,
+ ClientType,
} from "@beatsync/shared";
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
+import { toast } from "sonner";
// Helper function for NTP response handling
const handleNTPResponse = (response: NTPResponseMessageType) => {
@@ -44,6 +46,9 @@ export const WebSocketManager = ({
roomId,
username,
}: WebSocketManagerProps) => {
+ // Track previous clients to detect new connections
+ const previousClientsRef = useRef([]);
+
// Get PostHog client ID
const { clientId } = useClientId();
@@ -74,6 +79,17 @@ export const WebSocketManager = ({
const handleSetAudioSources = useGlobalStore(
(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);
const setPlaybackControlsPermissions = useGlobalStore(
(state) => state.setPlaybackControlsPermissions
);
@@ -98,6 +114,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 !== clientId
+ );
+
+ // 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}&clientId=${clientId}`;
console.log("Creating new WS connection to", SOCKET_URL);
@@ -158,9 +202,17 @@ 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") {
+ 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 (event.type === "SET_PLAYBACK_CONTROLS") {
setPlaybackControlsPermissions(event.permissions);
}
@@ -179,6 +231,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 */}
+ handleRemove(source.videoId, e)}
+ >
+
+
+
+ {/* 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"
+ />
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Error Message */}
+ {error && (
+
+ )}
+
+ {/* View Mode Toggle */}
+ {results.length > 0 && (
+
+ View:
+ setViewMode('grid')}
+ className="h-8 px-3"
+ >
+
+
+ setViewMode('list')}
+ className="h-8 px-3"
+ >
+
+
+
+ {results.length} results
+
+
+ )}
+
+ {/* Results */}
+
+ {results.map((video) => (
+
+
+ {viewMode === 'grid' ? (
+
+ {/* Thumbnail */}
+
+
+
+
handlePlay(video)}
+ size="sm"
+ className="bg-red-600 hover:bg-red-700 text-white mr-2"
+ >
+
+
+
handleAddToQueue(video)}
+ size="sm"
+ variant="secondary"
+ >
+
+
+
+
+ {video.duration}
+
+
+
+ {/* Video Info */}
+
+
+ {video.title}
+
+
{video.channelTitle}
+
+
+
+ {video.viewCount}
+
+ ā¢
+ {video.publishedAt}
+
+
+
+ ) : (
+
+ {/* Thumbnail */}
+
+
+
+ {video.duration}
+
+
+
+ {/* Video Info */}
+
+
+ {video.title}
+
+
{video.channelTitle}
+
+
+
+ {video.viewCount}
+
+ ā¢
+ {video.publishedAt}
+
+
+
+ {/* Actions */}
+
+
handlePlay(video)}
+ size="sm"
+ className="bg-red-600 hover:bg-red-700 text-white h-8 px-3"
+ >
+
+
+
handleAddToQueue(video)}
+ size="sm"
+ variant="secondary"
+ className="h-8 px-3"
+ >
+
+
+
+
+ )}
+
+
+ ))}
+
+
+ {/* 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 2a809a20..79a47c9d 100644
--- a/apps/client/src/store/global.tsx
+++ b/apps/client/src/store/global.tsx
@@ -19,6 +19,7 @@ import {
PlaybackControlsPermissionsType,
PositionType,
SpatialConfigType,
+ YouTubeSourceType,
} from "@beatsync/shared";
import { Mutex } from "async-mutex";
import { toast } from "sonner";
@@ -26,6 +27,15 @@ import { create } from "zustand";
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;
+}
+
// https://webaudioapi.com/book/Web_Audio_API_Boris_Smus_html/ch02.html
interface AudioPlayerState {
@@ -47,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;
@@ -72,7 +91,6 @@ interface GlobalStateValues {
isPlaying: boolean;
currentTime: number;
duration: number;
- volume: number;
// Tracking properties
playbackStartTime: number;
@@ -80,6 +98,7 @@ interface GlobalStateValues {
// Shuffle state
isShuffled: boolean;
+ repeatMode: "none" | "all" | "one";
reconnectionInfo: {
isReconnecting: boolean;
currentAttempt: number;
@@ -109,6 +128,36 @@ 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;
+
startSpatialAudio: () => void;
sendStopSpatialAudio: () => void;
setSpatialConfig: (config: SpatialConfigType) => void;
@@ -151,6 +200,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,
@@ -160,6 +216,7 @@ const initialState: GlobalStateValues = {
// Spatial audio
isShuffled: false,
+ repeatMode: "none",
isSpatialAudioEnabled: false,
isDraggingListeningSource: false,
listeningSourcePosition: { x: GRID.SIZE / 2, y: GRID.SIZE / 2 },
@@ -184,7 +241,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,
@@ -1026,6 +1082,311 @@ 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 }),
setPlaybackControlsPermissions: (permissions) =>
set({ playbackControlsPermissions: permissions }),
};
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;
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 19fe119a..6f1a06ec 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -15,8 +15,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);
@@ -75,7 +75,7 @@ console.log(`HTTP listening on http://${server.hostname}:${server.port}`);
// Restore state from backup on startup
BackupManager.restoreState().catch((error) => {
- console.error("Failed to restore state on startup:", error);
+ console.warn("ā ļø Failed to restore state on startup (this is normal in development without R2/S3):", error);
});
// Simple graceful shutdown
@@ -83,7 +83,12 @@ const shutdown = async () => {
console.log("\nā ļø Shutting down...");
server.stop(); // Stop accepting new connections
- await BackupManager.backupState(); // Save state
+
+ try {
+ await BackupManager.backupState(); // Save state
+ } catch (error) {
+ console.warn("ā ļø Backup on shutdown failed (this is normal in development):", error);
+ }
process.exit(0);
};
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/BackupManager.ts b/apps/server/src/managers/BackupManager.ts
index 94cf1b9f..b27037a6 100644
--- a/apps/server/src/managers/BackupManager.ts
+++ b/apps/server/src/managers/BackupManager.ts
@@ -130,6 +130,11 @@ export class BackupManager {
// Clean up old backups after successful backup
await this.cleanupOldBackups();
} catch (error) {
+ // Check if this is a connection error (likely R2 not available in development)
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'FailedToOpenSocket') {
+ console.warn("ā ļø State backup skipped - R2/S3 storage not available (this is normal in development without MinIO)");
+ return;
+ }
console.error("ā State backup failed:", error);
throw error;
}
@@ -149,7 +154,11 @@ export class BackupManager {
console.log("š No backups found");
// Still clean up orphaned rooms even if no backup exists
- await this.cleanupOrphanedRooms();
+ try {
+ await this.cleanupOrphanedRooms();
+ } catch (cleanupError) {
+ console.warn("ā ļø Orphaned room cleanup failed (likely R2 not available):", cleanupError);
+ }
return false;
}
@@ -235,6 +244,11 @@ export class BackupManager {
return true;
} catch (error) {
+ // Check if this is a connection error (likely R2 not available in development)
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'FailedToOpenSocket') {
+ console.warn("ā ļø State restore skipped - R2/S3 storage not available (this is normal in development without MinIO)");
+ return false;
+ }
console.error("ā State restore failed:", error);
return false;
}
diff --git a/apps/server/src/managers/RoomManager.ts b/apps/server/src/managers/RoomManager.ts
index dcd39dd9..55d96ca2 100644
--- a/apps/server/src/managers/RoomManager.ts
+++ b/apps/server/src/managers/RoomManager.ts
@@ -2,14 +2,18 @@ import {
AudioSourceType,
ClientType,
epochNow,
+ YouTubeSourceType,
+ PositionType,
+ WSBroadcastType,
NTP_CONSTANTS,
PauseActionType,
+ PlayYouTubeActionType,
+ PauseYouTubeActionType,
+ SeekYouTubeActionType,
PlayActionType,
PlaybackControlsPermissionsEnum,
PlaybackControlsPermissionsType,
- PositionType,
RoomType,
- WSBroadcastType,
} from "@beatsync/shared";
import { AudioSourceSchema, GRID } from "@beatsync/shared/types/basic";
import { Server, ServerWebSocket } from "bun";
@@ -23,6 +27,10 @@ import { WSData } from "../utils/websocket";
interface RoomData {
audioSources: AudioSourceType[];
+ youtubeSources: YouTubeSourceType[];
+ currentMode: "library" | "youtube";
+ selectedAudioUrl: string;
+ selectedYouTubeId: string;
clients: Map;
roomId: string;
intervalId?: NodeJS.Timeout;
@@ -73,6 +81,10 @@ export class RoomManager {
private clients = new Map();
private clientCache = new Map>(); // user id -> isAdmin
private audioSources: AudioSourceType[] = [];
+ private youtubeSources: YouTubeSourceType[] = [];
+ private currentMode: "library" | "youtube" = "library";
+ private selectedAudioUrl: string = "";
+ private selectedYouTubeId: string = "";
private listeningSource: PositionType = {
x: GRID.ORIGIN_X,
y: GRID.ORIGIN_Y,
@@ -266,6 +278,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,
@@ -670,4 +686,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 5676ee15..c8287efe 100644
--- a/apps/server/src/routes/websocketHandlers.ts
+++ b/apps/server/src/routes/websocketHandlers.ts
@@ -5,6 +5,7 @@ import {
WSRequestSchema,
} from "@beatsync/shared";
import { Server, ServerWebSocket } from "bun";
+import { SCHEDULE_TIME_MS } from "../config";
import { globalManager } from "../managers";
import { sendBroadcast } from "../utils/responses";
import { WSData } from "../utils/websocket";
@@ -35,10 +36,10 @@ 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}`
);
// TODO: this is not ideal:
@@ -49,7 +50,7 @@ export const handleOpen = (ws: ServerWebSocket, server: Server) => {
type: "ROOM_EVENT",
event: {
type: "SET_AUDIO_SOURCES",
- sources: audioSources,
+ sources: roomState.audioSources,
},
};
@@ -57,6 +58,54 @@ export const handleOpen = (ws: ServerWebSocket, server: Server) => {
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));
+ }
+
// Always send the current playback controls
const playbackControlsMessage: WSBroadcastType = {
type: "ROOM_EVENT",
@@ -92,12 +141,13 @@ export const handleMessage = async (
}
if (parsedMessage.type === ClientActionEnum.enum.NTP_REQUEST) {
- // Manually mutate the message to include the t1 timestamp
+ // Manually mutate the message to include the t1 timestamp
parsedMessage.t1 = t1;
}
// Delegate to type-safe dispatcher
await dispatchMessage({ ws, message: parsedMessage, server });
+
} catch (error) {
console.error("Invalid message format:", error);
ws.send(
diff --git a/apps/server/src/websocket/handlers/addYouTubeSource.ts b/apps/server/src/websocket/handlers/addYouTubeSource.ts
new file mode 100644
index 00000000..95b6a92d
--- /dev/null
+++ b/apps/server/src/websocket/handlers/addYouTubeSource.ts
@@ -0,0 +1,25 @@
+import { ExtractWSRequestFrom } from "@beatsync/shared";
+import { sendBroadcast } from "../../utils/responses";
+import { requireCanMutate } from "../middlewares";
+import { HandlerFunction } from "../types";
+
+export const handleAddYouTubeSource: HandlerFunction<
+ ExtractWSRequestFrom["ADD_YOUTUBE_SOURCE"]
+> = async ({ ws, message, server }) => {
+ const { room } = requireCanMutate(ws);
+
+ room.addYouTubeSource(message.source);
+
+ // Broadcast updated YouTube sources to all clients
+ sendBroadcast({
+ server,
+ roomId: ws.data.roomId,
+ message: {
+ type: "ROOM_EVENT",
+ event: {
+ type: "SET_YOUTUBE_SOURCES",
+ sources: room.getState().youtubeSources,
+ },
+ },
+ });
+};
diff --git a/apps/server/src/websocket/handlers/pauseYouTube.ts b/apps/server/src/websocket/handlers/pauseYouTube.ts
new file mode 100644
index 00000000..2d6123cb
--- /dev/null
+++ b/apps/server/src/websocket/handlers/pauseYouTube.ts
@@ -0,0 +1,25 @@
+import { epochNow, ExtractWSRequestFrom } from "@beatsync/shared";
+import { SCHEDULE_TIME_MS } from "../../config";
+import { sendBroadcast } from "../../utils/responses";
+import { requireCanMutate } from "../middlewares";
+import { HandlerFunction } from "../types";
+
+export const handlePauseYouTube: HandlerFunction<
+ ExtractWSRequestFrom["PAUSE_YOUTUBE"]
+> = async ({ ws, message, server }) => {
+ const { room } = requireCanMutate(ws);
+
+ const serverTimeToExecute = epochNow() + SCHEDULE_TIME_MS;
+
+ room.updatePlaybackSchedulePauseYouTube(message, serverTimeToExecute);
+
+ sendBroadcast({
+ server,
+ roomId: ws.data.roomId,
+ message: {
+ type: "SCHEDULED_ACTION",
+ scheduledAction: message,
+ serverTimeToExecute: serverTimeToExecute,
+ },
+ });
+};
diff --git a/apps/server/src/websocket/handlers/playYouTube.ts b/apps/server/src/websocket/handlers/playYouTube.ts
new file mode 100644
index 00000000..f00fdee1
--- /dev/null
+++ b/apps/server/src/websocket/handlers/playYouTube.ts
@@ -0,0 +1,25 @@
+import { epochNow, ExtractWSRequestFrom } from "@beatsync/shared";
+import { SCHEDULE_TIME_MS } from "../../config";
+import { sendBroadcast } from "../../utils/responses";
+import { requireCanMutate } from "../middlewares";
+import { HandlerFunction } from "../types";
+
+export const handlePlayYouTube: HandlerFunction<
+ ExtractWSRequestFrom["PLAY_YOUTUBE"]
+> = async ({ ws, message, server }) => {
+ const { room } = requireCanMutate(ws);
+
+ const serverTimeToExecute = epochNow() + SCHEDULE_TIME_MS;
+
+ room.updatePlaybackSchedulePlayYouTube(message, serverTimeToExecute);
+
+ sendBroadcast({
+ server,
+ roomId: ws.data.roomId,
+ message: {
+ type: "SCHEDULED_ACTION",
+ scheduledAction: message,
+ serverTimeToExecute: serverTimeToExecute,
+ },
+ });
+};
diff --git a/apps/server/src/websocket/handlers/removeYouTubeSource.ts b/apps/server/src/websocket/handlers/removeYouTubeSource.ts
new file mode 100644
index 00000000..8fb7ada6
--- /dev/null
+++ b/apps/server/src/websocket/handlers/removeYouTubeSource.ts
@@ -0,0 +1,25 @@
+import { ExtractWSRequestFrom } from "@beatsync/shared";
+import { sendBroadcast } from "../../utils/responses";
+import { requireCanMutate } from "../middlewares";
+import { HandlerFunction } from "../types";
+
+export const handleRemoveYouTubeSource: HandlerFunction<
+ ExtractWSRequestFrom["REMOVE_YOUTUBE_SOURCE"]
+> = async ({ ws, message, server }) => {
+ const { room } = requireCanMutate(ws);
+
+ room.removeYouTubeSource(message.videoId);
+
+ // Broadcast updated YouTube sources to all clients
+ sendBroadcast({
+ server,
+ roomId: ws.data.roomId,
+ message: {
+ type: "ROOM_EVENT",
+ event: {
+ type: "SET_YOUTUBE_SOURCES",
+ sources: room.getState().youtubeSources,
+ },
+ },
+ });
+};
diff --git a/apps/server/src/websocket/handlers/seekYouTube.ts b/apps/server/src/websocket/handlers/seekYouTube.ts
new file mode 100644
index 00000000..5e6dc161
--- /dev/null
+++ b/apps/server/src/websocket/handlers/seekYouTube.ts
@@ -0,0 +1,25 @@
+import { epochNow, ExtractWSRequestFrom } from "@beatsync/shared";
+import { SCHEDULE_TIME_MS } from "../../config";
+import { sendBroadcast } from "../../utils/responses";
+import { requireCanMutate } from "../middlewares";
+import { HandlerFunction } from "../types";
+
+export const handleSeekYouTube: HandlerFunction<
+ ExtractWSRequestFrom["SEEK_YOUTUBE"]
+> = async ({ ws, message, server }) => {
+ const { room } = requireCanMutate(ws);
+
+ const serverTimeToExecute = epochNow() + SCHEDULE_TIME_MS;
+
+ room.updatePlaybackScheduleSeekYouTube(message, serverTimeToExecute);
+
+ sendBroadcast({
+ server,
+ roomId: ws.data.roomId,
+ message: {
+ type: "SCHEDULED_ACTION",
+ scheduledAction: message,
+ serverTimeToExecute: serverTimeToExecute,
+ },
+ });
+};
diff --git a/apps/server/src/websocket/handlers/setMode.ts b/apps/server/src/websocket/handlers/setMode.ts
new file mode 100644
index 00000000..39d07610
--- /dev/null
+++ b/apps/server/src/websocket/handlers/setMode.ts
@@ -0,0 +1,25 @@
+import { ExtractWSRequestFrom } from "@beatsync/shared";
+import { sendBroadcast } from "../../utils/responses";
+import { requireCanMutate } from "../middlewares";
+import { HandlerFunction } from "../types";
+
+export const handleSetMode: HandlerFunction<
+ ExtractWSRequestFrom["SET_MODE"]
+> = async ({ ws, message, server }) => {
+ const { room } = requireCanMutate(ws);
+
+ room.setCurrentMode(message.mode);
+
+ // Broadcast mode change to all clients
+ sendBroadcast({
+ server,
+ roomId: ws.data.roomId,
+ message: {
+ type: "ROOM_EVENT",
+ event: {
+ type: "SET_CURRENT_MODE",
+ mode: message.mode,
+ },
+ },
+ });
+};
diff --git a/apps/server/src/websocket/handlers/setSelectedAudio.ts b/apps/server/src/websocket/handlers/setSelectedAudio.ts
new file mode 100644
index 00000000..c2453fef
--- /dev/null
+++ b/apps/server/src/websocket/handlers/setSelectedAudio.ts
@@ -0,0 +1,25 @@
+import { ExtractWSRequestFrom } from "@beatsync/shared";
+import { sendBroadcast } from "../../utils/responses";
+import { requireCanMutate } from "../middlewares";
+import { HandlerFunction } from "../types";
+
+export const handleSetSelectedAudio: HandlerFunction<
+ ExtractWSRequestFrom["SET_SELECTED_AUDIO"]
+> = async ({ ws, message, server }) => {
+ const { room } = requireCanMutate(ws);
+
+ room.setSelectedAudio(message.audioUrl);
+
+ // Broadcast selected audio change to all clients
+ sendBroadcast({
+ server,
+ roomId: ws.data.roomId,
+ message: {
+ type: "ROOM_EVENT",
+ event: {
+ type: "SET_SELECTED_AUDIO",
+ audioUrl: message.audioUrl,
+ },
+ },
+ });
+};
diff --git a/apps/server/src/websocket/handlers/setSelectedYouTube.ts b/apps/server/src/websocket/handlers/setSelectedYouTube.ts
new file mode 100644
index 00000000..81edfc78
--- /dev/null
+++ b/apps/server/src/websocket/handlers/setSelectedYouTube.ts
@@ -0,0 +1,25 @@
+import { ExtractWSRequestFrom } from "@beatsync/shared";
+import { sendBroadcast } from "../../utils/responses";
+import { requireCanMutate } from "../middlewares";
+import { HandlerFunction } from "../types";
+
+export const handleSetSelectedYouTube: HandlerFunction<
+ ExtractWSRequestFrom["SET_SELECTED_YOUTUBE"]
+> = async ({ ws, message, server }) => {
+ const { room } = requireCanMutate(ws);
+
+ room.setSelectedYouTube(message.videoId);
+
+ // Broadcast selected YouTube change to all clients
+ sendBroadcast({
+ server,
+ roomId: ws.data.roomId,
+ message: {
+ type: "ROOM_EVENT",
+ event: {
+ type: "SET_SELECTED_YOUTUBE",
+ videoId: message.videoId,
+ },
+ },
+ });
+};
diff --git a/apps/server/src/websocket/registry.ts b/apps/server/src/websocket/registry.ts
index dfd7a42e..9af0fda1 100644
--- a/apps/server/src/websocket/registry.ts
+++ b/apps/server/src/websocket/registry.ts
@@ -1,12 +1,20 @@
import { ClientActionEnum } from "@beatsync/shared";
+import { handleAddYouTubeSource } from "./handlers/addYouTubeSource";
import { handleSetAdmin } from "./handlers/handleSetAdmin";
import { handleSetPlaybackControls } from "./handlers/handleSetPlaybackControls";
import { handleMoveClient } from "./handlers/moveClient";
import { handleNTPRequest } from "./handlers/ntpRequest";
import { handlePause } from "./handlers/pause";
+import { handlePauseYouTube } from "./handlers/pauseYouTube";
import { handlePlay } from "./handlers/play";
+import { handlePlayYouTube } from "./handlers/playYouTube";
+import { handleRemoveYouTubeSource } from "./handlers/removeYouTubeSource";
import { handleReorderClient } from "./handlers/reorderClient";
+import { handleSeekYouTube } from "./handlers/seekYouTube";
import { handleSetListeningSource } from "./handlers/setListeningSource";
+import { handleSetMode } from "./handlers/setMode";
+import { handleSetSelectedAudio } from "./handlers/setSelectedAudio";
+import { handleSetSelectedYouTube } from "./handlers/setSelectedYouTube";
import { handleStartSpatialAudio } from "./handlers/startSpatialAudio";
import { handleStopSpatialAudio } from "./handlers/stopSpatialAudio";
import { handleSync } from "./handlers/sync";
@@ -27,6 +35,46 @@ export const WS_REGISTRY: WebsocketRegistry = {
description: "Schedule pause action for synchronized playback",
},
+ [ClientActionEnum.enum.PLAY_YOUTUBE]: {
+ handle: handlePlayYouTube,
+ description: "Schedule YouTube play action for synchronized playback",
+ },
+
+ [ClientActionEnum.enum.PAUSE_YOUTUBE]: {
+ handle: handlePauseYouTube,
+ description: "Schedule YouTube pause action for synchronized playback",
+ },
+
+ [ClientActionEnum.enum.SEEK_YOUTUBE]: {
+ handle: handleSeekYouTube,
+ description: "Schedule YouTube seek action for synchronized playback",
+ },
+
+ [ClientActionEnum.enum.SET_MODE]: {
+ handle: handleSetMode,
+ description: "Set the current mode (library or youtube)",
+ },
+
+ [ClientActionEnum.enum.ADD_YOUTUBE_SOURCE]: {
+ handle: handleAddYouTubeSource,
+ description: "Add a YouTube source to the room",
+ },
+
+ [ClientActionEnum.enum.REMOVE_YOUTUBE_SOURCE]: {
+ handle: handleRemoveYouTubeSource,
+ description: "Remove a YouTube source from the room",
+ },
+
+ [ClientActionEnum.enum.SET_SELECTED_AUDIO]: {
+ handle: handleSetSelectedAudio,
+ description: "Set the selected audio source",
+ },
+
+ [ClientActionEnum.enum.SET_SELECTED_YOUTUBE]: {
+ handle: handleSetSelectedYouTube,
+ description: "Set the selected YouTube source",
+ },
+
[ClientActionEnum.enum.START_SPATIAL_AUDIO]: {
handle: handleStartSpatialAudio,
description: "Start spatial audio processing loop",
diff --git a/bun.lock b/bun.lock
index fd0dd17c..6c79bf2b 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": {
@@ -537,6 +539,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=="],
@@ -933,6 +937,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=="],
@@ -1049,6 +1055,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=="],
@@ -1103,6 +1111,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=="],
@@ -1223,6 +1233,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=="],
@@ -1391,6 +1403,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=="],
@@ -1465,6 +1479,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/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
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 e8592477..da42f152 100644
--- a/packages/shared/types/WSBroadcast.ts
+++ b/packages/shared/types/WSBroadcast.ts
@@ -1,10 +1,13 @@
import { z } from "zod";
-import {
- PauseActionSchema,
- PlayActionSchema,
- SetPlaybackControlsSchema,
+import {
+ PauseActionSchema,
+ PlayActionSchema,
+ PlayYouTubeActionSchema,
+ PauseYouTubeActionSchema,
+ SeekYouTubeActionSchema,
+ SetPlaybackControlsSchema
} from "./WSRequest";
-import { AudioSourceSchema, PositionSchema } from "./basic";
+import { AudioSourceSchema, PositionSchema, YouTubeSourceSchema } from "./basic";
// ROOM EVENTS
@@ -31,11 +34,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,
SetPlaybackControlsSchema,
]),
});
@@ -63,6 +97,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 2e529559..8243e4ef 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(),
@@ -15,6 +15,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",
"SET_ADMIN", // Set admin status
"SET_PLAYBACK_CONTROLS", // Set playback controls
]);
@@ -68,6 +76,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(),
+});
+
const SetAdminSchema = z.object({
type: z.literal(ClientActionEnum.enum.SET_ADMIN),
clientId: z.string(), // The client to set admin status for
@@ -97,6 +149,14 @@ export const WSRequestSchema = z.discriminatedUnion("type", [
SetListeningSourceSchema,
MoveClientSchema,
ClientRequestSyncSchema,
+ PlayYouTubeActionSchema,
+ PauseYouTubeActionSchema,
+ SeekYouTubeActionSchema,
+ SetModeActionSchema,
+ AddYouTubeSourceActionSchema,
+ RemoveYouTubeSourceActionSchema,
+ SetSelectedAudioActionSchema,
+ SetSelectedYouTubeActionSchema,
SetAdminSchema,
SetPlaybackControlsSchema,
]);
@@ -106,6 +166,16 @@ 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;
+
// Mapped type to access request types by their type field
export type ExtractWSRequestFrom = {
[K in WSRequestType["type"]]: Extract;
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;