diff --git a/src/app/routes/api/spotify/playback.ts b/src/app/routes/api/spotify/playback.ts index 682b753..ccb45b2 100644 --- a/src/app/routes/api/spotify/playback.ts +++ b/src/app/routes/api/spotify/playback.ts @@ -1,7 +1,6 @@ import { createServerFileRoute } from '@tanstack/react-start/server' import { SpotifyWebApiClient } from '@/lib/spotify/client' -// Helper function to get access token from cookies function getAccessTokenFromCookies(request: Request): string | null { const cookieHeader = request.headers.get('cookie') if (!cookieHeader) return null @@ -28,7 +27,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method } const body = await request.json() - const { action, deviceId, volumePercent } = body + const { action, deviceId, volumePercent, trackId, positionMs } = body const spotifyClient = new SpotifyWebApiClient(accessToken) @@ -36,6 +35,18 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method case 'play': await spotifyClient.play(deviceId) break + case 'playTrack': + if (!trackId) { + return new Response( + JSON.stringify({ error: 'trackId is required for playTrack action' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + await spotifyClient.playTrack(trackId, deviceId, positionMs) + break case 'pause': await spotifyClient.pause() break @@ -89,7 +100,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method } catch (error) { console.error('Playback control error:', error) - // Handle specific Spotify API errors if (error instanceof Error) { if (error.message.includes('NO_ACTIVE_DEVICE')) { return new Response( @@ -111,7 +121,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method ) } - // Log the full error details for debugging console.error('Full error details:', { message: error.message, stack: error.stack, @@ -129,7 +138,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method } }, - // GET endpoint for retrieving current playback state and devices GET: async ({ request }: { request: Request }) => { try { const accessToken = getAccessTokenFromCookies(request) @@ -144,7 +152,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method } const url = new URL(request.url) - const type = url.searchParams.get('type') // 'state' or 'devices' + const type = url.searchParams.get('type') const spotifyClient = new SpotifyWebApiClient(accessToken) @@ -167,7 +175,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method } ) } else { - // Return both by default const [devices, playbackState] = await Promise.all([ spotifyClient.getDevices(), spotifyClient.getPlaybackState(), diff --git a/src/contexts/spotify-context.tsx b/src/contexts/spotify-context.tsx index cd767f1..a072390 100644 --- a/src/contexts/spotify-context.tsx +++ b/src/contexts/spotify-context.tsx @@ -165,16 +165,16 @@ export const SpotifyProvider: React.FC = ({ children }) => { const play = useCallback(async (deviceId?: string) => { if (!authState.isAuthenticated) return; - + setIsLoading(true); setError(null); - + try { await makeSpotifyRequest('playback', { method: 'POST', body: JSON.stringify({ action: 'play', deviceId }), }); - + setTimeout(refreshPlaybackState, 500); } catch (error) { if (error instanceof Error && !error.message.includes('JSON')) { @@ -186,6 +186,29 @@ export const SpotifyProvider: React.FC = ({ children }) => { } }, [authState.isAuthenticated]); + const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => { + if (!authState.isAuthenticated) return; + + setIsLoading(true); + setError(null); + + try { + await makeSpotifyRequest('playback', { + method: 'POST', + body: JSON.stringify({ action: 'playTrack', trackId, deviceId, positionMs }), + }); + + setTimeout(refreshPlaybackState, 500); + } catch (error) { + if (error instanceof Error && !error.message.includes('JSON')) { + setError(error.message); + } + console.warn('Track playback action completed with warning:', error); + } finally { + setIsLoading(false); + } + }, [authState.isAuthenticated]); + const pause = useCallback(async () => { if (!authState.isAuthenticated) return; @@ -422,6 +445,7 @@ export const SpotifyProvider: React.FC = ({ children }) => { login, logout, play, + playTrack, pause, skipNext, skipPrevious, diff --git a/src/features/bracket/components/match-card.tsx b/src/features/bracket/components/match-card.tsx index 123bcad..14d3b40 100644 --- a/src/features/bracket/components/match-card.tsx +++ b/src/features/bracket/components/match-card.tsx @@ -1,8 +1,9 @@ -import { ActionIcon, Card, Flex, Text, Stack, Indicator } from "@mantine/core"; +import { ActionIcon, Card, Flex, Text, Indicator } from "@mantine/core"; import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react"; import React, { useCallback, useMemo } from "react"; import { MatchSlot } from "./match-slot"; import { Match } from "@/features/matches/types"; +import { Team } from "@/features/teams/types"; import { useSheet } from "@/hooks/use-sheet"; import { MatchForm } from "./match-form"; import Sheet from "@/components/sheet/sheet"; @@ -10,6 +11,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks"; import { endMatch, startMatch } from "@/features/matches/server"; import { tournamentKeys } from "@/features/tournaments/queries"; import { useQueryClient } from "@tanstack/react-query"; +import { useSpotifyPlayback } from "@/lib/spotify/hooks"; interface MatchCardProps { match: Match; @@ -24,6 +26,7 @@ export const MatchCard: React.FC = ({ }) => { const queryClient = useQueryClient(); const editSheet = useSheet(); + const { playTrack, pause } = useSpotifyPlayback(); const homeSlot = useMemo( () => ({ from: orders[match.home_from_lid], @@ -65,6 +68,8 @@ export const MatchCard: React.FC = ({ [showControls, match.status] ); + const hasWalkoutData = showControls && match.home && match.away && 'song_id' in match.home && 'song_id' in match.away; + const start = useServerMutation({ mutationFn: startMatch, successMessage: "Match started!", @@ -84,19 +89,13 @@ export const MatchCard: React.FC = ({ }, }); - const handleStart = useCallback(async () => { - await start.mutate({ - data: match.id, - }); - }, [match]); - const handleFormSubmit = useCallback( async (data: { home_cups: number; away_cups: number; ot_count: number; }) => { - await end.mutate({ + end.mutate({ data: { ...data, matchId: match.id, @@ -107,12 +106,14 @@ export const MatchCard: React.FC = ({ [match.id, editSheet] ); - const handleSpeakerClick = useCallback(() => { - if ("speechSynthesis" in window && match.home?.name && match.away?.name) { - const utterance = new SpeechSynthesisUtterance( - `${match.home.name} vs. ${match.away.name}` - ); + const speak = useCallback((text: string): Promise => { + return new Promise((resolve) => { + if (!("speechSynthesis" in window)) { + resolve(); + return; + } + const utterance = new SpeechSynthesisUtterance(text); const voices = window.speechSynthesis.getVoices(); const preferredVoice = @@ -130,9 +131,71 @@ export const MatchCard: React.FC = ({ utterance.volume = 0.8; utterance.pitch = 1.0; + utterance.onend = () => resolve(); + utterance.onerror = () => resolve(); + window.speechSynthesis.speak(utterance); + }); + }, []); + + const playTeamWalkout = useCallback((team: Team): Promise => { + return new Promise((resolve) => { + const songDuration = (team.song_end - team.song_start) * 1000; + + playTrack(team.song_id, undefined, team.song_start * 1000); + + setTimeout(async () => { + await pause(); + resolve(); + }, songDuration); + }); + }, [playTrack, pause]); + + const handleSpeakerClick = useCallback(async () => { + if (!hasWalkoutData || !match.home?.name || !match.away?.name) { + await speak(`${match.home?.name || "Home"} vs. ${match.away?.name || "Away"}`); + return; } - }, [match.home?.name, match.away?.name]); + + try { + const homeTeam = match.home as Team; + const awayTeam = match.away as Team; + + await playTeamWalkout(homeTeam); + await speak(homeTeam.name); + await speak("versus"); + await playTeamWalkout(awayTeam); + await speak(awayTeam.name); + await speak("have fun, good luck!"); + + } catch (error) { + console.warn('Walkout sequence error:', error); + await speak(`${match.home.name} vs. ${match.away.name}`); + } + }, [hasWalkoutData, match.home, match.away, speak, playTeamWalkout]); + + const handleStart = useCallback(async () => { + start.mutate({ + data: match.id, + }); + + // Play walkout sequence after starting the match + if (hasWalkoutData && match.home?.name && match.away?.name) { + try { + const homeTeam = match.home as Team; + const awayTeam = match.away as Team; + + await playTeamWalkout(homeTeam); + await speak(homeTeam.name); + await speak("versus"); + await playTeamWalkout(awayTeam); + await speak(awayTeam.name); + await speak("have fun, good luck!"); + } catch (error) { + console.warn('Auto-walkout sequence error:', error); + } + } + }, [match, start, hasWalkoutData, playTeamWalkout, speak]); return ( @@ -175,7 +238,7 @@ export const MatchCard: React.FC = ({ )} - {showControls && ( + {showControls && match.status !== "tbd" && ( = ({ )} + {showEditButton && ( - toServerResult(() => pbAdmin.getTournament(tournamentId)) - ); + .handler(async ({ data: tournamentId, context }) => { + const isAdmin = context.roles.includes("Admin"); + return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin)); + }); export const getCurrentTournament = createServerFn() .middleware([superTokensFunctionMiddleware]) diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts index 17f7001..5fca6a1 100644 --- a/src/lib/pocketbase/services/players.ts +++ b/src/lib/pocketbase/services/players.ts @@ -100,7 +100,7 @@ export function createPlayersService(pb: PocketBase) { expand: "tournament,home,away", }); - return result.map(transformMatch); + return result.map((match) => transformMatch(match)); }, async getUnenrolledPlayers(tournamentId: string): Promise { diff --git a/src/lib/pocketbase/services/teams.ts b/src/lib/pocketbase/services/teams.ts index 948d980..e15ae5d 100644 --- a/src/lib/pocketbase/services/teams.ts +++ b/src/lib/pocketbase/services/teams.ts @@ -100,7 +100,7 @@ export function createTeamsService(pb: PocketBase) { expand: "tournament,home,away", }); - return result.map(transformMatch); + return result.map((match) => transformMatch(match)); }, }; } diff --git a/src/lib/pocketbase/services/tournaments.ts b/src/lib/pocketbase/services/tournaments.ts index 05b3877..76ecec1 100644 --- a/src/lib/pocketbase/services/tournaments.ts +++ b/src/lib/pocketbase/services/tournaments.ts @@ -14,11 +14,11 @@ import { PlayerInfo } from "@/features/players/types"; export function createTournamentsService(pb: PocketBase) { return { - async getTournament(id: string): Promise { + async getTournament(id: string, isAdmin: boolean = false): Promise { const result = await pb.collection("tournaments").getOne(id, { expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players", }); - return transformTournament(result); + return transformTournament(result, isAdmin); }, async getMostRecentTournament(): Promise { const result = await pb diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts index 2c44ed6..626b837 100644 --- a/src/lib/pocketbase/util/transform-types.ts +++ b/src/lib/pocketbase/util/transform-types.ts @@ -27,7 +27,7 @@ export function transformTeamInfo(record: any): TeamInfo { }; } -export const transformMatch = (record: any): Match => { +export const transformMatch = (record: any, isAdmin: boolean = false): Match => { return { id: record.id, order: record.order, @@ -47,8 +47,8 @@ export const transformMatch = (record: any): Match => { is_losers_bracket: record.is_losers_bracket, status: record.status || "tbd", tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament, - home: record.expand?.home ? transformTeamInfo(record.expand.home) : record.home, - away: record.expand?.away ? transformTeamInfo(record.expand.away) : record.away, + home: record.expand?.home ? (isAdmin ? transformTeam(record.expand.home) : transformTeamInfo(record.expand.home)) : record.home, + away: record.expand?.away ? (isAdmin ? transformTeam(record.expand.away) : transformTeamInfo(record.expand.away)) : record.away, created: record.created, updated: record.updated, home_seed: record.home_seed, @@ -135,20 +135,20 @@ export function transformTeam(record: any): Team { }; } -export function transformTournament(record: any): Tournament { +export function transformTournament(record: any, isAdmin: boolean = false): Tournament { const teams = record.expand?.teams ?.sort((a: any, b: any) => new Date(a.created) < new Date(b.created) ? -1 : 0 ) - ?.map(transformTeamInfo) ?? []; + ?.map(isAdmin ? transformTeam : transformTeamInfo) ?? []; const matches = record.expand?.matches ?.sort((a: any, b: any) => a.lid - b.lid ? -1 : 0 ) - ?.map(transformMatch) ?? []; + ?.map((match: any) => transformMatch(match, isAdmin)) ?? []; return { id: record.id, diff --git a/src/lib/spotify/client.ts b/src/lib/spotify/client.ts index 04e0a97..e4775b3 100644 --- a/src/lib/spotify/client.ts +++ b/src/lib/spotify/client.ts @@ -96,6 +96,17 @@ export class SpotifyWebApiClient { }); } + async playTrack(trackId: string, deviceId?: string, positionMs?: number): Promise { + const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play'; + await this.request(endpoint, { + method: 'PUT', + body: JSON.stringify({ + uris: [`spotify:track:${trackId}`], + position_ms: positionMs || 0, + }), + }); + } + async pause(): Promise { await this.request('/me/player/pause', { method: 'PUT', diff --git a/src/lib/spotify/hooks.ts b/src/lib/spotify/hooks.ts index b55f1d9..d526e9f 100644 --- a/src/lib/spotify/hooks.ts +++ b/src/lib/spotify/hooks.ts @@ -22,6 +22,7 @@ export const useSpotifyPlayback = () => { playbackState, currentTrack, play, + playTrack, pause, skipNext, skipPrevious, @@ -29,11 +30,12 @@ export const useSpotifyPlayback = () => { refreshPlaybackState, isLoading, } = useSpotify(); - + return { playbackState, currentTrack, play, + playTrack, pause, skipNext, skipPrevious, diff --git a/src/lib/spotify/types.ts b/src/lib/spotify/types.ts index e7eb576..692cbc5 100644 --- a/src/lib/spotify/types.ts +++ b/src/lib/spotify/types.ts @@ -90,6 +90,7 @@ export interface SpotifyContextType extends SpotifyAuthState { logout: () => void; play: (deviceId?: string) => Promise; + playTrack: (trackId: string, deviceId?: string, positionMs?: number) => Promise; pause: () => Promise; skipNext: () => Promise; skipPrevious: () => Promise;