Merge pull request 'test' (#24) from test into main

Reviewed-on: #24
pull/49/head
Eric Paci 2025-08-10 17:36:02 +02:00
commit 01a655aacd
9 changed files with 576 additions and 364 deletions

View File

@ -23,18 +23,31 @@ public class App {
if (cfg.spotifyClientId == null || cfg.spotifyClientSecret == null || cfg.spotifyRedirectUri == null) { if (cfg.spotifyClientId == null || cfg.spotifyClientSecret == null || cfg.spotifyRedirectUri == null) {
throw new IllegalStateException("Spotify-Konfiguration fehlt: Bitte stelle sicher, dass ClientId, ClientSecret und RedirectUri gesetzt sind."); throw new IllegalStateException("Spotify-Konfiguration fehlt: Bitte stelle sicher, dass ClientId, ClientSecret und RedirectUri gesetzt sind.");
} }
GameService gs = new GameService();
SpotifyAuthService sas = new SpotifyAuthService( SpotifyAuthService sas = new SpotifyAuthService(
cfg.spotifyClientId, cfg.spotifyClientId,
cfg.spotifyClientSecret, cfg.spotifyClientSecret,
cfg.spotifyRedirectUri cfg.spotifyRedirectUri
); );
GameService gs = new GameService(sas);
Javalin app = Javalin.create(config -> { Javalin app = Javalin.create(config -> {
config.showJavalinBanner = false; config.showJavalinBanner = false;
config.staticFiles.add("/public", Location.CLASSPATH); config.staticFiles.add("/public", Location.CLASSPATH);
}).start(cfg.port); }).start(cfg.port);
// app.before(ctx -> {
// System.out.println("→ " + ctx.method() + " " + ctx.fullUrl());
// });
// app.after(ctx -> {
// String limit = ctx.header("x-rate-limit-limit");
// String remaining = ctx.header("x-rate-limit-remaining");
// String reset = ctx.header("x-rate-limit-reset");
// String retryAfter = ctx.header("Retry-After");
// System.out.printf("← %d | limit=%s remaining=%s reset=%s retry-after=%s%n",
// ctx.status().getCode(), limit, remaining, reset, retryAfter);
// });
app.exception(Exception.class, (e, ctx) -> { app.exception(Exception.class, (e, ctx) -> {
log.error("Unhandled error", e); log.error("Unhandled error", e);
ctx.status(500).json(Map.of("error", e.getMessage())); ctx.status(500).json(Map.of("error", e.getMessage()));
@ -57,7 +70,7 @@ public class App {
}); });
// WS-Handler // WS-Handler
GameWebSocketHandler wsHandler = new GameWebSocketHandler(gs, sas); GameWebSocketHandler wsHandler = new GameWebSocketHandler(gs);
// HTTP-Controller // HTTP-Controller
new GameController(app, gs, sas, wsHandler); new GameController(app, gs, sas, wsHandler);

View File

@ -1,6 +1,6 @@
package eric.Roullette.controller; package eric.Roullette.controller;
import com.fasterxml.jackson.core.type.TypeReference; // import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import io.javalin.Javalin; import io.javalin.Javalin;
@ -13,23 +13,25 @@ package eric.Roullette.controller;
import okhttp3.RequestBody; import okhttp3.RequestBody;
import okhttp3.Response; import okhttp3.Response;
import okhttp3.MediaType; import okhttp3.MediaType;
import com.fasterxml.jackson.databind.node.JsonNodeFactory; // import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode; // import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger; // import org.slf4j.Logger;
import org.slf4j.LoggerFactory; // import org.slf4j.LoggerFactory;
import java.awt.*; import java.awt.*;
import java.io.IOException; // import java.io.IOException;
import java.util.List; // import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; // import java.util.Objects;
import java.util.UUID; // import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit; // import java.util.concurrent.TimeUnit;
public class GameController { public class GameController {
private final GameService gameService; private final GameService gameService;
private final SpotifyAuthService authService; private final SpotifyAuthService authService;
private final GameWebSocketHandler webSocketHandler; private final GameWebSocketHandler webSocketHandler;
private final OkHttpClient httpClient = new OkHttpClient();
private String accessToken = "";
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(GameController.class); private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(GameController.class);
public GameController(Javalin app, GameService gs, SpotifyAuthService sas, GameWebSocketHandler wsHandler) { public GameController(Javalin app, GameService gs, SpotifyAuthService sas, GameWebSocketHandler wsHandler) {
@ -48,13 +50,24 @@ public class GameController {
ctx.status(400).result("username fehlt"); ctx.status(400).result("username fehlt");
return; return;
} }
var devices = authService.getDevices(username); // Diese Methode muss es im SpotifyAuthService geben var accessToken = authService.getAccessTokenForUser(username);
ctx.json(Map.of("devices", devices)); if (accessToken == null) {
ctx.status(401).result("Zugriffstoken fehlt oder ist ungültig");
return;
}
setToken(accessToken);
var devices = authService.getDevices(accessToken);
ctx.json(devices);
}); });
} }
private void createGame(Context ctx) { private void setToken(String accessToken) {
Map<String, Object> body = ctx.bodyAsClass(Map.class); this.accessToken = accessToken;
}
@SuppressWarnings("unchecked")
private void createGame(Context ctx) throws InterruptedException {
Map<String, Object> body = (Map<String, Object>) ctx.bodyAsClass(Map.class);
String user = (String) body.get("username"); String user = (String) body.get("username");
if (user == null || user.isBlank()) { if (user == null || user.isBlank()) {
ctx.status(400).result("username fehlt"); ctx.status(400).result("username fehlt");
@ -72,7 +85,7 @@ public class GameController {
ctx.json(Map.of("status", "ok", "gameId", gameId)); ctx.json(Map.of("status", "ok", "gameId", gameId));
} }
private void joinGame(Context ctx) { private void joinGame(Context ctx) throws InterruptedException {
Map<String, String> body = ctx.bodyAsClass(Map.class); Map<String, String> body = ctx.bodyAsClass(Map.class);
String user = body.get("username"); String user = body.get("username");
String gameId = body.get("gameId"); String gameId = body.get("gameId");
@ -118,6 +131,7 @@ public class GameController {
"scores", game.scores() "scores", game.scores()
)); ));
} }
// Java
private void playTrack(Context ctx) { private void playTrack(Context ctx) {
Map<String, String> body = ctx.bodyAsClass(Map.class); Map<String, String> body = ctx.bodyAsClass(Map.class);
String username = body.get("username"); String username = body.get("username");
@ -129,10 +143,8 @@ public class GameController {
} }
try { try {
String accessToken = authService.getAccessTokenForUser(username); //String accessToken = authService.getAccessTokenForUser(username);
OkHttpClient client = new OkHttpClient(); OkHttpClient client = httpClient;
// 1. Track-Details holen
String trackId = trackUri.split(":")[2]; String trackId = trackUri.split(":")[2];
Request getTrack = new Request.Builder() Request getTrack = new Request.Builder()
.url("https://api.spotify.com/v1/tracks/" + trackId) .url("https://api.spotify.com/v1/tracks/" + trackId)
@ -140,14 +152,14 @@ public class GameController {
.build(); .build();
try (Response trackResp = client.newCall(getTrack).execute()) { try (Response trackResp = client.newCall(getTrack).execute()) {
if (!trackResp.isSuccessful()) { if (!trackResp.isSuccessful()) {
ctx.status(500).result("Fehler beim Laden der Track-Details"); //ctx.status(trackResp.code()).result("Fehler beim Laden der Track-Details: " + trackResp.body().string());
return; return;
} }
assert trackResp.body() != null;
var node = new com.fasterxml.jackson.databind.ObjectMapper().readTree(trackResp.body().string()); var node = new com.fasterxml.jackson.databind.ObjectMapper().readTree(trackResp.body().string());
long durationMs = node.get("duration_ms").asLong(); long durationMs = node.get("duration_ms").asLong();
long startOffset = durationMs / 2; long startOffset = durationMs / 2;
// 2. Play-Request mit position_ms
ObjectNode jsonNode = JsonNodeFactory.instance.objectNode(); ObjectNode jsonNode = JsonNodeFactory.instance.objectNode();
jsonNode.putArray("uris").add(trackUri); jsonNode.putArray("uris").add(trackUri);
jsonNode.put("position_ms", startOffset); jsonNode.put("position_ms", startOffset);
@ -160,16 +172,23 @@ public class GameController {
.build(); .build();
try (Response playResp = client.newCall(playReq).execute()) { try (Response playResp = client.newCall(playReq).execute()) {
// ctx.status(playResp.code());
// ctx.header("Retry-After", playResp.header("Retry-After") != null ? playResp.header("Retry-After") : "");
// ctx.header("x-rate-limit-limit", playResp.header("x-rate-limit-limit"));
// ctx.header("x-rate-limit-remaining", playResp.header("x-rate-limit-remaining"));
// ctx.header("x-rate-limit-reset", playResp.header("x-rate-limit-reset"));
// ctx.result(playResp.body().string());
if (playResp.isSuccessful()) { if (playResp.isSuccessful()) {
ctx.status(204).result("Track erfolgreich abgespielt"); ctx.status(204).result("Track erfolgreich abgespielt");
} else { } else {
ctx.status(playResp.code()).result("Fehler: " + playResp.body().string()); //ctx.status(playResp.code()).result("Fehler beim Abspielen: " + playResp.body().string());
System.out.println("Fehler beim Abspielen des Tracks");
} }
} }
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Fehler beim Abspielen des Tracks", e); log.error("Fehler beim Abspielen des Tracks", e);
ctx.status(500).result("Interner Fehler"); ctx.status(500).result("Interner Fehler: " + e.getMessage());
} }
} }

View File

@ -1,35 +1,61 @@
package eric.Roullette.service; package eric.Roullette.service;
import eric.Roullette.dto.PlayersMessage; import eric.Roullette.dto.PlayersMessage;
import eric.Roullette.util.JsonUtil; import eric.Roullette.util.JsonUtil;
import io.javalin.websocket.WsContext; import io.javalin.websocket.WsContext;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.*; import java.util.*;
import java.util.concurrent.*; import java.util.concurrent.*;
public class GameService { public class GameService {
private static final Logger log = LoggerFactory.getLogger(GameService.class); private static final Logger log = LoggerFactory.getLogger(GameService.class);
private final SpotifyAuthService authService;
private final Map<String, Set<WsContext>> sessions = new ConcurrentHashMap<>(); private final Map<String, Set<WsContext>> sessions = new ConcurrentHashMap<>();
private final Map<String, Game> games = new ConcurrentHashMap<>(); private final Map<String, Game> games = new ConcurrentHashMap<>();
public record Game(String id, List<String> players, Map<String,Integer> scores,String currentOwner, public GameService(SpotifyAuthService authService) { // <-- Konstruktor
String currentSong,List<String> allTracks) { this.authService = authService;
}
public Map<String, List<String>> getTrackInfos(Map<String, List<String>> allPlayerTracks) {
// für jeden String Spieler in allPlayerTracks die Liste der Tracks an authservice übergeben
Map<String, List<String>> trackInfos = new ConcurrentHashMap<>();
for (Map.Entry<String, List<String>> entry : allPlayerTracks.entrySet()) {
String player = entry.getKey();
List<String> tracks = entry.getValue();
if (tracks.isEmpty()) continue; // Keine Tracks, skip
try {
List<String> trackInfo = authService.getTrackInfos(tracks);
trackInfos.put(player, trackInfo);
} catch (Exception e) {
log.error("Fehler beim Abrufen der Track-Infos für Spieler {}: {}", player, e.getMessage());
}
}
return trackInfos;
}
public record Game(String id, List<String> players, Map<String,Integer> scores, String currentOwner,
String currentSong, List<String> allTracks, Map<String, List<String>> playerTracks) {
public static Game create(String id) { public static Game create(String id) {
return new Game(id, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>()); return new Game(id, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>(), new ConcurrentHashMap<>());
} }
} }
public Game getOrCreateGame(String gameId) { public Game getOrCreateGame(String gameId) {
return games.computeIfAbsent(gameId, id -> Game.create(id)); return games.computeIfAbsent(gameId, Game::create);
} }
public void addPlayer(String gameId, String user) { public void addPlayer(String gameId, String user) throws InterruptedException {
Game g = getOrCreateGame(gameId); Game g = getOrCreateGame(gameId);
if (user != null && !g.players().contains(user)) { if (user != null && !g.players().contains(user)) {
g.players().add(user); g.players().add(user);
g.scores().putIfAbsent(user, 0); g.scores().putIfAbsent(user, 0);
// Songs einmalig laden und speichern
List<String> tracks = authService.getRecentTracks(user);
g.playerTracks().put(user, tracks);
} }
} }
@ -54,25 +80,31 @@ public class GameService {
} }
public void createGame(String gameId) { public void createGame(String gameId) {
Game game = new Game(gameId, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>()); Game game = new Game(gameId, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>(), new ConcurrentHashMap<>());
games.put(gameId, game); games.put(gameId, game);
} }
public boolean gameExists(String gameId) { public boolean gameExists(String gameId) {
return games.containsKey(gameId); return games.containsKey(gameId);
} }
public Game startRound(String gameId, List<String> uris) { public Game startRound(String gameId, List<String> uris) {
Game g = getOrCreateGame(gameId); Game g = getOrCreateGame(gameId);
if (g.players().isEmpty()) throw new IllegalStateException("No players"); if (g.players().isEmpty()) throw new IllegalStateException("No players");
String owner = g.players().get(ThreadLocalRandom.current().nextInt(g.players().size())); String owner = g.players().get(ThreadLocalRandom.current().nextInt(g.players().size()));
String song = uris.get(ThreadLocalRandom.current().nextInt(uris.size())); String song = uris.get(ThreadLocalRandom.current().nextInt(uris.size()));
Game updated = new Game(gameId, g.players(), g.scores(), owner, song, uris); Game updated = new Game(gameId, g.players(), g.scores(), owner, song, uris, g.playerTracks());
games.put(gameId, updated); games.put(gameId, updated);
return updated; return updated;
} }
// In GameService.java
public Set<WsContext> getSessions(String gameId) { public Set<WsContext> getSessions(String gameId) {
return sessions.getOrDefault(gameId, Collections.emptySet()); return sessions.getOrDefault(gameId, Collections.emptySet());
} }
// Map<Player, List<Track>>
} public Map<String, List<String>> getPlayerTracks(String gameId) {
Game game = games.get(gameId);
if (game == null) return Collections.emptyMap();
return game.playerTracks();
}
}

View File

@ -1,26 +1,26 @@
package eric.Roullette.service; package eric.Roullette.service;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.neovisionaries.i18n.CountryCode; import com.neovisionaries.i18n.CountryCode;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.Response; import okhttp3.Response;
import org.apache.hc.core5.http.ParseException; import org.apache.hc.core5.http.ParseException;
import se.michaelthelin.spotify.SpotifyApi; import se.michaelthelin.spotify.SpotifyApi;
import se.michaelthelin.spotify.SpotifyHttpManager; import se.michaelthelin.spotify.SpotifyHttpManager;
import se.michaelthelin.spotify.exceptions.SpotifyWebApiException; import se.michaelthelin.spotify.exceptions.SpotifyWebApiException;
import se.michaelthelin.spotify.model_objects.credentials.AuthorizationCodeCredentials; import se.michaelthelin.spotify.model_objects.credentials.AuthorizationCodeCredentials;
import se.michaelthelin.spotify.model_objects.specification.*; import se.michaelthelin.spotify.model_objects.specification.*;
import se.michaelthelin.spotify.requests.data.player.GetCurrentUsersRecentlyPlayedTracksRequest; import se.michaelthelin.spotify.requests.data.player.GetCurrentUsersRecentlyPlayedTracksRequest;
import se.michaelthelin.spotify.requests.data.library.GetUsersSavedTracksRequest; import se.michaelthelin.spotify.requests.data.library.GetUsersSavedTracksRequest;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import static com.neovisionaries.i18n.CountryCode.DE; import static com.neovisionaries.i18n.CountryCode.DE;
public class SpotifyAuthService { public class SpotifyAuthService {
private final String clientId; private final String clientId;
@ -36,6 +36,7 @@ public class SpotifyAuthService {
} }
public URI getAuthorizationUri(String user) { public URI getAuthorizationUri(String user) {
System.out.println("Erstelle Auth-URL für Benutzer: " + user);
// Temporäre API-Instanz nur für die Erstellung der Auth-URL // Temporäre API-Instanz nur für die Erstellung der Auth-URL
SpotifyApi tempApi = new SpotifyApi.Builder() SpotifyApi tempApi = new SpotifyApi.Builder()
.setClientId(clientId) .setClientId(clientId)
@ -68,7 +69,8 @@ public class SpotifyAuthService {
} }
public List<String> getRecentTracks(String user) { public List<String> getRecentTracks(String user) {
int limit = 50; System.out.println("Hole kürzlich gespielte Tracks für Benutzer: " + user);
int limit = 2;
SpotifyApi userApi = userApis.get(user); SpotifyApi userApi = userApis.get(user);
if (userApi == null) { if (userApi == null) {
@ -112,6 +114,7 @@ public class SpotifyAuthService {
} }
private List<String> getSavedTracks(String user, int limit, int offset) { private List<String> getSavedTracks(String user, int limit, int offset) {
System.out.println("Hole gespeicherte Tracks für Benutzer: " + user + ", Limit: " + limit + ", Offset: " + offset);
SpotifyApi userApi = userApis.get(user); SpotifyApi userApi = userApis.get(user);
if (userApi == null) { if (userApi == null) {
@ -143,10 +146,11 @@ public class SpotifyAuthService {
return Collections.emptyList(); return Collections.emptyList();
} }
} }
public List<String> getTrackInfos(List<String> allTracks) { public List<String> getTrackInfos(List<String> tracks) {
System.out.println("Hole Track-Infos für " + tracks.size() + " Tracks.");
//für jede URI den titel holen //für jede URI den titel holen
List<String> trackInfos = new ArrayList<>(); List<String> trackInfos = new ArrayList<>();
for (String uri : allTracks) { for (String uri : tracks) {
SpotifyApi userApi = userApis.values().stream().findFirst().orElse(null); SpotifyApi userApi = userApis.values().stream().findFirst().orElse(null);
if (userApi == null) { if (userApi == null) {
System.err.println("Kein SpotifyApi-Client gefunden."); System.err.println("Kein SpotifyApi-Client gefunden.");
@ -176,6 +180,7 @@ public class SpotifyAuthService {
public String getAccessTokenForUser(String username) { public String getAccessTokenForUser(String username) {
System.out.println("Hole AccessToken für Benutzer: " + username);
SpotifyApi userApi = userApis.get(username); SpotifyApi userApi = userApis.get(username);
if (userApi == null) { if (userApi == null) {
System.err.println("Kein SpotifyApi-Client für Benutzer gefunden: " + username); System.err.println("Kein SpotifyApi-Client für Benutzer gefunden: " + username);
@ -183,8 +188,8 @@ public class SpotifyAuthService {
} }
return userApi.getAccessToken(); return userApi.getAccessToken();
} }
public List<Map<String, Object>> getDevices(String username) { public List<Map<String, Object>> getDevices(String accessToken) {
String accessToken = getAccessTokenForUser(username); System.out.println("Hole Geräte für AccessToken: " + accessToken);
OkHttpClient client = new OkHttpClient(); OkHttpClient client = new OkHttpClient();
Request req = new Request.Builder() Request req = new Request.Builder()
.url("https://api.spotify.com/v1/me/player/devices") .url("https://api.spotify.com/v1/me/player/devices")

View File

@ -2,7 +2,7 @@ package eric.Roullette.websocket;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import eric.Roullette.service.GameService; import eric.Roullette.service.GameService;
import eric.Roullette.service.SpotifyAuthService; //import eric.Roullette.service.SpotifyAuthService;
import eric.Roullette.util.JsonUtil; import eric.Roullette.util.JsonUtil;
import io.javalin.websocket.WsConfig; import io.javalin.websocket.WsConfig;
import io.javalin.websocket.WsContext; import io.javalin.websocket.WsContext;
@ -17,14 +17,20 @@ import java.util.concurrent.ConcurrentHashMap;
public class GameWebSocketHandler { public class GameWebSocketHandler {
private final GameService service; private final GameService service;
private final SpotifyAuthService authService; //private final SpotifyAuthService authService;
// Spiel-ID → (Username → deren Guess) // Spiel-ID → (Username → deren Guess)
private final Map<String, Map<String, String>> currentGuesses = new ConcurrentHashMap<>(); private final Map<String, Map<String, String>> currentGuesses = new ConcurrentHashMap<>();
public GameWebSocketHandler(GameService gameService, SpotifyAuthService authService) { private final Map<String, List<String>> trackInfoCache = new ConcurrentHashMap<>();
private final Map<String, List<String>> allTracksCache = new ConcurrentHashMap<>();
//Map<gameId, Map<player, List<String>>
private Map<String, Map<String, List<String>>> playerTracksCache = new ConcurrentHashMap<>();
private Map<String, Map<String, List<String>>> playerTrackInfoCache = new ConcurrentHashMap<>();
public GameWebSocketHandler(GameService gameService) {
this.service = gameService; this.service = gameService;
this.authService = authService; //this.authService = authService;
} }
/** /**
@ -71,29 +77,51 @@ public class GameWebSocketHandler {
} }
case "requestPlayers" -> service.broadcastPlayers(gameId); case "requestPlayers" -> service.broadcastPlayers(gameId);
case "next-round" -> {
nextround(gameId);
}
case "start-round" -> { case "start-round" -> {
var game = service.getOrCreateGame(gameId); var currentGame = service.getOrCreateGame(gameId);
if (game.players().isEmpty()) return; if (currentGame.players().isEmpty()) return;
// Tracks pro Spieler sammeln
// Songs von allen Spielern sammeln Map<String, List<String>> allPlayerTracks = currentGame.playerTracks();
List<String> allTracks = new ArrayList<>(); // alle tracks sammeln
for (String player : game.players()) { List<String> allTracks = allPlayerTracks.values().stream()
allTracks.addAll(authService.getRecentTracks(player)); .flatMap(List::stream)
} .toList();
if (allTracks.isEmpty()) { System.out.println("AlltracksCache für Spiel " + gameId + " hat " + allTracks.size() + " Songs (rundenstart)");
// TODO: Fehler an Client senden, dass keine Songs da sind if (!allTracks.isEmpty()) {
return;
}
// Runde im Service starten, um Song und Owner zu setzen
service.startRound(gameId, allTracks); service.startRound(gameId, allTracks);
// Jetzt Broadcast mit den aktuellen Daten }
// Trackinfos für alle Spieler sammeln
Map<String, List<String>> allTrackInfos = service.getTrackInfos(allPlayerTracks);
// Cache für Trackinfos pro Spiel-ID
playerTrackInfoCache.put(gameId, allTrackInfos);
System.out.println("TrackInfosCache für Spiel " + gameId + " hat " + allTrackInfos.size() + " Spieler (rundenstart)");
broadcastRoundStart(gameId); broadcastRoundStart(gameId);
} }
} }
}); });
} }
public void nextround(String gameId) {
var game = service.getOrCreateGame(gameId);
if (game.players().isEmpty()) return;
// Songs von allen Spielern sammeln
List<String> allTracks = new ArrayList<>();
for (String player : game.players()) {
allTracks.addAll(game.playerTracks().getOrDefault(player, List.of()));
}
if (allTracks.isEmpty()) {
// TODO: Fehler an Client senden, dass keine Songs da sind
return;
}
// TODO funktionalität bei neu joinenden Spielern überprüfen
// Runde im Service starten, um Song und Owner zu setzen
service.startRound(gameId, allTracks);
// Jetzt Broadcast mit den aktuellen Daten
broadcastRoundStart(gameId);
}
// ----- Broadcast-Methoden ----- // ----- Broadcast-Methoden -----
/** Broadcastet den Runden-Start (Song + Optionen) an alle Clients. */ /** Broadcastet den Runden-Start (Song + Optionen) an alle Clients. */
@ -102,7 +130,9 @@ public class GameWebSocketHandler {
List<String> opts = game.players(); List<String> opts = game.players();
String songUri = game.currentSong(); String songUri = game.currentSong();
List<String> allTracks = game.allTracks(); List<String> allTracks = game.allTracks();
List<String> trackInfos = authService.getTrackInfos(allTracks); Map<String, List<String>> trackInfos = playerTrackInfoCache.get(gameId);
// Cache pro Spiel-ID nutzen
String msg = JsonUtil.toJson(Map.of( String msg = JsonUtil.toJson(Map.of(
"type", "round-start", "type", "round-start",
"ownerOptions", opts, "ownerOptions", opts,
@ -156,18 +186,8 @@ private void broadcastRoundResult(String gameId) {
// nächste Runde starten // nächste Runde starten
// ... // ...
new Thread(() -> { new Thread(() -> {
try { Thread.sleep(4000); } catch (InterruptedException ignored) {} try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
// Songs erneut sammeln nextround(gameId);
var currentGame = service.getOrCreateGame(gameId);
List<String> allTracks = new ArrayList<>();
for (String player : currentGame.players()) {
allTracks.addAll(authService.getRecentTracks(player));
}
if (!allTracks.isEmpty()) {
// Neue Runde starten
service.startRound(gameId, allTracks);
}
broadcastRoundStart(gameId);
}).start(); }).start();
} }
} }

View File

@ -2,42 +2,161 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotify Roulette Spiel</title> <title>Spotify Roulette Spiel</title>
<style> <style>
body { font-family: sans-serif; max-width: 600px; margin: auto; padding: 1rem; } :root {
#options button { margin: 0.5rem; } --primary-color: #1DB954;
#scoreboard { margin-top: 1rem; } --bg-light: #121212; /* vorher: #f2f2f2 */
--bg-dark: #000000; /* vorher: #121212 */
--text-light: #eaeaea; /* vorher: #ffffff */
--text-dark: #eaeaea; /* vorher: #333333 */
--accent: #191414;
--option-circle-size: 80%;
--option-max-size: 400px;
--option-radius: 120px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-light);
color: var(--text-dark);
display: grid;
grid-template-areas:
"header header"
"main aside"
"footer footer";
grid-template-columns: 3fr 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
header {
grid-area: header;
background: var(--primary-color);
color: var(--text-light);
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
main { grid-area: main; padding: 2rem; }
aside {
grid-area: aside;
background: var(--accent);
color: var(--text-light);
padding: 1.5rem;
overflow-y: auto;
}
footer {
grid-area: footer;
background: var(--accent);
color: var(--text-light);
text-align: center;
padding: 0.5rem;
font-size: 0.9rem;
}
.section-title {
margin-bottom: 1rem;
font-size: 1.2rem;
border-bottom: 2px solid var(--primary-color);
padding-bottom: 0.3rem;
}
ul { list-style: none; }
#controls, .round-controls { margin: 1rem 0; text-align: center; }
button {
background: var(--primary-color);
border: none;
color: var(--text-light);
padding: 0.6rem 1.2rem;
margin: 0.3rem;
font-size: 1rem;
border-radius: 20px;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) { background: #0c903a; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
/* Rundenbereich */
#roundArea {
margin-top: 1rem;
background: var(--bg-dark);
color: var(--text-light);
padding: 0.7rem 1rem;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
}
#songEmbed iframe { border-radius: 6px; }
:root {
/* 80% der Haupt-Container-Breite, max. 400px */
--option-circle-size: 80%;
--option-max-size: 400px;
}
#options {
position: relative;
width: var(--option-circle-size);
max-width: var(--option-max-size);
aspect-ratio: 1; /* Höhe = Breite */
margin: 2rem auto;
}
#options .player-option {
position: absolute;
width: 100px;
height: 40px;
left: 50%;
top: 50%;
transform-origin: center calc(-50% - 10px);
text-align: center;
padding: 0.5rem;
border-radius: 4px;
}
#options .player-option:nth-child(1) { transform: rotate(0deg) translate(0, var(--option-radius)); }
#options .player-option:nth-child(2) { transform: rotate(90deg) translate(0, var(--option-radius)); }
#options .player-option:nth-child(3) { transform: rotate(180deg) translate(0, var(--option-radius)); }
#options .player-option:nth-child(4) { transform: rotate(270deg) translate(0, var(--option-radius)); }
</style> </style>
</head> </head>
<body> <body>
<h1>Spotify Roulette</h1> <header>
<p>Spiel-Code: <strong id="gameId"></strong></p> <h1>Spotify Roulette</h1>
<div>Spiel-Code: <span id="gameId" class="game-code"></span></div>
<div id="songListArea" style="position:fixed; right:0; top:0; width:200px; height:100vh; overflow-y:auto; background:#f8f8f8; border-left:1px solid #ccc; padding:1rem; z-index:10;"> </header>
<h3>Geladene Songs</h3> <main>
<ul id="songList"></ul> <section>
</div> <div class="section-title">Teilnehmer</div>
<ul id="playersList"></ul>
<h2>Teilnehmer</h2> </section>
<ul id="playersList"></ul> <div id="controls">
<div id="controls">
<button id="startRound">Runde starten</button> <button id="startRound">Runde starten</button>
</div> </div>
<section id="roundArea" hidden>
<div id="roundArea" hidden> <div class="section-title">Wer hats gehört?</div>
<h2>Wer hats gehört?</h2>
<div id="songEmbed"></div> <div id="songEmbed"></div>
<div id="options"></div> <div class="round-controls" id="options">
<p id="result"></p> <!-- Buttons zu Auswahl verteilen: JS erzeugt <button class="player-option">Name</button> -->
</div> </div>
<div id="result"></div>
<h2>Scoreboard</h2> <button id="nextRound" hidden>Weiter</button>
<ul id="scoreboard"></ul> </section>
<section>
<div class="section-title">Scoreboard</div>
<ul id="scoreboard"></ul>
</section>
</main>
<aside>
<div class="section-title">Geladene Songs</div>
<ul id="songList"></ul>
<div id="deviceSelectArea"></div>
</aside>
<footer> Spotify Roulette alles ausser arbeiten </footer>
<script type="module" src="/js/utils.js"></script>
<script type="module" src="/js/start-round.js"></script>
<script type="module" src="/js/game.js"></script> <script type="module" src="/js/game.js"></script>
<div id="deviceSelectArea"></div>
<script type="module" src="/js/device-select.js"></script> <script type="module" src="/js/device-select.js"></script>
</body> </body>
</html> </html>

View File

@ -18,7 +18,7 @@ area.appendChild(select);
async function loadDevices() { async function loadDevices() {
select.innerHTML = ""; select.innerHTML = "";
const { devices } = await fetchJson(`/api/spotify/devices?username=${encodeURIComponent(username)}`); const devices = await fetchJson(`/api/spotify/devices?username=${encodeURIComponent(username)}`);
if (!devices.length) { if (!devices.length) {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.textContent = "Keine Geräte gefunden"; opt.textContent = "Keine Geräte gefunden";

View File

@ -60,7 +60,7 @@ function connectWebSocket() {
setupStartRound(socket); setupStartRound(socket);
}); });
socket.addEventListener("message", ({ data }) => { socket.addEventListener("message", async ({ data }) => {
console.log("WS-Rohdaten:", data); console.log("WS-Rohdaten:", data);
const msg = JSON.parse(data); const msg = JSON.parse(data);
@ -73,7 +73,7 @@ function connectWebSocket() {
window.location.reload(); window.location.reload();
break; break;
case "round-start": case "round-start":
handleRoundStart(msg); await handleRoundStart(msg);
break; break;
case "round-result": case "round-result":
handleRoundResult(msg); handleRoundResult(msg);
@ -98,7 +98,7 @@ function connectWebSocket() {
} }
connectWebSocket(); connectWebSocket();
// 8) Funktion zum Anzeigen einer neuen Runde // Zugriff auf DOM-Elemente
const startBtn = document.getElementById("startRound"); const startBtn = document.getElementById("startRound");
const roundArea = document.getElementById("roundArea"); const roundArea = document.getElementById("roundArea");
const songEmbed = document.getElementById("songEmbed"); const songEmbed = document.getElementById("songEmbed");
@ -106,8 +106,13 @@ const optionsDiv = document.getElementById("options");
const resultP = document.getElementById("result"); const resultP = document.getElementById("result");
const scoreboard = document.getElementById("scoreboard"); const scoreboard = document.getElementById("scoreboard");
function handleRoundStart({ ownerOptions, songUri, allTracks, trackInfos }) { // 8) Funktion zum Anzeigen einer neuen Runde
//let playLock = false;
async function handleRoundStart({ownerOptions, songUri, allTracks, trackInfos}) {
// UI zurücksetzen // UI zurücksetzen
//if (playLock) return; // Verhindert parallele Ausführung
//playLock = true;
resultP.textContent = ""; resultP.textContent = "";
optionsDiv.innerHTML = ""; optionsDiv.innerHTML = "";
songEmbed.innerHTML = ""; songEmbed.innerHTML = "";
@ -120,39 +125,52 @@ function handleRoundStart({ ownerOptions, songUri, allTracks, trackInfos }) {
width="100%" height="80" frameborder="0" width="100%" height="80" frameborder="0"
allow="encrypted-media"> allow="encrypted-media">
</iframe>`; </iframe>`;
// Song automatisch abspielen (sofern deviceId bereit und Username vorhanden)
if (window.playOnSpotify && typeof window.playOnSpotify === "function") { if (window.playOnSpotify && typeof window.playOnSpotify === "function") {
window.playOnSpotify(songUri, username); await window.playOnSpotify(songUri, username); // Warten bis fertig
} }
// Tipp-Buttons erzeugen //if (window.playOnSpotify && typeof window.playOnSpotify === "function") {
ownerOptions.forEach(user => { // window.playOnSpotify(songUri, username);
//}
// Dynamische Kreisverteilung der Buttons
// Warten, bis #options gerendert ist
setTimeout(() => {
const optsRect = optionsDiv.getBoundingClientRect();
const radius = Math.min(optsRect.width, optsRect.height) / 2 - 50; // 50px Abstand zum Rand
ownerOptions.forEach((user, i) => {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.textContent = user; btn.textContent = user;
btn.classList.add("player-option");
const angle = 360 * i / ownerOptions.length;
btn.style.transform = `rotate(${angle}deg) translateY(-${radius}px) rotate(${-angle}deg)`;
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: "guess", type: "guess",
username: username, username: username,
guess: user guess: user
})); }));
// Nach Tipp alle Buttons deaktivieren
optionsDiv.querySelectorAll("button").forEach(b => b.disabled = true); optionsDiv.querySelectorAll("button").forEach(b => b.disabled = true);
}); });
optionsDiv.append(btn); optionsDiv.appendChild(btn);
}); });
}, 0);
// UI anzeigen
startBtn.hidden = true; startBtn.hidden = true;
roundArea.hidden = false; roundArea.hidden = false;
const songList = document.getElementById("songList"); const songList = document.getElementById("songList");
songList.innerHTML = ""; songList.innerHTML = "";
if (Array.isArray(trackInfos)) { //trackinfos ist eine map bestehend aus aus Spielername und Liste von Track-Infos
trackInfos.forEach(trackInfo => { const userTracks = trackInfos?.[username] ?? [];
userTracks.forEach(trackInfo => {
const li = document.createElement("li"); const li = document.createElement("li");
li.textContent = trackInfo li.textContent = trackInfo;
songList.appendChild(li); songList.appendChild(li);
}); });
} //playLock = false;
} }
// 9) Funktion zum Anzeigen des Ergebnisses // 9) Funktion zum Anzeigen des Ergebnisses
@ -166,28 +184,31 @@ function renderScoreboard(scores) {
} }
function handleRoundResult({ scores, guesses, owner }) { function handleRoundResult({ scores, guesses, owner }) {
// Scoreboard updaten
renderScoreboard(scores); renderScoreboard(scores);
// Ergebnis für alle Spieler anzeigen resultP.innerHTML = "";
resultP.innerHTML = ""; // Vorher leeren
Object.entries(guesses).forEach(([user, guess]) => { Object.entries(guesses).forEach(([user, guess]) => {
const correct = guess === owner; const correct = guess === owner;
const icon = correct ? "✅" : "❌"; const icon = correct ? "✅" : "❌";
const msg = `${icon} ${user} hat auf ${guess} getippt${correct ? " (richtig!)" : " (falsch)"}`; const delta = correct ? "+3" : "-1";
const msg = `${icon} ${user} hat auf ${guess} getippt${correct ? " (richtig!)" : " (falsch)"} [${delta}]`;
const p = document.createElement("p"); const p = document.createElement("p");
p.textContent = msg; p.textContent = msg;
resultP.appendChild(p); resultP.appendChild(p);
}); });
// Nach kurzer Pause für die nächste Runde vorbereiten const nextBtn = document.getElementById("nextRound");
setTimeout(() => { nextBtn.hidden = false;
nextBtn.disabled = false;
nextBtn.onclick = () => {
socket.send(JSON.stringify({ type: "next-round" }));
nextBtn.hidden = true;
nextBtn.disabled = true;
resultP.textContent = ""; resultP.textContent = "";
startBtn.hidden = true; startBtn.hidden = true;
startBtn.disabled = true; startBtn.disabled = true;
roundArea.hidden = true; roundArea.hidden = true;
}, 3000); };
} }
function handleGameEnd({winner}) { function handleGameEnd({winner}) {
@ -196,13 +217,12 @@ function handleGameEnd({winner}) {
startBtn.hidden = false; startBtn.hidden = false;
roundArea.hidden = true; roundArea.hidden = true;
startBtn.disabled = false; startBtn.disabled = false;
// scoreboard leeren
scoreboard.innerHTML = ""; scoreboard.innerHTML = "";
}, 6000); }, 6000);
} }
// public/js/game.js // Spotify-Playback Funktion
// game.js
async function playOnSpotify(trackUri, username) { async function playOnSpotify(trackUri, username) {
const deviceId = document.getElementById("deviceSelect")?.value; const deviceId = document.getElementById("deviceSelect")?.value;
if (!deviceId) { if (!deviceId) {
@ -213,11 +233,7 @@ async function playOnSpotify(trackUri, username) {
const response = await fetch("/api/spotify/play", { const response = await fetch("/api/spotify/play", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ username, device_id: deviceId, track_uri: trackUri })
username,
device_id: deviceId,
track_uri: trackUri
})
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.text(); const error = await response.text();

View File

@ -1,4 +1,4 @@
// src/test/java/eric/roulette/service/GameServiceTest.java // src/test/java/eric/Roullette/service/GameServiceTest.java
package eric.Roullette.service; package eric.Roullette.service;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -9,7 +9,9 @@ class GameServiceTest {
@Test @Test
void testGetOrCreateGame() { void testGetOrCreateGame() {
GameService service = new GameService(); // Dummy-Parameter für SpotifyAuthService
SpotifyAuthService sas = new SpotifyAuthService("dummy", "dummy", "http://localhost");
GameService service = new GameService(sas);
// Erstes Mal anlegen // Erstes Mal anlegen
GameService.Game g1 = service.getOrCreateGame("g1"); GameService.Game g1 = service.getOrCreateGame("g1");
assertNotNull(g1); assertNotNull(g1);
@ -18,18 +20,4 @@ class GameServiceTest {
GameService.Game g2 = service.getOrCreateGame("g1"); GameService.Game g2 = service.getOrCreateGame("g1");
assertSame(g1, g2); assertSame(g1, g2);
} }
// @Test
// void testAddPlayerAndScores() {
// GameService service = new GameService();
// service.getOrCreateGame("g2"); // Spiel anlegen
// service.addPlayer("g2", "Alice"); // Spieler hinzufügen
// GameService.Game game = service.getOrCreateGame("g2");
// // Spieler-Liste korrekt
// assertTrue(game.players().contains("Alice"));
// assertEquals(1, game.players().size());
// // Score für neuen Spieler initial 0
// assertEquals(0, game.scores().get("Alice").intValue());
// // Duplikate vermeiden
// }
} }