Spaces:
Running
Running
Upload 7 files
Browse files- components/ConnectedDeviceCard.tsx +75 -0
- components/DeviceCard.tsx +58 -0
- components/DeviceScanner.tsx +62 -0
- components/Footer.tsx +11 -0
- components/Header.tsx +17 -0
- components/SyncDashboard.tsx +137 -0
- components/icons.tsx +53 -0
components/ConnectedDeviceCard.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import type { BluetoothDevice, Action } from '../types.ts';
|
| 4 |
+
import { InfoIcon } from './icons.tsx';
|
| 5 |
+
|
| 6 |
+
interface ConnectedDeviceCardProps {
|
| 7 |
+
device: BluetoothDevice;
|
| 8 |
+
dispatch: React.Dispatch<Action>;
|
| 9 |
+
onDisconnect: (id: string) => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const ConnectedDeviceCard: React.FC<ConnectedDeviceCardProps> = ({ device, dispatch, onDisconnect }) => {
|
| 13 |
+
const totalDelay = device.latency + device.delay;
|
| 14 |
+
|
| 15 |
+
const handleUpdateVolume = (id: string, volume: number) => {
|
| 16 |
+
dispatch({ type: 'UPDATE_VOLUME', payload: { id, volume } });
|
| 17 |
+
}
|
| 18 |
+
const handleUpdateDelay = (id: string, delay: number) => {
|
| 19 |
+
dispatch({ type: 'UPDATE_DELAY', payload: { id, delay } });
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const calibrationClass = device.isCalibrating ? 'calibrating-shimmer' : '';
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className={`bg-slate-700/50 p-4 rounded-lg relative overflow-hidden ${calibrationClass}`}>
|
| 26 |
+
<div className="flex justify-between items-start">
|
| 27 |
+
<div>
|
| 28 |
+
<p className="font-semibold text-white">{device.name}</p>
|
| 29 |
+
<div className="text-xs text-slate-400 space-x-2">
|
| 30 |
+
<span title="Inherent device latency">{`Latency: ${device.latency}ms`}</span>
|
| 31 |
+
<span className="font-bold text-cyan-400" title="Total effective delay after calibration">{`Total Delay: ${totalDelay}ms`}</span>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
<button
|
| 35 |
+
onClick={() => onDisconnect(device.id)}
|
| 36 |
+
className="text-xs text-red-400 hover:text-red-300 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 rounded"
|
| 37 |
+
aria-label={`Disconnect ${device.name}`}
|
| 38 |
+
>
|
| 39 |
+
Disconnect
|
| 40 |
+
</button>
|
| 41 |
+
</div>
|
| 42 |
+
<div className="mt-4 space-y-3">
|
| 43 |
+
<div>
|
| 44 |
+
<label htmlFor={`volume-${device.id}`} className="text-xs text-slate-300">Volume: {device.volume}</label>
|
| 45 |
+
<input
|
| 46 |
+
id={`volume-${device.id}`}
|
| 47 |
+
type="range"
|
| 48 |
+
min="0" max="100"
|
| 49 |
+
value={device.volume}
|
| 50 |
+
onChange={(e) => handleUpdateVolume(device.id, parseInt(e.target.value))}
|
| 51 |
+
className="w-full h-2 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
| 52 |
+
aria-label={`${device.name} volume control`}
|
| 53 |
+
/>
|
| 54 |
+
</div>
|
| 55 |
+
<div>
|
| 56 |
+
<label htmlFor={`delay-${device.id}`} className="flex items-center space-x-1 text-xs text-slate-300">
|
| 57 |
+
<span>Manual Delay: {device.delay}ms</span>
|
| 58 |
+
<span title="Manual offset to sync with other devices. Auto-calibrate sets this to compensate for latency.">
|
| 59 |
+
<InfoIcon className="w-3 h-3"/>
|
| 60 |
+
</span>
|
| 61 |
+
</label>
|
| 62 |
+
<input
|
| 63 |
+
id={`delay-${device.id}`}
|
| 64 |
+
type="range"
|
| 65 |
+
min="0" max="100"
|
| 66 |
+
value={device.delay}
|
| 67 |
+
onChange={(e) => handleUpdateDelay(device.id, parseInt(e.target.value))}
|
| 68 |
+
className="w-full h-2 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-cyan-500"
|
| 69 |
+
aria-label={`${device.name} manual delay control`}
|
| 70 |
+
/>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
);
|
| 75 |
+
}
|
components/DeviceCard.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import type { BluetoothDevice } from '../types.ts';
|
| 4 |
+
import { DeviceType, DeviceStatus } from '../types.ts';
|
| 5 |
+
import { SpeakerIcon, HeadphonesIcon, SoundbarIcon } from './icons.tsx';
|
| 6 |
+
|
| 7 |
+
interface DeviceCardProps {
|
| 8 |
+
device: BluetoothDevice;
|
| 9 |
+
onConnect: (id: string) => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const DeviceIcon: React.FC<{ type: DeviceType }> = ({ type }) => {
|
| 13 |
+
const className = "w-8 h-8 text-slate-400 flex-shrink-0";
|
| 14 |
+
if (type === DeviceType.Headphones) return <HeadphonesIcon className={className} />;
|
| 15 |
+
if (type === DeviceType.Soundbar) return <SoundbarIcon className={className} />;
|
| 16 |
+
return <SpeakerIcon className={className} />;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const Battery: React.FC<{ level: number }> = ({ level }) => {
|
| 20 |
+
const width = `${level}%`;
|
| 21 |
+
const color = level > 50 ? 'bg-green-500' : level > 20 ? 'bg-yellow-500' : 'bg-red-500';
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="w-6 h-3 border border-slate-500 rounded-sm p-0.5 flex items-center" title={`Battery level: ${level}%`}>
|
| 25 |
+
<div className={`h-full rounded-sm ${color}`} style={{ width }}></div>
|
| 26 |
+
</div>
|
| 27 |
+
);
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
export const DeviceCard: React.FC<DeviceCardProps> = ({ device, onConnect }) => {
|
| 31 |
+
const isConnecting = device.status === DeviceStatus.Connecting;
|
| 32 |
+
const connectingClass = isConnecting ? 'animate-pulse ring-2 ring-blue-500 ring-offset-2 ring-offset-slate-800' : '';
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<div className={`flex items-center justify-between bg-slate-700/50 p-3 rounded-lg hover:bg-slate-700 transition-all duration-200 ${connectingClass}`}>
|
| 36 |
+
<div className="flex items-center space-x-4 overflow-hidden">
|
| 37 |
+
<DeviceIcon type={device.type} />
|
| 38 |
+
<div className="truncate">
|
| 39 |
+
<p className="font-medium text-white truncate">{device.name}</p>
|
| 40 |
+
<div className="flex items-center space-x-2 text-xs text-slate-400">
|
| 41 |
+
<span>{device.type}</span>
|
| 42 |
+
<span className="text-slate-600">•</span>
|
| 43 |
+
<Battery level={device.battery} />
|
| 44 |
+
<span>{device.battery}%</span>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
<button
|
| 49 |
+
onClick={() => onConnect(device.id)}
|
| 50 |
+
disabled={isConnecting}
|
| 51 |
+
className="ml-2 flex-shrink-0 px-3 py-1.5 text-sm font-semibold bg-slate-600 text-slate-200 rounded-md hover:bg-slate-500 disabled:bg-slate-800 disabled:text-slate-500 disabled:cursor-wait transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-slate-700"
|
| 52 |
+
aria-label={isConnecting ? `Connecting to ${device.name}` : `Connect to ${device.name}`}
|
| 53 |
+
>
|
| 54 |
+
{isConnecting ? 'Connecting...' : 'Connect'}
|
| 55 |
+
</button>
|
| 56 |
+
</div>
|
| 57 |
+
);
|
| 58 |
+
};
|
components/DeviceScanner.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useCallback } from 'react';
|
| 3 |
+
import type { BluetoothDevice, Action } from '../types.ts';
|
| 4 |
+
import { DeviceStatus } from '../types.ts';
|
| 5 |
+
import { DeviceCard } from './DeviceCard.tsx';
|
| 6 |
+
import { ScanIcon } from './icons.tsx';
|
| 7 |
+
|
| 8 |
+
interface DeviceScannerProps {
|
| 9 |
+
devices: BluetoothDevice[];
|
| 10 |
+
onScan: () => void;
|
| 11 |
+
dispatch: React.Dispatch<Action>;
|
| 12 |
+
isScanning: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const DeviceScanner: React.FC<DeviceScannerProps> = ({ devices, onScan, dispatch, isScanning }) => {
|
| 16 |
+
const handleConnect = useCallback((id: string) => {
|
| 17 |
+
dispatch({ type: 'UPDATE_STATUS', payload: { id, status: DeviceStatus.Connecting } });
|
| 18 |
+
setTimeout(() => {
|
| 19 |
+
const randomLatency = Math.floor(Math.random() * 50) + 5;
|
| 20 |
+
dispatch({ type: 'UPDATE_STATUS', payload: { id, status: DeviceStatus.Connected, latency: randomLatency } });
|
| 21 |
+
}, 1500);
|
| 22 |
+
}, [dispatch]);
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className="bg-slate-800/50 rounded-lg p-6 shadow-lg h-full flex flex-col">
|
| 26 |
+
<div className="flex justify-between items-center mb-4">
|
| 27 |
+
<h2 className="text-xl font-semibold text-white">Available Devices</h2>
|
| 28 |
+
<button
|
| 29 |
+
onClick={onScan}
|
| 30 |
+
disabled={isScanning}
|
| 31 |
+
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-slate-900"
|
| 32 |
+
aria-label={isScanning ? 'Scanning for devices' : 'Scan for new devices'}
|
| 33 |
+
>
|
| 34 |
+
{isScanning ? (
|
| 35 |
+
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 36 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 37 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 38 |
+
</svg>
|
| 39 |
+
) : (
|
| 40 |
+
<ScanIcon className="h-5 w-5" />
|
| 41 |
+
)}
|
| 42 |
+
<span>{isScanning ? 'Scanning...' : 'Scan'}</span>
|
| 43 |
+
</button>
|
| 44 |
+
</div>
|
| 45 |
+
<div className="flex-grow overflow-y-auto pr-2 -mr-2 space-y-3">
|
| 46 |
+
{isScanning && devices.length === 0 && (
|
| 47 |
+
<div className="text-center py-10 text-slate-400">
|
| 48 |
+
<p>Searching for nearby devices...</p>
|
| 49 |
+
</div>
|
| 50 |
+
)}
|
| 51 |
+
{!isScanning && devices.length === 0 && (
|
| 52 |
+
<div className="text-center py-10 text-slate-400">
|
| 53 |
+
<p>No devices found. Try scanning again.</p>
|
| 54 |
+
</div>
|
| 55 |
+
)}
|
| 56 |
+
{devices.map(device => (
|
| 57 |
+
<DeviceCard key={device.id} device={device} onConnect={handleConnect} />
|
| 58 |
+
))}
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
);
|
| 62 |
+
};
|
components/Footer.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
|
| 4 |
+
export const Footer: React.FC = () => {
|
| 5 |
+
return (
|
| 6 |
+
<footer className="container mx-auto p-4 mt-8 text-center text-slate-500 text-sm">
|
| 7 |
+
<p>Bluetooth Stereo Sync v1.0.0</p>
|
| 8 |
+
<p>This is a UI simulation. It does not control actual Bluetooth hardware.</p>
|
| 9 |
+
</footer>
|
| 10 |
+
);
|
| 11 |
+
};
|
components/Header.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import { BluetoothIcon } from './icons.tsx';
|
| 4 |
+
|
| 5 |
+
export const Header: React.FC = () => {
|
| 6 |
+
return (
|
| 7 |
+
<header className="bg-slate-800/50 backdrop-blur-sm border-b border-slate-700/50 sticky top-0 z-10">
|
| 8 |
+
<div className="container mx-auto p-4 flex items-center justify-center space-x-4">
|
| 9 |
+
<BluetoothIcon className="text-blue-400 h-8 w-8" />
|
| 10 |
+
<div>
|
| 11 |
+
<h1 className="text-2xl font-bold text-white tracking-tight">Bluetooth Stereo Sync</h1>
|
| 12 |
+
<p className="text-sm text-slate-400">Combine multiple speakers into one synchronized audio output.</p>
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
</header>
|
| 16 |
+
);
|
| 17 |
+
}
|
components/SyncDashboard.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import type { BluetoothDevice, Action } from '../types.ts';
|
| 4 |
+
import { DeviceStatus } from '../types.ts';
|
| 5 |
+
import { ConnectedDeviceCard } from './ConnectedDeviceCard.tsx';
|
| 6 |
+
import { VolumeHighIcon, VolumeMuteIcon } from './icons.tsx';
|
| 7 |
+
|
| 8 |
+
interface SyncDashboardProps {
|
| 9 |
+
connectedDevices: BluetoothDevice[];
|
| 10 |
+
dispatch: React.Dispatch<Action>;
|
| 11 |
+
masterVolume: number;
|
| 12 |
+
isMuted: boolean;
|
| 13 |
+
showNotification: (message: string) => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const AudioVisualizer: React.FC<{ volume: number, isMuted: boolean }> = ({ volume, isMuted }) => {
|
| 17 |
+
const barCount = 32;
|
| 18 |
+
const [bars, setBars] = useState<number[]>(Array(barCount).fill(2));
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
const interval = setInterval(() => {
|
| 22 |
+
if (isMuted || volume === 0) {
|
| 23 |
+
setBars(Array(barCount).fill(2));
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
const maxEffect = Math.max(2, (volume / 100) * 98 + 2);
|
| 27 |
+
setBars(bars.map(() => Math.random() * maxEffect + 2));
|
| 28 |
+
}, 150);
|
| 29 |
+
return () => clearInterval(interval);
|
| 30 |
+
}, [bars, volume, isMuted]);
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="flex items-end justify-center h-24 space-x-1 bg-slate-900/50 p-4 rounded-lg">
|
| 34 |
+
{bars.map((height, i) => (
|
| 35 |
+
<div
|
| 36 |
+
key={i}
|
| 37 |
+
className="w-full bg-gradient-to-t from-blue-500 to-cyan-400 rounded-t-full"
|
| 38 |
+
style={{ height: `${height}%`, transition: 'height 0.1s ease-in-out' }}
|
| 39 |
+
></div>
|
| 40 |
+
))}
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
export const SyncDashboard: React.FC<SyncDashboardProps> = ({ connectedDevices, dispatch, masterVolume, isMuted, showNotification }) => {
|
| 46 |
+
const [isCalibrating, setIsCalibrating] = useState(false);
|
| 47 |
+
|
| 48 |
+
const handleAutoCalibrate = () => {
|
| 49 |
+
setIsCalibrating(true);
|
| 50 |
+
dispatch({ type: 'START_CALIBRATION' });
|
| 51 |
+
|
| 52 |
+
const maxLatency = Math.max(...connectedDevices.map(d => d.latency), 0);
|
| 53 |
+
|
| 54 |
+
const calibrationPromises = connectedDevices.map((device, index) => {
|
| 55 |
+
return new Promise<void>(resolve => {
|
| 56 |
+
setTimeout(() => {
|
| 57 |
+
const delay = maxLatency - device.latency;
|
| 58 |
+
dispatch({ type: 'UPDATE_DELAY', payload: { id: device.id, delay } });
|
| 59 |
+
resolve();
|
| 60 |
+
}, (index + 1) * 200); // Faster calibration
|
| 61 |
+
});
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
Promise.all(calibrationPromises).then(() => {
|
| 65 |
+
setTimeout(() => {
|
| 66 |
+
dispatch({ type: 'FINISH_CALIBRATION' });
|
| 67 |
+
setIsCalibrating(false);
|
| 68 |
+
showNotification("Calibration complete! Devices are now in sync.");
|
| 69 |
+
}, 500);
|
| 70 |
+
});
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const handleDisconnect = (id: string) => {
|
| 74 |
+
dispatch({ type: 'UPDATE_STATUS', payload: {id, status: DeviceStatus.Disconnected } });
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
if (connectedDevices.length === 0) {
|
| 78 |
+
return (
|
| 79 |
+
<div className="bg-slate-800/50 rounded-lg p-6 shadow-lg h-full flex items-center justify-center min-h-[300px]">
|
| 80 |
+
<div className="text-center text-slate-400">
|
| 81 |
+
<h2 className="text-xl font-semibold mb-2 text-white">Synchronized Group</h2>
|
| 82 |
+
<p>Connect devices from the 'Available Devices' list to create a stereo group.</p>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
<div className="bg-slate-800/50 rounded-lg p-6 shadow-lg h-full space-y-6">
|
| 90 |
+
<div>
|
| 91 |
+
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4 mb-4">
|
| 92 |
+
<h2 className="text-xl font-semibold text-white">Synchronized Group ({connectedDevices.length})</h2>
|
| 93 |
+
<button
|
| 94 |
+
onClick={handleAutoCalibrate}
|
| 95 |
+
disabled={isCalibrating}
|
| 96 |
+
className="px-4 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-slate-800"
|
| 97 |
+
aria-label={isCalibrating ? 'Calibrating device delays' : 'Auto-calibrate device delays'}
|
| 98 |
+
>
|
| 99 |
+
{isCalibrating ? 'Calibrating...' : 'Auto-Calibrate Delays'}
|
| 100 |
+
</button>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<div className="space-y-4">
|
| 104 |
+
<div className="bg-slate-900/50 p-4 rounded-lg">
|
| 105 |
+
<label className="text-sm font-medium text-white">Master Controls</label>
|
| 106 |
+
<div className="flex items-center space-x-4 mt-2">
|
| 107 |
+
<button onClick={() => dispatch({ type: 'TOGGLE_MUTE' })} className="p-2 rounded-full hover:bg-slate-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500" aria-label={isMuted ? 'Unmute master volume' : 'Mute master volume'}>
|
| 108 |
+
{isMuted ? <VolumeMuteIcon className="h-6 w-6 text-slate-300" /> : <VolumeHighIcon className="h-6 w-6 text-slate-300" />}
|
| 109 |
+
</button>
|
| 110 |
+
<input
|
| 111 |
+
type="range"
|
| 112 |
+
min="0"
|
| 113 |
+
max="100"
|
| 114 |
+
value={isMuted ? 0 : masterVolume}
|
| 115 |
+
onChange={(e) => dispatch({ type: 'SET_MASTER_VOLUME', payload: parseInt(e.target.value) })}
|
| 116 |
+
className="w-full h-2 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
| 117 |
+
aria-label="Master volume control"
|
| 118 |
+
/>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
<AudioVisualizer volume={masterVolume} isMuted={isMuted} />
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 126 |
+
{connectedDevices.map(device => (
|
| 127 |
+
<ConnectedDeviceCard
|
| 128 |
+
key={device.id}
|
| 129 |
+
device={device}
|
| 130 |
+
dispatch={dispatch}
|
| 131 |
+
onDisconnect={handleDisconnect}
|
| 132 |
+
/>
|
| 133 |
+
))}
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
);
|
| 137 |
+
};
|
components/icons.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
|
| 4 |
+
type IconProps = React.SVGProps<SVGSVGElement>;
|
| 5 |
+
|
| 6 |
+
export const BluetoothIcon: React.FC<IconProps> = (props) => (
|
| 7 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
| 8 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M17.293 4.293a1 1 0 011.414 0l2 2a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7a1 1 0 010-1.414l2-2a1 1 0 011.414 0L12 7.586l5.293-3.293zM12 20v-8" />
|
| 9 |
+
</svg>
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
export const ScanIcon: React.FC<IconProps> = (props) => (
|
| 13 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
| 14 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
| 15 |
+
</svg>
|
| 16 |
+
);
|
| 17 |
+
|
| 18 |
+
export const SpeakerIcon: React.FC<IconProps> = (props) => (
|
| 19 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
| 20 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
|
| 21 |
+
</svg>
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
export const HeadphonesIcon: React.FC<IconProps> = (props) => (
|
| 25 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
| 26 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 8.25v7.5" />
|
| 27 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 16.5v-2.25m3.75 2.25v-2.25m-7.5 0v2.25m7.5-6.75v-2.25m-3.75 2.25v-2.25m-3.75 0v2.25M9 12a3 3 0 11-6 0 3 3 0 016 0zm9 0a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 28 |
+
</svg>
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
export const SoundbarIcon: React.FC<IconProps> = (props) => (
|
| 32 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
| 33 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 18.75v-3.75m16.5 3.75v-3.75m-14.25-3.75h12c.966 0 1.75-.784 1.75-1.75V8.25c0-.966-.784-1.75-1.75-1.75h-12c-.966 0-1.75.784-1.75 1.75v3.25c0 .966.784 1.75 1.75 1.75z" />
|
| 34 |
+
</svg>
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
+
export const InfoIcon: React.FC<IconProps> = (props) => (
|
| 38 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
| 39 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
| 40 |
+
</svg>
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
export const VolumeHighIcon: React.FC<IconProps> = (props) => (
|
| 44 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
| 45 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
|
| 46 |
+
</svg>
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
export const VolumeMuteIcon: React.FC<IconProps> = (props) => (
|
| 50 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
| 51 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 9.75L19.5 12m0 0l2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
|
| 52 |
+
</svg>
|
| 53 |
+
);
|