Compare commits
3 commits
2ca3de3c75
...
ff733b9774
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff733b9774 | ||
|
|
7609feead0 | ||
|
|
96286eb424 |
14 changed files with 381 additions and 75 deletions
|
|
@ -20,6 +20,11 @@ export const defaultSdk = SpotifyApi.withClientCredentials(
|
||||||
);
|
);
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
|
account: {
|
||||||
|
accountLinking: {
|
||||||
|
trustedProviders: ["spotify"],
|
||||||
|
},
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
lastSyncAt: {
|
lastSyncAt: {
|
||||||
|
|
@ -40,6 +45,7 @@ export const auth = betterAuth({
|
||||||
scope: [
|
scope: [
|
||||||
"streaming",
|
"streaming",
|
||||||
"user-read-playback-state",
|
"user-read-playback-state",
|
||||||
|
"user-read-private",
|
||||||
"user-read-currently-playing",
|
"user-read-currently-playing",
|
||||||
"user-modify-playback-state",
|
"user-modify-playback-state",
|
||||||
"playlist-read-private",
|
"playlist-read-private",
|
||||||
|
|
@ -48,7 +54,6 @@ export const auth = betterAuth({
|
||||||
"user-top-read",
|
"user-top-read",
|
||||||
"user-read-recently-played",
|
"user-read-recently-played",
|
||||||
"user-library-read",
|
"user-library-read",
|
||||||
// "user-personalized",
|
|
||||||
"user-read-email",
|
"user-read-email",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export type QuizRound = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuizState = {
|
export type QuizState = {
|
||||||
status: "running" | "results";
|
status: "running" | "review" | "results";
|
||||||
workflowId: string | null;
|
workflowId: string | null;
|
||||||
questionIndex: number;
|
questionIndex: number;
|
||||||
currentQuestion: Question | null;
|
currentQuestion: Question | null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { Question, QuizRound } from "../../party-types";
|
import type { Question, QuizRound } from "../../party-types";
|
||||||
|
import { buildAudioMetadataQuestion } from "../audio-question-generator";
|
||||||
import { buildNumericQuestion } from "../numeric-question-generator";
|
import { buildNumericQuestion } from "../numeric-question-generator";
|
||||||
import {
|
import {
|
||||||
buildMemberOptions,
|
buildMemberOptions,
|
||||||
|
|
@ -266,6 +267,50 @@ describe("question generation", () => {
|
||||||
expect(question).toBeNull();
|
expect(question).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds audio questions with fewer than four real options", async () => {
|
||||||
|
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.99);
|
||||||
|
const db = createFakeDb(null);
|
||||||
|
const analytics = {
|
||||||
|
storyClusters: [
|
||||||
|
{
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
name: "Shared Track One",
|
||||||
|
artists: [{ name: "Shared Artist One" }],
|
||||||
|
albumName: "Shared Album One",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Shared Track Two",
|
||||||
|
artists: [{ name: "Shared Artist Two" }],
|
||||||
|
albumName: "Shared Album Two",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
artists: [
|
||||||
|
{ name: "Shared Artist One" },
|
||||||
|
{ name: "Shared Artist Two" },
|
||||||
|
],
|
||||||
|
genres: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupSummary: {
|
||||||
|
mostSharedGenres: [],
|
||||||
|
},
|
||||||
|
} as PartyAnalytics;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const question = await buildAudioMetadataQuestion(db, analytics, 0, []);
|
||||||
|
|
||||||
|
expect(question).not.toBeNull();
|
||||||
|
expect(question?.type).toBe("choice");
|
||||||
|
if (question?.type === "choice") {
|
||||||
|
expect(question.options).toHaveLength(2);
|
||||||
|
expect(question.text).toContain("Shared Track Two");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
randomSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("selects a fresh party song when the current one was already used", async () => {
|
it("selects a fresh party song when the current one was already used", async () => {
|
||||||
const db = createSongFallbackDb([
|
const db = createSongFallbackDb([
|
||||||
makeSong("track-1", "spotify:track:one", "One"),
|
makeSong("track-1", "spotify:track:one", "One"),
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
|
||||||
export function pickQuestionCandidate<T extends QuestionLike>(
|
export function pickQuestionCandidate<T extends QuestionLike>(
|
||||||
candidates: QuestionCandidate<T>[],
|
candidates: QuestionCandidate<T>[],
|
||||||
history: QuizRound[],
|
history: QuizRound[],
|
||||||
index: number,
|
_index: number,
|
||||||
): T | null {
|
): T | null {
|
||||||
const seenKeys = new Set<string>();
|
const seenKeys = new Set<string>();
|
||||||
const seenSubjects = new Set<string>();
|
const seenSubjects = new Set<string>();
|
||||||
|
|
@ -165,7 +165,7 @@ export function pickQuestionCandidate<T extends QuestionLike>(
|
||||||
|
|
||||||
if (fresh.length === 0) return null;
|
if (fresh.length === 0) return null;
|
||||||
const pool = fresh;
|
const pool = fresh;
|
||||||
const candidate = pool[index % pool.length];
|
const candidate = pickRandom(pool);
|
||||||
if (!candidate) return null;
|
if (!candidate) return null;
|
||||||
return {
|
return {
|
||||||
...candidate.question,
|
...candidate.question,
|
||||||
|
|
@ -510,7 +510,8 @@ export function buildOrderedOptions(
|
||||||
const options = uniqueStrings(
|
const options = uniqueStrings(
|
||||||
values.filter((value): value is string => isUsableText(value)),
|
values.filter((value): value is string => isUsableText(value)),
|
||||||
);
|
);
|
||||||
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
const optionCount = getAvailableOptionCount(options.length, desiredCount);
|
||||||
|
return optionCount ? options.slice(0, optionCount) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOptionsWithCorrect(
|
export function buildOptionsWithCorrect(
|
||||||
|
|
@ -523,7 +524,8 @@ export function buildOptionsWithCorrect(
|
||||||
correct,
|
correct,
|
||||||
...candidates.filter((c) => isUsableText(c) && c !== correct),
|
...candidates.filter((c) => isUsableText(c) && c !== correct),
|
||||||
]);
|
]);
|
||||||
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
const optionCount = getAvailableOptionCount(options.length, desiredCount);
|
||||||
|
return optionCount ? options.slice(0, optionCount) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickRandom<T>(items: T[]): T | null {
|
export function pickRandom<T>(items: T[]): T | null {
|
||||||
|
|
@ -532,6 +534,14 @@ export function pickRandom<T>(items: T[]): T | null {
|
||||||
return items[index] ?? null;
|
return items[index] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAvailableOptionCount(
|
||||||
|
availableCount: number,
|
||||||
|
desiredCount: number,
|
||||||
|
): number | null {
|
||||||
|
if (availableCount < 2) return null;
|
||||||
|
return Math.min(availableCount, desiredCount);
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentLeader(
|
export function getCurrentLeader(
|
||||||
quizState: { scores: Record<string, number> },
|
quizState: { scores: Record<string, number> },
|
||||||
members: PartyQuestionMember[],
|
members: PartyQuestionMember[],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
import Elysia from "elysia";
|
import Elysia from "elysia";
|
||||||
|
|
||||||
import { auth, betterAuthElysia } from "../auth";
|
import { auth, betterAuthElysia } from "../auth";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { account } from "../db/schema";
|
||||||
|
|
||||||
|
const SPOTIFY_PLAYBACK_SCOPES = ["streaming", "user-read-private"];
|
||||||
|
|
||||||
|
function parseScopes(scope: string | null | undefined) {
|
||||||
|
return new Set(scope?.split(/[\s,]+/).filter(Boolean) ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
export const spotifyRoutes = new Elysia()
|
export const spotifyRoutes = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
|
|
@ -8,6 +17,28 @@ export const spotifyRoutes = new Elysia()
|
||||||
app.get(
|
app.get(
|
||||||
"/token",
|
"/token",
|
||||||
async ({ user, set }) => {
|
async ({ user, set }) => {
|
||||||
|
const [spotifyAccount] = await db
|
||||||
|
.select({ scope: account.scope })
|
||||||
|
.from(account)
|
||||||
|
.where(
|
||||||
|
and(eq(account.userId, user.id), eq(account.providerId, "spotify")),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const grantedScopes = parseScopes(spotifyAccount?.scope);
|
||||||
|
const missingScopes = SPOTIFY_PLAYBACK_SCOPES.filter(
|
||||||
|
(scope) => !grantedScopes.has(scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingScopes.length > 0) {
|
||||||
|
set.status = 403;
|
||||||
|
return {
|
||||||
|
error: "Spotify playback permission required",
|
||||||
|
code: "SPOTIFY_RELINK_REQUIRED",
|
||||||
|
missingScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const token = await auth.api.getAccessToken({
|
const token = await auth.api.getAccessToken({
|
||||||
body: {
|
body: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
import { partyAnalysisWorkflow } from "./party-analysis";
|
import { partyAnalysisWorkflow } from "./party-analysis";
|
||||||
|
|
||||||
const TOTAL_QUESTIONS = 5;
|
const TOTAL_QUESTIONS = 5;
|
||||||
|
const REVIEW_DURATION_MS = 5000;
|
||||||
|
|
||||||
export const quizQueue = new WorkflowQueue("quiz_queue", {
|
export const quizQueue = new WorkflowQueue("quiz_queue", {
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
|
|
@ -62,6 +63,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
|
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
|
||||||
|
quizState.status = "running";
|
||||||
quizState.questionIndex = i;
|
quizState.questionIndex = i;
|
||||||
|
|
||||||
const question = await QuizWorkflow.generateQuestion(
|
const question = await QuizWorkflow.generateQuestion(
|
||||||
|
|
@ -135,9 +137,13 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
|
|
||||||
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
|
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
|
||||||
quizState.scores[playerId] = (quizState.scores[playerId] ?? 0) + gained;
|
quizState.scores[playerId] = (quizState.scores[playerId] ?? 0) + gained;
|
||||||
|
const response = quizState.answers[playerId];
|
||||||
|
if (response) response.pointsGained = gained;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
quizState.status = "review";
|
||||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
|
await DBOS.sleep(REVIEW_DURATION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quiz complete
|
// Quiz complete
|
||||||
|
|
@ -183,7 +189,11 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noAnswers = round.responses
|
||||||
|
.filter((response) => response.selected < 0)
|
||||||
|
.map((response): [string, number] => [response.playerId, 0]);
|
||||||
const ordered = round.responses
|
const ordered = round.responses
|
||||||
|
.filter((response) => response.selected >= 0)
|
||||||
.map((response) => ({
|
.map((response) => ({
|
||||||
response,
|
response,
|
||||||
distance: Math.abs(
|
distance: Math.abs(
|
||||||
|
|
@ -192,6 +202,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.distance - b.distance);
|
.sort((a, b) => a.distance - b.distance);
|
||||||
|
if (ordered.length === 0) return noAnswers;
|
||||||
|
|
||||||
const groups: Array<{ distance: number; responses: QuizResponse[] }> = [];
|
const groups: Array<{ distance: number; responses: QuizResponse[] }> = [];
|
||||||
for (const item of ordered) {
|
for (const item of ordered) {
|
||||||
|
|
@ -204,13 +215,13 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groups.length <= 1) {
|
if (groups.length <= 1) {
|
||||||
return round.responses.map((response) => [
|
return ordered.map(({ response }) => [
|
||||||
response.playerId,
|
response.playerId,
|
||||||
round.question.points,
|
round.question.points,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups.flatMap((group, index) => {
|
const scoredAnswers = groups.flatMap((group, index) => {
|
||||||
const factor = (groups.length - index - 1) / (groups.length - 1);
|
const factor = (groups.length - index - 1) / (groups.length - 1);
|
||||||
const gained = Math.round(round.question.points * factor);
|
const gained = Math.round(round.question.points * factor);
|
||||||
return group.responses.map((response): [string, number] => [
|
return group.responses.map((response): [string, number] => [
|
||||||
|
|
@ -218,6 +229,8 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
gained,
|
gained,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return [...scoredAnswers, ...noAnswers];
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ type QuizQuestion =
|
||||||
};
|
};
|
||||||
|
|
||||||
type QuizState = {
|
type QuizState = {
|
||||||
status: "running" | "results";
|
status: "running" | "review" | "results";
|
||||||
questionIndex: number;
|
questionIndex: number;
|
||||||
currentQuestion: QuizQuestion | null;
|
currentQuestion: QuizQuestion | null;
|
||||||
};
|
};
|
||||||
|
|
@ -171,7 +171,7 @@ function connectApiSocket() {
|
||||||
|
|
||||||
function toDeviceQuestionData(quizData: QuizState): DeviceQuestionData | null {
|
function toDeviceQuestionData(quizData: QuizState): DeviceQuestionData | null {
|
||||||
if (!quizData.currentQuestion) return null;
|
if (!quizData.currentQuestion) return null;
|
||||||
if (quizData.status === "results") return null;
|
if (quizData.status !== "running") return null;
|
||||||
const question = quizData.currentQuestion;
|
const question = quizData.currentQuestion;
|
||||||
const q_type =
|
const q_type =
|
||||||
question.type === "choice"
|
question.type === "choice"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useParty } from "#/hooks/use-party";
|
import { useParty } from "#/hooks/use-party";
|
||||||
import { Question } from "./question";
|
import { Question } from "./question";
|
||||||
|
import { QuestionReview } from "./question-review";
|
||||||
import { Results } from "./results";
|
import { Results } from "./results";
|
||||||
|
import { SpotifyPlayback } from "./spotify-playback";
|
||||||
|
|
||||||
export function PartyView() {
|
export function PartyView() {
|
||||||
const { party } = useParty();
|
const { party } = useParty();
|
||||||
|
|
@ -8,7 +10,19 @@ export function PartyView() {
|
||||||
|
|
||||||
switch (party.data.status) {
|
switch (party.data.status) {
|
||||||
case "running":
|
case "running":
|
||||||
return <Question />;
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SpotifyPlayback />
|
||||||
|
<Question />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "review":
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SpotifyPlayback />
|
||||||
|
<QuestionReview />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case "results":
|
case "results":
|
||||||
return <Results />;
|
return <Results />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
web/src/components/party/question-review.tsx
Normal file
99
web/src/components/party/question-review.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemHeader,
|
||||||
|
ItemTitle,
|
||||||
|
} from "#/components/ui/item";
|
||||||
|
import { Section, SectionTitle } from "#/components/ui/section";
|
||||||
|
import { useParty } from "#/hooks/use-party";
|
||||||
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
|
type PartyQuestion = NonNullable<
|
||||||
|
NonNullable<ReturnType<typeof useParty>["party"]>["data"]["currentQuestion"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
function formatAnswer(question: PartyQuestion, selected: number) {
|
||||||
|
if (selected < 0) return "No answer";
|
||||||
|
if (question.type === "numeric") return selected.toString();
|
||||||
|
return question.options[selected] ?? `Option ${selected + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCorrectAnswer(question: PartyQuestion) {
|
||||||
|
if (question.type === "numeric") return question.correct.toString();
|
||||||
|
return question.options[question.correct] ?? `Option ${question.correct + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOutcome(
|
||||||
|
question: PartyQuestion,
|
||||||
|
response: NonNullable<
|
||||||
|
NonNullable<ReturnType<typeof useParty>["party"]>["data"]["answers"][string]
|
||||||
|
> | null,
|
||||||
|
) {
|
||||||
|
if (!response || response.selected < 0) return "No answer";
|
||||||
|
if (response.correct)
|
||||||
|
return question.type === "numeric" ? "Exact" : "Correct";
|
||||||
|
if (question.type === "numeric" && response.pointsGained > 0)
|
||||||
|
return "Closest";
|
||||||
|
return "Incorrect";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuestionReview() {
|
||||||
|
const { party, members } = useParty();
|
||||||
|
const question = party?.data?.currentQuestion;
|
||||||
|
if (!party?.data || !question) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>
|
||||||
|
Question {party.data.questionIndex + 1} Results
|
||||||
|
</SectionTitle>
|
||||||
|
<ItemGroup>
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{question.text}</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
Correct answer: {formatCorrectAnswer(question)}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
{members.map((member) => {
|
||||||
|
const response = party.data.answers[member.userId];
|
||||||
|
const didAnswer = response != null && response.selected >= 0;
|
||||||
|
const isCorrect = response?.correct === true;
|
||||||
|
const outcome = formatOutcome(question, response ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={member.id}
|
||||||
|
variant={isCorrect ? "outline" : "default"}
|
||||||
|
className={cn(
|
||||||
|
isCorrect && "border-green-500/50 bg-green-500/10",
|
||||||
|
response && !isCorrect && "border-red-500/30 bg-red-500/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle>{member.user?.name ?? "Unknown player"}</ItemTitle>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
+{response?.pointsGained ?? 0}
|
||||||
|
</div>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemDescription>
|
||||||
|
{didAnswer && response
|
||||||
|
? formatAnswer(
|
||||||
|
question,
|
||||||
|
response.selectedValue ?? response.selected,
|
||||||
|
)
|
||||||
|
: "No answer"}
|
||||||
|
{didAnswer ? ` - ${outcome}` : ""}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ItemGroup>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
ItemHeader,
|
ItemHeader,
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "#/components/ui/item";
|
} from "#/components/ui/item";
|
||||||
import { Label } from "#/components/ui/label";
|
|
||||||
import {
|
import {
|
||||||
Progress,
|
Progress,
|
||||||
ProgressLabel,
|
ProgressLabel,
|
||||||
|
|
@ -18,9 +17,7 @@ import {
|
||||||
import { Section, SectionTitle } from "#/components/ui/section";
|
import { Section, SectionTitle } from "#/components/ui/section";
|
||||||
import { Slider } from "#/components/ui/slider";
|
import { Slider } from "#/components/ui/slider";
|
||||||
import { Spinner } from "#/components/ui/spinner";
|
import { Spinner } from "#/components/ui/spinner";
|
||||||
import { Switch } from "#/components/ui/switch";
|
|
||||||
import { useParty } from "#/hooks/use-party";
|
import { useParty } from "#/hooks/use-party";
|
||||||
import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
|
|
||||||
import { useUser } from "#/hooks/user";
|
import { useUser } from "#/hooks/user";
|
||||||
import { client } from "#/lib/eden";
|
import { client } from "#/lib/eden";
|
||||||
|
|
||||||
|
|
@ -57,17 +54,6 @@ export function Question() {
|
||||||
const [selectedValue, setSelectedValue] = useState<number | null>(null);
|
const [selectedValue, setSelectedValue] = useState<number | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const question = party?.data?.currentQuestion;
|
const question = party?.data?.currentQuestion;
|
||||||
const spotifyTrackUri =
|
|
||||||
question?.song?.platform === "spotify" && question.song.platform_id
|
|
||||||
? `spotify:track:${question.song.platform_id}`
|
|
||||||
: null;
|
|
||||||
const {
|
|
||||||
enabled: spotifyEnabled,
|
|
||||||
setEnabled: setSpotifyEnabled,
|
|
||||||
status: spotifyStatus,
|
|
||||||
error: spotifyError,
|
|
||||||
isLoading: spotifyIsLoading,
|
|
||||||
} = useSpotifyPlayer(spotifyTrackUri);
|
|
||||||
const questionStartTimestamp = question?.startTimestamp ?? null;
|
const questionStartTimestamp = question?.startTimestamp ?? null;
|
||||||
const questionAnnouncement = question
|
const questionAnnouncement = question
|
||||||
? getQuestionAnnouncement(question)
|
? getQuestionAnnouncement(question)
|
||||||
|
|
@ -174,49 +160,17 @@ export function Question() {
|
||||||
</Item>
|
</Item>
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemHeader>
|
<Progress value={progressValue}>
|
||||||
<Progress value={progressValue}>
|
<ProgressLabel>
|
||||||
<ProgressLabel>
|
{hasResponded ? "You responded" : "Waiting on your response"}
|
||||||
{hasResponded ? "You responded" : "Waiting on your response"}
|
</ProgressLabel>
|
||||||
</ProgressLabel>
|
<ProgressValue>{() => timeLeft}</ProgressValue>
|
||||||
<ProgressValue>{() => timeLeft}</ProgressValue>
|
</Progress>
|
||||||
</Progress>
|
|
||||||
</ItemHeader>
|
|
||||||
<ItemDescription>
|
<ItemDescription>
|
||||||
{answeredCount} / {members.length} responses
|
{answeredCount} / {members.length} responses
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
<Item variant="muted">
|
|
||||||
<ItemContent>
|
|
||||||
<ItemHeader>
|
|
||||||
<Label htmlFor="spotify-playback" className="cursor-pointer">
|
|
||||||
Audio playback
|
|
||||||
</Label>
|
|
||||||
<Switch
|
|
||||||
id="spotify-playback"
|
|
||||||
checked={spotifyEnabled}
|
|
||||||
disabled={spotifyIsLoading || !spotifyTrackUri}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
void setSpotifyEnabled(checked === true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ItemHeader>
|
|
||||||
<ItemDescription>
|
|
||||||
{question.hideSongTitle
|
|
||||||
? "Listen closely and guess the song."
|
|
||||||
: (question.song?.name ??
|
|
||||||
"This question has no associated song.")}
|
|
||||||
{!spotifyTrackUri
|
|
||||||
? " Spotify playback is unavailable for this question."
|
|
||||||
: spotifyError
|
|
||||||
? ` ${spotifyError}`
|
|
||||||
: spotifyStatus === "loading"
|
|
||||||
? " Connecting Spotify..."
|
|
||||||
: null}
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
{question.type === "numeric" ? (
|
{question.type === "numeric" ? (
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
|
|
|
||||||
94
web/src/components/party/spotify-playback.tsx
Normal file
94
web/src/components/party/spotify-playback.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "#/components/ui/button";
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemHeader,
|
||||||
|
} from "#/components/ui/item";
|
||||||
|
import { Label } from "#/components/ui/label";
|
||||||
|
import { Switch } from "#/components/ui/switch";
|
||||||
|
import { useParty } from "#/hooks/use-party";
|
||||||
|
import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
|
||||||
|
import { authClient } from "#/lib/auth-client";
|
||||||
|
|
||||||
|
export function SpotifyPlayback() {
|
||||||
|
const { party } = useParty();
|
||||||
|
const [isRelinkingSpotify, setIsRelinkingSpotify] = useState(false);
|
||||||
|
const question = party?.data?.currentQuestion;
|
||||||
|
const spotifyTrackUri =
|
||||||
|
question?.song?.platform === "spotify" && question.song.platform_id
|
||||||
|
? `spotify:track:${question.song.platform_id}`
|
||||||
|
: null;
|
||||||
|
const {
|
||||||
|
enabled: spotifyEnabled,
|
||||||
|
setEnabled: setSpotifyEnabled,
|
||||||
|
status: spotifyStatus,
|
||||||
|
error: spotifyError,
|
||||||
|
requiresRelink: spotifyRequiresRelink,
|
||||||
|
isLoading: spotifyIsLoading,
|
||||||
|
} = useSpotifyPlayer(spotifyTrackUri);
|
||||||
|
|
||||||
|
if (!question) return null;
|
||||||
|
|
||||||
|
async function handleSpotifyRelink() {
|
||||||
|
setIsRelinkingSpotify(true);
|
||||||
|
try {
|
||||||
|
await authClient.linkSocial({
|
||||||
|
provider: "spotify",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRelinkingSpotify(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemGroup>
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemHeader>
|
||||||
|
<Label htmlFor="spotify-playback" className="cursor-pointer">
|
||||||
|
Audio playback
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="spotify-playback"
|
||||||
|
checked={spotifyEnabled}
|
||||||
|
disabled={spotifyIsLoading || !spotifyTrackUri}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
void setSpotifyEnabled(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemDescription>
|
||||||
|
{question.hideSongTitle
|
||||||
|
? "Listen closely and guess the song."
|
||||||
|
: (question.song?.name ??
|
||||||
|
"This question has no associated song.")}
|
||||||
|
{!spotifyTrackUri
|
||||||
|
? " Spotify playback is unavailable for this question."
|
||||||
|
: spotifyError
|
||||||
|
? ` ${spotifyError}`
|
||||||
|
: spotifyStatus === "loading"
|
||||||
|
? " Connecting Spotify..."
|
||||||
|
: null}
|
||||||
|
{spotifyRequiresRelink ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleSpotifyRelink()}
|
||||||
|
disabled={isRelinkingSpotify}
|
||||||
|
>
|
||||||
|
{isRelinkingSpotify
|
||||||
|
? "Opening Spotify..."
|
||||||
|
: "Grant Spotify playback permission"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,29 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { client } from "#/lib/eden";
|
import { client } from "#/lib/eden";
|
||||||
|
|
||||||
const SPOTIFY_SDK_SRC = "https://sdk.scdn.co/spotify-player.js";
|
const SPOTIFY_SDK_SRC = "https://sdk.scdn.co/spotify-player.js";
|
||||||
|
export const SPOTIFY_PLAYBACK_SCOPES = ["streaming"] as const;
|
||||||
|
|
||||||
|
class SpotifyRelinkRequiredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Spotify needs playback permission before audio can start.");
|
||||||
|
this.name = "SpotifyRelinkRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRelinkRequiredResponse(
|
||||||
|
value: unknown,
|
||||||
|
): value is { code: "SPOTIFY_RELINK_REQUIRED" } {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"code" in value &&
|
||||||
|
value.code === "SPOTIFY_RELINK_REQUIRED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRelinkRequiredError(error: unknown) {
|
||||||
|
return error instanceof SpotifyRelinkRequiredError;
|
||||||
|
}
|
||||||
|
|
||||||
let sdkPromise: Promise<void> | null = null;
|
let sdkPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
|
@ -37,7 +60,13 @@ function loadSpotifySdk(): Promise<void> {
|
||||||
|
|
||||||
async function fetchSpotifyAccessToken(): Promise<string> {
|
async function fetchSpotifyAccessToken(): Promise<string> {
|
||||||
const { data, error } = await client.api.spotify.token.get();
|
const { data, error } = await client.api.spotify.token.get();
|
||||||
if (error || !data || !("accessToken" in data)) {
|
if (error) {
|
||||||
|
if (isRelinkRequiredResponse(error.value)) {
|
||||||
|
throw new SpotifyRelinkRequiredError();
|
||||||
|
}
|
||||||
|
throw new Error("Spotify access token is unavailable.");
|
||||||
|
}
|
||||||
|
if (!data || !("accessToken" in data)) {
|
||||||
throw new Error("Spotify access token is unavailable.");
|
throw new Error("Spotify access token is unavailable.");
|
||||||
}
|
}
|
||||||
if (!data.accessToken) {
|
if (!data.accessToken) {
|
||||||
|
|
@ -63,6 +92,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
||||||
const [enabled, setEnabledState] = useState(false);
|
const [enabled, setEnabledState] = useState(false);
|
||||||
const [status, setStatus] = useState<PlaybackStatus>("idle");
|
const [status, setStatus] = useState<PlaybackStatus>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [requiresRelink, setRequiresRelink] = useState(false);
|
||||||
|
|
||||||
const playerRef = useRef<SpotifyPlayer | null>(null);
|
const playerRef = useRef<SpotifyPlayer | null>(null);
|
||||||
const playerPromiseRef = useRef<Promise<SpotifyPlayer> | null>(null);
|
const playerPromiseRef = useRef<Promise<SpotifyPlayer> | null>(null);
|
||||||
|
|
@ -86,6 +116,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setRequiresRelink(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
playerPromiseRef.current = (async () => {
|
playerPromiseRef.current = (async () => {
|
||||||
|
|
@ -125,6 +156,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
const message = parseSpotifyError(error_);
|
const message = parseSpotifyError(error_);
|
||||||
fail(message);
|
fail(message);
|
||||||
|
setRequiresRelink(isRelinkRequiredError(error_));
|
||||||
setError(message);
|
setError(message);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
cb("");
|
cb("");
|
||||||
|
|
@ -242,6 +274,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRequiresRelink(false);
|
||||||
setStatus("playing");
|
setStatus("playing");
|
||||||
},
|
},
|
||||||
[ensurePlayer],
|
[ensurePlayer],
|
||||||
|
|
@ -252,6 +285,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
||||||
enabledRef.current = next;
|
enabledRef.current = next;
|
||||||
setEnabledState(next);
|
setEnabledState(next);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setRequiresRelink(false);
|
||||||
|
|
||||||
if (!next) {
|
if (!next) {
|
||||||
setStatus("paused");
|
setStatus("paused");
|
||||||
|
|
@ -270,6 +304,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
}
|
}
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
|
setRequiresRelink(isRelinkRequiredError(error_));
|
||||||
setError(parseSpotifyError(error_));
|
setError(parseSpotifyError(error_));
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
|
|
@ -288,6 +323,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
||||||
try {
|
try {
|
||||||
await playTrack(trackUri);
|
await playTrack(trackUri);
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
|
setRequiresRelink(isRelinkRequiredError(error_));
|
||||||
setError(parseSpotifyError(error_));
|
setError(parseSpotifyError(error_));
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
|
|
@ -308,6 +344,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
||||||
setEnabled,
|
setEnabled,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
|
requiresRelink,
|
||||||
isLoading: status === "loading",
|
isLoading: status === "loading",
|
||||||
isReady: status === "ready" || status === "playing",
|
isReady: status === "ready" || status === "playing",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { treaty } from "@elysiajs/eden";
|
import { treaty } from "@elysiajs/eden";
|
||||||
import type { App } from "../../../api/src/index";
|
import type { App } from "../../../api/src/index";
|
||||||
|
|
||||||
export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
|
const apiBaseUrl =
|
||||||
// export const client = treaty<App>(
|
import.meta.env.VITE_BETTER_AUTH_URL ||
|
||||||
// process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
|
(typeof window === "undefined"
|
||||||
// {},
|
? "http://127.0.0.1:3000"
|
||||||
// );
|
: window.location.origin);
|
||||||
|
|
||||||
|
export const client = treaty<App>(apiBaseUrl, {
|
||||||
|
fetch: { credentials: "include" },
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ const config = defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
devtools(),
|
devtools(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
tanstackStart({
|
tanstackStart({
|
||||||
spa: {
|
spa: {
|
||||||
enabled: true
|
enabled: true,
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
viteReact({
|
viteReact({
|
||||||
babel: {
|
babel: {
|
||||||
|
|
@ -22,8 +22,8 @@ const config = defineConfig({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: ["aura.rpi1.danbulant.cloud"],
|
allowedHosts: ["aura.rpi1.danbulant.cloud"],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:4000",
|
target: "http://localhost:4000",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue