Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Fill in the `.env` file in `apps/client` with the following:
```sh
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
NEXT_PUBLIC_APPLE_MUSIC_DEVELOPER_TOKEN=your_apple_music_developer_token_here
```

Run the following commands to start the server and client:
Expand Down
3 changes: 3 additions & 0 deletions apps/client/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
env: {
NEXT_PUBLIC_APPLE_MUSIC_DEVELOPER_TOKEN: process.env.NEXT_PUBLIC_APPLE_MUSIC_DEVELOPER_TOKEN,
},
async rewrites() {
return [
{
Expand Down
166 changes: 166 additions & 0 deletions apps/client/src/components/AppleMusicConnector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";

const APPLE_MUSIC_DEVELOPER_TOKEN = process.env.NEXT_PUBLIC_APPLE_MUSIC_DEVELOPER_TOKEN;

// Dynamically load MusicKit JS from Apple CDN
function loadMusicKitScript(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).MusicKit) {
resolve();
return;
}
const script = document.createElement("script");
script.src = "https://js-cdn.music.apple.com/musickit/v1/musickit.js";
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load MusicKit JS"));
document.body.appendChild(script);
});
}

interface AppleMusicConnectorProps {
onTrackSelected?: (track: any) => void;
}

export default function AppleMusicConnector({ onTrackSelected }: AppleMusicConnectorProps) {
const [isReady, setIsReady] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(false);
const [userToken, setUserToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedTrack, setSelectedTrack] = useState<any | null>(null);

useEffect(() => {
if (!APPLE_MUSIC_DEVELOPER_TOKEN) {
setError("Apple Music developer token is not set. Please add NEXT_PUBLIC_APPLE_MUSIC_DEVELOPER_TOKEN to your .env file.");
return;
}
loadMusicKitScript()
.then(() => {
(window as any).MusicKit.configure({
developerToken: APPLE_MUSIC_DEVELOPER_TOKEN,
app: {
name: "beatsync",
build: "1.0.0",
},
});
setIsReady(true);
})
.catch((err) => setError(err.message));
}, []);

const handleSignIn = async () => {
try {
const musicKit = (window as any).MusicKit.getInstance();
const token = await musicKit.authorize();
setUserToken(token);
setIsAuthorized(true);
} catch (err: any) {
setError(err.message || "Failed to authorize with Apple Music");
}
};

const handleSearch = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
setIsSearching(true);
setSearchResults([]);
setSelectedTrack(null);
try {
const musicKit = (window as any).MusicKit.getInstance();
const result = await musicKit.api.search(searchQuery, { types: ["songs"], limit: 10 });
const songs = result.songs?.data || [];
setSearchResults(songs);
} catch (err: any) {
setError(err.message || "Failed to search Apple Music");
} finally {
setIsSearching(false);
}
};

const handleSelectTrack = (track: any) => {
setSelectedTrack(track);
if (onTrackSelected) {
onTrackSelected(track);
}
};

