spotify controls

This commit is contained in:
yohlo
2025-09-12 11:08:21 -05:00
parent 9d92a8a510
commit 0169468114
15 changed files with 1655 additions and 28 deletions

View File

@@ -1,14 +1,17 @@
import { AuthProvider } from "@/contexts/auth-context"
import { SpotifyProvider } from "@/contexts/spotify-context"
import MantineProvider from "@/lib/mantine/mantine-provider"
import { Toaster } from "sonner"
const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<AuthProvider>
<MantineProvider>
<Toaster position='top-center' />
{children}
</MantineProvider>
<SpotifyProvider>
<MantineProvider>
<Toaster position='top-center' />
{children}
</MantineProvider>
</SpotifyProvider>
</AuthProvider>
)
}

View File

@@ -0,0 +1,2 @@
export { default as SpotifyControlsBar } from './spotify-controls-bar';
export { default as SpotifySheet } from './spotify-sheet';

View File

@@ -0,0 +1,179 @@
import { ActionIcon, Box, Group, Loader, Text, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
PlayIcon,
PauseIcon,
SkipBackIcon,
SkipForwardIcon,
GearIcon,
SpotifyLogoIcon,
} from '@phosphor-icons/react';
import { useSpotify } from '@/lib/spotify/hooks';
import { useAuth } from '@/contexts/auth-context';
import SpotifySheet from './spotify-sheet';
const SpotifyControlsBar = () => {
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const [sheetOpened, { open: openSheet, close: closeSheet }] = useDisclosure(false);
const {
isAuthenticated,
playbackState,
currentTrack,
isLoading,
error,
play,
pause,
skipNext,
skipPrevious,
activeDevice,
} = useSpotify();
if (!isAdmin) return null;
if (!isAuthenticated) {
return (
<Box py="md" mb="md">
<Group justify="center" gap="sm">
<SpotifyLogoIcon size={24} color="var(--mantine-color-green-6)" />
<Text size="sm" c="dimmed">
Connect Spotify to control music during tournaments
</Text>
<ActionIcon
variant="light"
color="green"
size="lg"
onClick={openSheet}
loading={isLoading}
>
<GearIcon size={18} />
</ActionIcon>
</Group>
<SpotifySheet opened={sheetOpened} onClose={closeSheet} />
</Box>
);
}
const isPlaying = playbackState?.is_playing || false;
const hasActiveDevice = !!activeDevice;
return (
<Box py="md" mb="md">
<Group justify="center" gap="md" align="center">
{currentTrack && (
<Group gap="sm" style={{ maxWidth: 400 }}>
{currentTrack.album.images[2] && (
<img
src={currentTrack.album.images[2].url}
alt={currentTrack.album.name}
style={{
width: 48,
height: 48,
borderRadius: 4,
flexShrink: 0
}}
/>
)}
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} truncate>
{currentTrack.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentTrack.artists.map(a => a.name).join(', ')}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentTrack.album.name}
</Text>
</Box>
</Group>
)}
<Group gap="xs">
<Tooltip label="Previous track">
<ActionIcon
variant="light"
size="lg"
onClick={skipPrevious}
disabled={!hasActiveDevice || isLoading}
loading={isLoading}
>
<SkipBackIcon size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={isPlaying ? 'Pause' : 'Play'}>
<ActionIcon
variant="filled"
color="green"
size="xl"
onClick={() => isPlaying ? pause() : play()}
disabled={!hasActiveDevice || isLoading}
loading={isLoading}
>
{isPlaying ? <PauseIcon size={24} /> : <PlayIcon size={24} />}
</ActionIcon>
</Tooltip>
<Tooltip label="Next track">
<ActionIcon
variant="light"
size="lg"
onClick={skipNext}
disabled={!hasActiveDevice || isLoading}
loading={isLoading}
>
<SkipForwardIcon size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Group gap="xs">
{activeDevice && (
<Box>
<Text size="xs" c="dimmed">
Playing on {activeDevice.name}
</Text>
</Box>
)}
<Tooltip label="Spotify settings">
<ActionIcon
variant="light"
color="green"
size="lg"
onClick={openSheet}
>
<GearIcon size={18} />
</ActionIcon>
</Tooltip>
</Group>
{isLoading && (
<Loader size="sm" color="green" />
)}
</Group>
{error && (
<Group justify="center" mt="xs">
<Text size="xs" c="red">
{error}
</Text>
</Group>
)}
{isAuthenticated && !hasActiveDevice && !isLoading && (
<Group justify="center" mt="xs">
<Text size="xs" c="orange">
No active device. Please select a device in settings.
</Text>
</Group>
)}
<SpotifySheet opened={sheetOpened} onClose={closeSheet} />
</Box>
);
};
export default SpotifyControlsBar;

View File

