play walkout songs

This commit is contained in:
yohlo
2025-09-22 17:57:29 -05:00
parent ae934e77f4
commit b93ce38d48
12 changed files with 152 additions and 42 deletions

View File

@@ -1,7 +1,6 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createServerFileRoute } from '@tanstack/react-start/server'
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from '@/lib/spotify/client'
// Helper function to get access token from cookies
function getAccessTokenFromCookies(request: Request): string | null { function getAccessTokenFromCookies(request: Request): string | null {
const cookieHeader = request.headers.get('cookie') const cookieHeader = request.headers.get('cookie')
if (!cookieHeader) return null if (!cookieHeader) return null
@@ -28,7 +27,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
} }
const body = await request.json() const body = await request.json()
const { action, deviceId, volumePercent } = body const { action, deviceId, volumePercent, trackId, positionMs } = body
const spotifyClient = new SpotifyWebApiClient(accessToken) const spotifyClient = new SpotifyWebApiClient(accessToken)
@@ -36,6 +35,18 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
case 'play': case 'play':
await spotifyClient.play(deviceId) await spotifyClient.play(deviceId)
break 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': case 'pause':
await spotifyClient.pause() await spotifyClient.pause()
break break
@@ -89,7 +100,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
} catch (error) { } catch (error) {
console.error('Playback control error:', error) console.error('Playback control error:', error)
// Handle specific Spotify API errors
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes('NO_ACTIVE_DEVICE')) { if (error.message.includes('NO_ACTIVE_DEVICE')) {
return new Response( 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:', { console.error('Full error details:', {
message: error.message, message: error.message,
stack: error.stack, 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 }) => { GET: async ({ request }: { request: Request }) => {
try { try {
const accessToken = getAccessTokenFromCookies(request) const accessToken = getAccessTokenFromCookies(request)
@@ -144,7 +152,7 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
} }
const url = new URL(request.url) 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) const spotifyClient = new SpotifyWebApiClient(accessToken)
@@ -167,7 +175,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/playback').method
} }
) )
} else { } else {
// Return both by default
const [devices, playbackState] = await Promise.all([ const [devices, playbackState] = await Promise.all([
spotifyClient.getDevices(), spotifyClient.getDevices(),
spotifyClient.getPlaybackState(), spotifyClient.getPlaybackState(),

View File

@@ -165,16 +165,16 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const play = useCallback(async (deviceId?: string) => { const play = useCallback(async (deviceId?: string) => {
if (!authState.isAuthenticated) return; if (!authState.isAuthenticated) return;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
await makeSpotifyRequest('playback', { await makeSpotifyRequest('playback', {
method: 'POST', method: 'POST',
body: JSON.stringify({ action: 'play', deviceId }), body: JSON.stringify({ action: 'play', deviceId }),
}); });
setTimeout(refreshPlaybackState, 500); setTimeout(refreshPlaybackState, 500);
} catch (error) { } catch (error) {
if (error instanceof Error && !error.message.includes('JSON')) { if (error instanceof Error && !error.message.includes('JSON')) {
@@ -186,6 +186,29 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
} }
}, [authState.isAuthenticated]); }, [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 () => { const pause = useCallback(async () => {
if (!authState.isAuthenticated) return; if (!authState.isAuthenticated) return;
@@ -422,6 +445,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
login, login,
logout, logout,
play, play,
playTrack,
pause, pause,
skipNext, skipNext,
skipPrevious, skipPrevious,

View File

@@ -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 { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { MatchSlot } from "./match-slot"; import { MatchSlot } from "./match-slot";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { Team } from "@/features/teams/types";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { MatchForm } from "./match-form"; import { MatchForm } from "./match-form";
import Sheet from "@/components/sheet/sheet"; 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 { endMatch, startMatch } from "@/features/matches/server";
import { tournamentKeys } from "@/features/tournaments/queries"; import { tournamentKeys } from "@/features/tournaments/queries";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -24,6 +26,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const editSheet = useSheet(); const editSheet = useSheet();
const { playTrack, pause } = useSpotifyPlayback();
const homeSlot = useMemo( const homeSlot = useMemo(
() => ({ () => ({
from: orders[match.home_from_lid], from: orders[match.home_from_lid],
@@ -65,6 +68,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
[showControls, match.status] [showControls, match.status]
); );
const hasWalkoutData = showControls && match.home && match.away && 'song_id' in match.home && 'song_id' in match.away;
const start = useServerMutation({ const start = useServerMutation({
mutationFn: startMatch, mutationFn: startMatch,
successMessage: "Match started!", successMessage: "Match started!",
@@ -84,19 +89,13 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}, },
}); });
const handleStart = useCallback(async () => {
await start.mutate({
data: match.id,
});
}, [match]);
const handleFormSubmit = useCallback( const handleFormSubmit = useCallback(
async (data: { async (data: {
home_cups: number; home_cups: number;
away_cups: number; away_cups: number;
ot_count: number; ot_count: number;
}) => { }) => {
await end.mutate({ end.mutate({
data: { data: {
...data, ...data,
matchId: match.id, matchId: match.id,
@@ -107,12 +106,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
[match.id, editSheet] [match.id, editSheet]
); );
const handleSpeakerClick = useCallback(() => { const speak = useCallback((text: string): Promise<void> => {
if ("speechSynthesis" in window && match.home?.name && match.away?.name) { return new Promise((resolve) => {
const utterance = new SpeechSynthesisUtterance( if (!("speechSynthesis" in window)) {
`${match.home.name} vs. ${match.away.name}` resolve();
); return;
}
const utterance = new SpeechSynthesisUtterance(text);
const voices = window.speechSynthesis.getVoices(); const voices = window.speechSynthesis.getVoices();
const preferredVoice = const preferredVoice =
@@ -130,9 +131,71 @@ export const MatchCard: React.FC<MatchCardProps> = ({
utterance.volume = 0.8; utterance.volume = 0.8;
utterance.pitch = 1.0; utterance.pitch = 1.0;
utterance.onend = () => resolve();
utterance.onerror = () => resolve();
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
});
}, []);
const playTeamWalkout = useCallback((team: Team): Promise<void> => {
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 ( return (
<Flex direction="row" align="center" justify="end" gap={8}> <Flex direction="row" align="center" justify="end" gap={8}>
@@ -175,7 +238,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Text> </Text>
)} )}
{showControls && ( {showControls && match.status !== "tbd" && (
<ActionIcon <ActionIcon
pos="absolute" pos="absolute"
bottom={-2} bottom={-2}
@@ -210,6 +273,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Flex> </Flex>
)} )}
{showEditButton && ( {showEditButton && (
<Flex direction="column" justify="center" align="center"> <Flex direction="column" justify="center" align="center">
<ActionIcon <ActionIcon

View File

@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { TeamInfo } from "../teams/types"; import { TeamInfo, Team } from "../teams/types";
import { TournamentInfo } from "../tournaments/types"; import { TournamentInfo } from "../tournaments/types";
export type MatchStatus = "tbd" | "ready" | "started" | "ended"; export type MatchStatus = "tbd" | "ready" | "started" | "ended";
@@ -23,8 +23,8 @@ export interface Match {
is_losers_bracket: boolean; is_losers_bracket: boolean;
status: MatchStatus; status: MatchStatus;
tournament: TournamentInfo; tournament: TournamentInfo;
home?: TeamInfo; home?: TeamInfo | Team;
away?: TeamInfo; away?: TeamInfo | Team;
created: string; created: string;
updated: string; updated: string;
home_seed?: number; home_seed?: number;

View File

@@ -32,9 +32,10 @@ export const updateTournament = createServerFn()
export const getTournament = createServerFn() export const getTournament = createServerFn()
.validator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => .handler(async ({ data: tournamentId, context }) => {
toServerResult(() => pbAdmin.getTournament(tournamentId)) const isAdmin = context.roles.includes("Admin");
); return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin));
});
export const getCurrentTournament = createServerFn() export const getCurrentTournament = createServerFn()
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])

View File

@@ -100,7 +100,7 @@ export function createPlayersService(pb: PocketBase) {
expand: "tournament,home,away", expand: "tournament,home,away",
}); });
return result.map(transformMatch); return result.map((match) => transformMatch(match));
}, },
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> { async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {

View File

@@ -100,7 +100,7 @@ export function createTeamsService(pb: PocketBase) {
expand: "tournament,home,away", expand: "tournament,home,away",
}); });
return result.map(transformMatch); return result.map((match) => transformMatch(match));
}, },
}; };
} }

View File

@@ -14,11 +14,11 @@ import { PlayerInfo } from "@/features/players/types";
export function createTournamentsService(pb: PocketBase) { export function createTournamentsService(pb: PocketBase) {
return { return {
async getTournament(id: string): Promise<Tournament> { async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> {
const result = await pb.collection("tournaments").getOne(id, { 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", 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<Tournament> { async getMostRecentTournament(): Promise<Tournament> {
const result = await pb const result = await pb

View File

@@ -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 { return {
id: record.id, id: record.id,
order: record.order, order: record.order,
@@ -47,8 +47,8 @@ export const transformMatch = (record: any): Match => {
is_losers_bracket: record.is_losers_bracket, is_losers_bracket: record.is_losers_bracket,
status: record.status || "tbd", status: record.status || "tbd",
tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament, tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament,
home: record.expand?.home ? transformTeamInfo(record.expand.home) : record.home, home: record.expand?.home ? (isAdmin ? transformTeam(record.expand.home) : transformTeamInfo(record.expand.home)) : record.home,
away: record.expand?.away ? transformTeamInfo(record.expand.away) : record.away, away: record.expand?.away ? (isAdmin ? transformTeam(record.expand.away) : transformTeamInfo(record.expand.away)) : record.away,
created: record.created, created: record.created,
updated: record.updated, updated: record.updated,
home_seed: record.home_seed, 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 = const teams =
record.expand?.teams record.expand?.teams
?.sort((a: any, b: any) => ?.sort((a: any, b: any) =>
new Date(a.created) < new Date(b.created) ? -1 : 0 new Date(a.created) < new Date(b.created) ? -1 : 0
) )
?.map(transformTeamInfo) ?? []; ?.map(isAdmin ? transformTeam : transformTeamInfo) ?? [];
const matches = const matches =
record.expand?.matches record.expand?.matches
?.sort((a: any, b: any) => ?.sort((a: any, b: any) =>
a.lid - b.lid ? -1 : 0 a.lid - b.lid ? -1 : 0
) )
?.map(transformMatch) ?? []; ?.map((match: any) => transformMatch(match, isAdmin)) ?? [];
return { return {
id: record.id, id: record.id,

View File

@@ -96,6 +96,17 @@ export class SpotifyWebApiClient {
}); });
} }
async playTrack(trackId: string, deviceId?: string, positionMs?: number): Promise<void> {
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<void> { async pause(): Promise<void> {
await this.request('/me/player/pause', { await this.request('/me/player/pause', {
method: 'PUT', method: 'PUT',

View File

@@ -22,6 +22,7 @@ export const useSpotifyPlayback = () => {
playbackState, playbackState,
currentTrack, currentTrack,
play, play,
playTrack,
pause, pause,
skipNext, skipNext,
skipPrevious, skipPrevious,
@@ -29,11 +30,12 @@ export const useSpotifyPlayback = () => {
refreshPlaybackState, refreshPlaybackState,
isLoading, isLoading,
} = useSpotify(); } = useSpotify();
return { return {
playbackState, playbackState,
currentTrack, currentTrack,
play, play,
playTrack,
pause, pause,
skipNext, skipNext,
skipPrevious, skipPrevious,

View File

@@ -90,6 +90,7 @@ export interface SpotifyContextType extends SpotifyAuthState {
logout: () => void; logout: () => void;
play: (deviceId?: string) => Promise<void>; play: (deviceId?: string) => Promise<void>;
playTrack: (trackId: string, deviceId?: string, positionMs?: number) => Promise<void>;
pause: () => Promise<void>; pause: () => Promise<void>;
skipNext: () => Promise<void>; skipNext: () => Promise<void>;
skipPrevious: () => Promise<void>; skipPrevious: () => Promise<void>;