return (
<div className="p-4 border rounded bg-neutral-900 text-white w-full max-w-md">
<h2 className="text-lg font-semibold mb-2">Apple Music Integration</h2>
{!isReady && !error && <div>Loading Apple Music SDK...</div>}
{error && <div className="text-red-400 mb-2">Error: {error}</div>}
{isReady && !isAuthorized && !error && (
<button
onClick={handleSignIn}
className="px-4 py-2 rounded bg-[#FA2A55] text-white font-medium hover:bg-[#fa2a55cc] transition-colors mb-2"
>
Sign in with Apple Music
</button>
)}
{isAuthorized && !error && (
<div>
<div className="mb-2">Signed in to Apple Music!</div>
{/* Search Input */}
<form onSubmit={handleSearch} className="flex gap-2 mb-4">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search for songs, artists, albums..."
className="flex-1 px-2 py-1 rounded text-black"
disabled={isSearching}
/>
<button
type="submit"
className="px-3 py-1 rounded bg-[#FA2A55] text-white font-medium hover:bg-[#fa2a55cc] transition-colors"
disabled={isSearching || !searchQuery.trim()}
>
{isSearching ? "Searching..." : "Search"}
</button>
</form>
{/* Search Results */}
{searchResults.length > 0 && !selectedTrack && (
<div className="space-y-2 max-h-64 overflow-y-auto">
{searchResults.map((track) => (
<div key={track.id} className="flex items-center gap-3 p-2 bg-neutral-800 rounded">
<img
src={track.attributes.artwork.url.replace('{w}x{h}bb', '60x60bb')}
alt={track.attributes.name}
className="w-12 h-12 rounded object-cover"
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{track.attributes.name}</div>
<div className="text-xs text-neutral-300 truncate">{track.attributes.artistName}</div>
</div>
<button
onClick={() => handleSelectTrack(track)}
className="px-2 py-1 rounded bg-primary-700 text-white text-xs font-semibold hover:bg-primary-600"
>
Select
</button>
</div>
))}
</div>
)}
{/* Selected Track */}
{selectedTrack && (
<div className="mt-4 p-3 bg-neutral-800 rounded flex items-center gap-3">
<img
src={selectedTrack.attributes.artwork.url.replace('{w}x{h}bb', '100x100bb')}
alt={selectedTrack.attributes.name}
className="w-16 h-16 rounded object-cover"
/>
<div>
<div className="font-semibold">{selectedTrack.attributes.name}</div>
<div className="text-sm text-neutral-300">{selectedTrack.attributes.artistName}</div>
<div className="text-xs text-neutral-400">Album: {selectedTrack.attributes.albumName}</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
146 changes: 101 additions & 45 deletions apps/client/src/components/AudioUploaderMinimal.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
"use client";

import React from "react";
import { uploadAudioFile } from "@/lib/api";
import { cn, trimFileName } from "@/lib/utils";
import { useRoomStore } from "@/store/room";
import { CloudUpload, Plus } from "lucide-react";
import { usePostHog } from "posthog-js/react";
import { useState } from "react";
import { toast } from "sonner";
import AppleMusicConnector from "./AppleMusicConnector";

export const AudioUploaderMinimal = () => {
export const AudioUploaderMinimal = ({ setSelectedAppleMusicTrack, selectedAppleMusicTrack }: { setSelectedAppleMusicTrack?: (track: any) => void, selectedAppleMusicTrack?: any }) => {
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
const roomId = useRoomStore((state) => state.roomId);
const posthog = usePostHog();
const [showAppleMusic, setShowAppleMusic] = useState(false);

const handleFileUpload = async (file: File) => {
// Store file name for display
Expand Down Expand Up @@ -63,6 +66,11 @@ export const AudioUploaderMinimal = () => {
}
};

// Placeholder for Apple Music connection logic
const handleConnectAppleMusic = () => {
setShowAppleMusic(true);
};

const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
Expand Down Expand Up @@ -98,53 +106,101 @@ export const AudioUploaderMinimal = () => {
};

return (
<div
className={cn(
"border border-neutral-700/50 rounded-md mx-2 transition-all overflow-hidden bg-neutral-800/30 hover:bg-neutral-800/50",
isDragging
? "outline outline-primary-400 outline-dashed"
: "outline-none"
)}
id="drop_zone"
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDragEnd={onDragLeave}
onDrop={onDropEvent}
>
<label htmlFor="audio-upload" className="cursor-pointer block w-full">
<div className="p-3 flex items-center gap-3">
<div className="bg-primary-700 text-white p-1.5 rounded-md flex-shrink-0">
{isUploading ? (
<CloudUpload className="h-4 w-4 animate-pulse" />
) : (
<Plus className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-white truncate">
{isUploading
? "Uploading..."
: fileName
? trimFileName(fileName)
: "Upload audio"}
<>
<div
className={cn(
"border border-neutral-700/50 rounded-md mx-2 transition-all overflow-hidden bg-neutral-800/30 hover:bg-neutral-800/50",
isDragging
? "outline outline-primary-400 outline-dashed"
: "outline-none"
)}
id="drop_zone"
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDragEnd={onDragLeave}
onDrop={onDropEvent}
>
<label htmlFor="audio-upload" className="cursor-pointer block w-full">
<div className="p-3 flex items-center gap-3">
<div className="bg-primary-700 text-white p-1.5 rounded-md flex-shrink-0">
{isUploading ? (
<CloudUpload className="h-4 w-4 animate-pulse" />
) : (
<Plus className="h-4 w-4" />
)}
</div>
{!isUploading && !fileName && (
<div className="text-xs text-neutral-400 truncate">
Add music to queue
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-white truncate">
{isUploading
? "Uploading..."
: fileName
? trimFileName(fileName)
: "Upload audio"}
</div>
)}
{!isUploading && !fileName && (
<div className="text-xs text-neutral-400 truncate">
Add music to queue
</div>
)}
</div>
</div>
</label>

<input
id="audio-upload"
type="file"
accept="audio/*"
onChange={onInputChange}
disabled={isUploading}
className="hidden"
/>
</div>
{/* Apple Music Connect Button */}
<div className="mx-2 mt-2">
<button
type="button"
onClick={handleConnectAppleMusic}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[#FA2A55] text-white font-medium hover:bg-[#fa2a55cc] transition-colors"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="10" fill="white"/>
<path d="M14.5 6.5L8.5 8V13.5" stroke="#FA2A55" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="8.5" cy="14" r="1" fill="#FA2A55"/>
</svg>
Connect to Apple Music
</button>
</div>
{showAppleMusic && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60">
<div className="relative w-full max-w-md mx-auto">
<button
className="absolute top-2 right-2 text-white bg-neutral-700 rounded-full p-1 hover:bg-neutral-600"
onClick={() => setShowAppleMusic(false)}
aria-label="Close Apple Music Connector"
>
&times;
</button>
<AppleMusicConnector onTrackSelected={(track) => {
setSelectedAppleMusicTrack?.(track);
setShowAppleMusic(false);
}} />
</div>
</div>
</label>

<input
id="audio-upload"
type="file"
accept="audio/*"
onChange={onInputChange}
disabled={isUploading}
className="hidden"
/>
</div>
)}
{selectedAppleMusicTrack && (
<div className="mx-2 mt-4 p-3 bg-neutral-900 rounded flex items-center gap-3">
<img
src={selectedAppleMusicTrack.attributes.artwork.url.replace('{w}x{h}bb', '60x60bb')}
alt={selectedAppleMusicTrack.attributes.name}
className="w-12 h-12 rounded object-cover"
/>
<div>
<div className="font-semibold">{selectedAppleMusicTrack.attributes.name}</div>
<div className="text-sm text-neutral-300">{selectedAppleMusicTrack.attributes.artistName}</div>
<div className="text-xs text-neutral-400">Album: {selectedAppleMusicTrack.attributes.albumName}</div>
</div>
</div>
)}
</>
);
};
4 changes: 3 additions & 1 deletion apps/client/src/components/Queue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ 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 { AudioUploaderMinimal } from "./AudioUploaderMinimal";

export const Queue = ({ className, ...rest }: React.ComponentProps<"div">) => {
export const Queue = ({ className, setSelectedAppleMusicTrack, selectedAppleMusicTrack, ...rest }: React.ComponentProps<"div"> & { setSelectedAppleMusicTrack: (track: any) => void, selectedAppleMusicTrack: any }) => {
const posthog = usePostHog();
const audioSources = useGlobalStore((state) => state.audioSources);
const selectedAudioId = useGlobalStore((state) => state.selectedAudioUrl);
Expand Down Expand Up @@ -49,6 +50,7 @@ export const Queue = ({ className, ...rest }: React.ComponentProps<"div">) => {
return (
<div className={cn("", className)} {...rest}>
{/* <h2 className="text-xl font-bold mb-2 select-none">Beatsync</h2> */}
<AudioUploaderMinimal setSelectedAppleMusicTrack={setSelectedAppleMusicTrack} selectedAppleMusicTrack={selectedAppleMusicTrack} />
<div className="space-y-1">
{audioSources.length > 0 ? (
<AnimatePresence initial={true}>
Expand Down
Loading