@@ -0,0 +1,197 @@
import {
ActionIcon,
Box,
Button,
Divider,
Group,
NativeSelect,
Stack,
Text,
Title
} from '@mantine/core';
import {
SpotifyLogoIcon,
DevicesIcon,
SignOutIcon,
ArrowsClockwiseIcon
} from '@phosphor-icons/react';
import { useSpotify } from '@/lib/spotify/hooks';
import { useAuth } from '@/contexts/auth-context';
import Sheet from '@/components/sheet/sheet';
interface SpotifySheetProps {
opened: boolean;
onClose: () => void;
}
const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const {
isAuthenticated,
login,
logout,
devices,
activeDevice,
currentTrack,
getDevices,
setActiveDevice,
isLoading,
error,
} = useSpotify();
if (!isAdmin) return null;
const handleDeviceChange = (deviceId: string) => {
if (deviceId && deviceId !== activeDevice?.id) {
setActiveDevice(deviceId);
}
};
const refreshDevices = () => {
getDevices();
};
const handleChange = (opened: boolean) => {
if (!opened) onClose();
};
return (
<Sheet
opened={opened}
onChange={handleChange}
>
<Stack gap="lg">
<Group gap="sm" justify="center">
<SpotifyLogoIcon size={32} color="var(--mantine-color-green-6)" />
<Title order={3}>Spotify Controls</Title>
</Group>
{!isAuthenticated ? (
<>
<Box>
<Text size="sm" c="dimmed" mb="md">
Connect your Spotify account
</Text>
</Box>
<Button
leftSection={<SpotifyLogoIcon size={20} />}
color="green"
size="lg"
onClick={login}
loading={isLoading}
fullWidth
>
Connect with Spotify
</Button>
</>
) : (
<>
{/* Current track display */}
{currentTrack && (
<>
<Box>
<Title order={5} mb="xs">Now Playing</Title>
<Group gap="md">
{currentTrack.album.images[2] && (
<img
src={currentTrack.album.images[2].url}
alt={currentTrack.album.name}
style={{ width: 64, height: 64, borderRadius: 4 }}
/>
)}
<Box flex={1}>
<Text fw={600} size="sm" truncate>
{currentTrack.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentTrack.artists.map(a => a.name).join(', ')}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentTrack.album.name}
</Text>
</Box>
</Group>
</Box>
<Divider />
</>
)}
<Box>
<Group justify="space-between" align="center" mb="sm">
<Title order={5}>
<Group gap="xs">
<DevicesIcon size={20} />
Select Device
</Group>
</Title>
<ActionIcon
variant="light"
size="sm"
onClick={refreshDevices}
loading={isLoading}
>
<ArrowsClockwiseIcon size={16} />
</ActionIcon>
</Group>
{devices.length > 0 ? (
<NativeSelect
value={activeDevice?.id || ''}
onChange={(event) => handleDeviceChange(event.currentTarget.value)}
data={[
{ value: '', label: 'Select a device...' },
...devices.map(device => ({
value: device.id,
label: `${device.name} ${device.is_active ? '(Active)' : ''} - ${device.type}`,
})),
]}
disabled={isLoading}
/>
) : (
<Text size="sm" c="dimmed">
No devices found.
</Text>
)}
{activeDevice && (
<Text size="xs" c="dimmed" mt="xs">
Active device: {activeDevice.name}
{activeDevice.volume_percent !== null &&
` (Volume: ${activeDevice.volume_percent}%)`
}
</Text>
)}
</Box>
{error && (
<>
<Divider />
<Box>
<Text size="sm" c="red">
{error}
</Text>
</Box>
</>
)}
<Divider />
<Button
leftSection={<SignOutIcon size={18} />}
variant="light"
color="red"
onClick={logout}
fullWidth
>
Disconnect Spotify
</Button>
</>
)}
</Stack>
</Sheet>
);
};
export default SpotifySheet;

View File

@@ -6,11 +6,6 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { teamInputSchema, teamUpdateSchema } from "./types";
import { logger } from "@/lib/logger";
export const listTeams = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(() => pbAdmin.listTeams())
);
export const listTeamInfos = createServerFn()
.middleware([superTokensFunctionMiddleware])
@@ -40,22 +35,10 @@ export const createTeam = createServerFn()
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
// Check if user is trying to create a team with themselves as a player
if (!isAdmin && !data.players.includes(userId)) {
throw new Error("You can only create teams that include yourself as a player");
}
// Additional validation: ensure user is not already on another team
if (!isAdmin) {
const userTeams = await pbAdmin.listTeams();
const existingTeam = userTeams.find(team =>
team.players.some(player => player.id === userId)
);
if (existingTeam) {
throw new Error(`You are already a member of team "${existingTeam.name}"`);
}
}
logger.info("Creating team", { name: data.name, userId, isAdmin });
return pbAdmin.createTeam(data);
})
@@ -88,10 +71,3 @@ export const updateTeam = createServerFn()
return pbAdmin.updateTeam(id, updates);
})
);
export const deleteTeam = createServerFn()
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.deleteTeam(teamId))
);