test #24
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,110 @@
|
||||||
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 Map<String, Set<WsContext>> sessions = new ConcurrentHashMap<>();
|
private final SpotifyAuthService authService;
|
||||||
private final Map<String, Game> games = new ConcurrentHashMap<>();
|
private final Map<String, Set<WsContext>> sessions = 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 static Game create(String id) {
|
|
||||||
return new Game(id, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
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 Game getOrCreateGame(String gameId) {
|
|
||||||
return games.computeIfAbsent(gameId, id -> Game.create(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addPlayer(String gameId, String user) {
|
public record Game(String id, List<String> players, Map<String,Integer> scores, String currentOwner,
|
||||||
Game g = getOrCreateGame(gameId);
|
String currentSong, List<String> allTracks, Map<String, List<String>> playerTracks) {
|
||||||
if (user != null && !g.players().contains(user)) {
|
public static Game create(String id) {
|
||||||
g.players().add(user);
|
return new Game(id, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>(), new ConcurrentHashMap<>());
|
||||||
g.scores().putIfAbsent(user, 0);
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public Game getOrCreateGame(String gameId) {
|
||||||
|
return games.computeIfAbsent(gameId, Game::create);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addPlayer(String gameId, String user) throws InterruptedException {
|
||||||
|
Game g = getOrCreateGame(gameId);
|
||||||
|
if (user != null && !g.players().contains(user)) {
|
||||||
|
g.players().add(user);
|
||||||
|
g.scores().putIfAbsent(user, 0);
|
||||||
|
// Songs einmalig laden und speichern
|
||||||
|
List<String> tracks = authService.getRecentTracks(user);
|
||||||
|
g.playerTracks().put(user, tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void registerSession(String gameId, WsContext ctx) {
|
||||||
|
sessions
|
||||||
|
.computeIfAbsent(gameId, id -> ConcurrentHashMap.newKeySet())
|
||||||
|
.add(ctx);
|
||||||
|
broadcastPlayers(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeSession(String gameId, WsContext ctx) {
|
||||||
|
sessions.getOrDefault(gameId, Collections.emptySet()).remove(ctx);
|
||||||
|
broadcastPlayers(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void broadcastPlayers(String gameId) {
|
||||||
|
Game game = games.get(gameId);
|
||||||
|
if (game == null) return;
|
||||||
|
PlayersMessage msg = new PlayersMessage(new ArrayList<>(game.players()));
|
||||||
|
sessions.getOrDefault(gameId, Collections.emptySet())
|
||||||
|
.forEach(ctx -> ctx.send(JsonUtil.toJson(msg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void createGame(String gameId) {
|
||||||
|
Game game = new Game(gameId, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>(), new ConcurrentHashMap<>());
|
||||||
|
games.put(gameId, game);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean gameExists(String gameId) {
|
||||||
|
return games.containsKey(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Game startRound(String gameId, List<String> uris) {
|
||||||
|
Game g = getOrCreateGame(gameId);
|
||||||
|
if (g.players().isEmpty()) throw new IllegalStateException("No players");
|
||||||
|
String owner = g.players().get(ThreadLocalRandom.current().nextInt(g.players().size()));
|
||||||
|
String song = uris.get(ThreadLocalRandom.current().nextInt(uris.size()));
|
||||||
|
Game updated = new Game(gameId, g.players(), g.scores(), owner, song, uris, g.playerTracks());
|
||||||
|
games.put(gameId, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<WsContext> getSessions(String gameId) {
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void registerSession(String gameId, WsContext ctx) {
|
|
||||||
sessions
|
|
||||||
.computeIfAbsent(gameId, id -> ConcurrentHashMap.newKeySet())
|
|
||||||
.add(ctx);
|
|
||||||
broadcastPlayers(gameId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void removeSession(String gameId, WsContext ctx) {
|
|
||||||
sessions.getOrDefault(gameId, Collections.emptySet()).remove(ctx);
|
|
||||||
broadcastPlayers(gameId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void broadcastPlayers(String gameId) {
|
|
||||||
Game game = games.get(gameId);
|
|
||||||
if (game == null) return;
|
|
||||||
PlayersMessage msg = new PlayersMessage(new ArrayList<>(game.players()));
|
|
||||||
sessions.getOrDefault(gameId, Collections.emptySet())
|
|
||||||
.forEach(ctx -> ctx.send(JsonUtil.toJson(msg)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void createGame(String gameId) {
|
|
||||||
Game game = new Game(gameId, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>());
|
|
||||||
games.put(gameId, game);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean gameExists(String gameId) {
|
|
||||||
return games.containsKey(gameId);
|
|
||||||
}
|
|
||||||
public Game startRound(String gameId, List<String> uris) {
|
|
||||||
Game g = getOrCreateGame(gameId);
|
|
||||||
if (g.players().isEmpty()) throw new IllegalStateException("No players");
|
|
||||||
String owner = g.players().get(ThreadLocalRandom.current().nextInt(g.players().size()));
|
|
||||||
String song = uris.get(ThreadLocalRandom.current().nextInt(uris.size()));
|
|
||||||
Game updated = new Game(gameId, g.players(), g.scores(), owner, song, uris);
|
|
||||||
games.put(gameId, updated);
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
// In GameService.java
|
|
||||||
public Set<WsContext> getSessions(String gameId) {
|
|
||||||
return sessions.getOrDefault(gameId, Collections.emptySet());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,152 +1,156 @@
|
||||||
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;
|
||||||
private final String clientSecret;
|
private final String clientSecret;
|
||||||
private final URI redirectUri;
|
private final URI redirectUri;
|
||||||
// Speichert für jeden Benutzer eine eigene, authentifizierte SpotifyApi-Instanz
|
// Speichert für jeden Benutzer eine eigene, authentifizierte SpotifyApi-Instanz
|
||||||
private final Map<String, SpotifyApi> userApis = new ConcurrentHashMap<>();
|
private final Map<String, SpotifyApi> userApis = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public SpotifyAuthService(String clientId, String clientSecret, String redirectUri) {
|
public SpotifyAuthService(String clientId, String clientSecret, String redirectUri) {
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.clientSecret = clientSecret;
|
this.clientSecret = clientSecret;
|
||||||
this.redirectUri = SpotifyHttpManager.makeUri(redirectUri);
|
this.redirectUri = SpotifyHttpManager.makeUri(redirectUri);
|
||||||
}
|
}
|
||||||
|
|
||||||
public URI getAuthorizationUri(String user) {
|
public URI getAuthorizationUri(String user) {
|
||||||
// Temporäre API-Instanz nur für die Erstellung der Auth-URL
|
System.out.println("Erstelle Auth-URL für Benutzer: " + user);
|
||||||
SpotifyApi tempApi = new SpotifyApi.Builder()
|
// Temporäre API-Instanz nur für die Erstellung der Auth-URL
|
||||||
.setClientId(clientId)
|
SpotifyApi tempApi = new SpotifyApi.Builder()
|
||||||
.setClientSecret(clientSecret)
|
.setClientId(clientId)
|
||||||
.setRedirectUri(redirectUri)
|
.setClientSecret(clientSecret)
|
||||||
.build();
|
.setRedirectUri(redirectUri)
|
||||||
|
.build();
|
||||||
|
|
||||||
return tempApi.authorizationCodeUri()
|
return tempApi.authorizationCodeUri()
|
||||||
.scope("user-read-recently-played user-library-read user-read-playback-state user-modify-playback-state streaming")
|
.scope("user-read-recently-played user-library-read user-read-playback-state user-modify-playback-state streaming")
|
||||||
.state(user) // Der Benutzername wird im State mitgegeben
|
.state(user) // Der Benutzername wird im State mitgegeben
|
||||||
.build()
|
.build()
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void exchangeCode(String code, String user) throws IOException, ParseException, SpotifyWebApiException {
|
public void exchangeCode(String code, String user) throws IOException, ParseException, SpotifyWebApiException {
|
||||||
// Erstellt eine neue, dedizierte API-Instanz für diesen Benutzer
|
// Erstellt eine neue, dedizierte API-Instanz für diesen Benutzer
|
||||||
SpotifyApi userApi = new SpotifyApi.Builder()
|
SpotifyApi userApi = new SpotifyApi.Builder()
|
||||||
.setClientId(clientId)
|
.setClientId(clientId)
|
||||||
.setClientSecret(clientSecret)
|
.setClientSecret(clientSecret)
|
||||||
.setRedirectUri(redirectUri)
|
.setRedirectUri(redirectUri)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Tauscht den Code gegen Tokens und konfiguriert die Instanz
|
// Tauscht den Code gegen Tokens und konfiguriert die Instanz
|
||||||
AuthorizationCodeCredentials creds = userApi.authorizationCode(code).build().execute();
|
AuthorizationCodeCredentials creds = userApi.authorizationCode(code).build().execute();
|
||||||
userApi.setAccessToken(creds.getAccessToken());
|
userApi.setAccessToken(creds.getAccessToken());
|
||||||
userApi.setRefreshToken(creds.getRefreshToken());
|
userApi.setRefreshToken(creds.getRefreshToken());
|
||||||
|
|
||||||
// Speichert die fertig konfigurierte API-Instanz für den Benutzer
|
// Speichert die fertig konfigurierte API-Instanz für den Benutzer
|
||||||
userApis.put(user, userApi);
|
userApis.put(user, userApi);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
SpotifyApi userApi = userApis.get(user);
|
int limit = 2;
|
||||||
|
SpotifyApi userApi = userApis.get(user);
|
||||||
|
|
||||||
if (userApi == null) {
|
if (userApi == null) {
|
||||||
System.err.println("Kein SpotifyApi-Client für Benutzer gefunden: " + user);
|
System.err.println("Kein SpotifyApi-Client für Benutzer gefunden: " + user);
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
GetCurrentUsersRecentlyPlayedTracksRequest request = userApi.getCurrentUsersRecentlyPlayedTracks()
|
GetCurrentUsersRecentlyPlayedTracksRequest request = userApi.getCurrentUsersRecentlyPlayedTracks()
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.build();
|
.build();
|
||||||
PagingCursorbased<PlayHistory> history = request.execute();
|
PagingCursorbased<PlayHistory> history = request.execute();
|
||||||
if (history == null || history.getItems() == null) {
|
if (history == null || history.getItems() == null) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
List<String> recentTracks = Arrays.stream(history.getItems())
|
List<String> recentTracks = Arrays.stream(history.getItems())
|
||||||
.map(item -> item.getTrack().getUri())
|
.map(item -> item.getTrack().getUri())
|
||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (recentTracks.size() < limit) {
|
if (recentTracks.size() < limit) {
|
||||||
int newLimit = limit - recentTracks.size();
|
int newLimit = limit - recentTracks.size();
|
||||||
// restliche songs mit kürzlich gespeicherten Tracks auffüllen
|
// restliche songs mit kürzlich gespeicherten Tracks auffüllen
|
||||||
List<String> savedTracks = getSavedTracks(user, newLimit, 0);
|
List<String> savedTracks = getSavedTracks(user, newLimit, 0);
|
||||||
// Nur Tracks hinzufügen, die noch nicht in recentTracks sind
|
// Nur Tracks hinzufügen, die noch nicht in recentTracks sind
|
||||||
savedTracks.removeAll(recentTracks);
|
savedTracks.removeAll(recentTracks);
|
||||||
recentTracks = new java.util.ArrayList<>(recentTracks);
|
recentTracks = new java.util.ArrayList<>(recentTracks);
|
||||||
recentTracks.addAll(savedTracks.subList(0, Math.min(newLimit, savedTracks.size())));
|
recentTracks.addAll(savedTracks.subList(0, Math.min(newLimit, savedTracks.size())));
|
||||||
if(recentTracks.size() < limit){
|
if(recentTracks.size() < limit){
|
||||||
newLimit = limit - recentTracks.size();
|
newLimit = limit - recentTracks.size();
|
||||||
List<String> savedTracks2 = getSavedTracks(user, newLimit, 50);
|
List<String> savedTracks2 = getSavedTracks(user, newLimit, 50);
|
||||||
savedTracks2.removeAll(recentTracks);
|
savedTracks2.removeAll(recentTracks);
|
||||||
recentTracks.addAll(savedTracks2.subList(0, Math.min(newLimit, savedTracks2.size())));
|
recentTracks.addAll(savedTracks2.subList(0, Math.min(newLimit, savedTracks2.size())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return recentTracks.subList(0, Math.min(limit, recentTracks.size()));
|
return recentTracks.subList(0, Math.min(limit, recentTracks.size()));
|
||||||
} catch (IOException | SpotifyWebApiException | ParseException e) {
|
} catch (IOException | SpotifyWebApiException | ParseException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> getSavedTracks(String user, int limit, int offset) {
|
private List<String> getSavedTracks(String user, int limit, int offset) {
|
||||||
SpotifyApi userApi = userApis.get(user);
|
System.out.println("Hole gespeicherte Tracks für Benutzer: " + user + ", Limit: " + limit + ", Offset: " + offset);
|
||||||
|
SpotifyApi userApi = userApis.get(user);
|
||||||
|
|
||||||
if (userApi == null) {
|
if (userApi == null) {
|
||||||
System.err.println("Kein SpotifyApi-Client für Benutzer gefunden: " + user);
|
System.err.println("Kein SpotifyApi-Client für Benutzer gefunden: " + user);
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
List<String> saved = new ArrayList<>();
|
List<String> saved = new ArrayList<>();
|
||||||
while (saved.size() < limit) {
|
while (saved.size() < limit) {
|
||||||
GetUsersSavedTracksRequest req = userApi.getUsersSavedTracks()
|
GetUsersSavedTracksRequest req = userApi.getUsersSavedTracks()
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.market(CountryCode.DE)
|
.market(CountryCode.DE)
|
||||||
.build();
|
.build();
|
||||||
Paging<SavedTrack> page = req.execute();
|
Paging<SavedTrack> page = req.execute();
|
||||||
if (page == null || page.getItems().length == 0){
|
if (page == null || page.getItems().length == 0){
|
||||||
System.out.println("Keine weiteren gespeicherten Tracks gefunden.");
|
System.out.println("Keine weiteren gespeicherten Tracks gefunden.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
for (SavedTrack st : page.getItems()) {
|
for (SavedTrack st : page.getItems()) {
|
||||||
saved.add(st.getTrack().getUri());
|
saved.add(st.getTrack().getUri());
|
||||||
if (saved.size() == limit) break;
|
if (saved.size() == limit) break;
|
||||||
}
|
}
|
||||||
offset += limit;
|
offset += limit;
|
||||||
}
|
}
|
||||||
return saved;
|
return saved;
|
||||||
} catch (IOException | SpotifyWebApiException | ParseException e) {
|
} catch (IOException | SpotifyWebApiException | ParseException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,7 +63,7 @@ public class GameWebSocketHandler {
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "guess" -> {
|
case "guess" -> {
|
||||||
String user = node.get("username").asText();
|
String user = node.get("username").asText();
|
||||||
String guess = node.get("guess").asText();
|
String guess = node.get("guess").asText();
|
||||||
// Guess speichern
|
// Guess speichern
|
||||||
currentGuesses
|
currentGuesses
|
||||||
|
|
@ -71,38 +77,62 @@ 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();
|
||||||
|
System.out.println("AlltracksCache für Spiel " + gameId + " hat " + allTracks.size() + " Songs (rundenstart)");
|
||||||
|
if (!allTracks.isEmpty()) {
|
||||||
|
service.startRound(gameId, allTracks);
|
||||||
}
|
}
|
||||||
if (allTracks.isEmpty()) {
|
// Trackinfos für alle Spieler sammeln
|
||||||
// TODO: Fehler an Client senden, dass keine Songs da sind
|
Map<String, List<String>> allTrackInfos = service.getTrackInfos(allPlayerTracks);
|
||||||
return;
|
// Cache für Trackinfos pro Spiel-ID
|
||||||
}
|
playerTrackInfoCache.put(gameId, allTrackInfos);
|
||||||
|
System.out.println("TrackInfosCache für Spiel " + gameId + " hat " + allTrackInfos.size() + " Spieler (rundenstart)");
|
||||||
// Runde im Service starten, um Song und Owner zu setzen
|
|
||||||
service.startRound(gameId, allTracks);
|
|
||||||
// Jetzt Broadcast mit den aktuellen Daten
|
|
||||||
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. */
|
||||||
public void broadcastRoundStart(String gameId) {
|
public void broadcastRoundStart(String gameId) {
|
||||||
var game = service.getOrCreateGame(gameId);
|
var game = service.getOrCreateGame(gameId);
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,162 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<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 */
|
||||||
</style>
|
--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>
|
||||||
</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">
|
||||||
|
<button id="startRound">Runde starten</button>
|
||||||
<div id="controls">
|
</div>
|
||||||
<button id="startRound">Runde starten</button>
|
<section id="roundArea" hidden>
|
||||||
</div>
|
<div class="section-title">Wer hat’s gehört?</div>
|
||||||
|
<div id="songEmbed"></div>
|
||||||
<div id="roundArea" hidden>
|
<div class="round-controls" id="options">
|
||||||
<h2>Wer hat’s gehört?</h2>
|
<!-- Buttons zu Auswahl verteilen: JS erzeugt <button class="player-option">Name</button> -->
|
||||||
<div id="songEmbed"></div>
|
</div>
|
||||||
<div id="options"></div>
|
<div id="result"></div>
|
||||||
<p id="result"></p>
|
<button id="nextRound" hidden>Weiter</button>
|
||||||
</div>
|
</section>
|
||||||
|
<section>
|
||||||
<h2>Scoreboard</h2>
|
<div class="section-title">Scoreboard</div>
|
||||||
<ul id="scoreboard"></ul>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
const btn = document.createElement("button");
|
//}
|
||||||
btn.textContent = user;
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
type: "guess",
|
|
||||||
username: username,
|
|
||||||
guess: user
|
|
||||||
}));
|
|
||||||
// Nach Tipp alle Buttons deaktivieren
|
|
||||||
optionsDiv.querySelectorAll("button").forEach(b => b.disabled = true);
|
|
||||||
});
|
|
||||||
optionsDiv.append(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
// UI anzeigen
|
// Dynamische Kreisverteilung der Buttons
|
||||||
startBtn.hidden = true;
|
// 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");
|
||||||
|
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", () => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: "guess",
|
||||||
|
username: username,
|
||||||
|
guess: user
|
||||||
|
}));
|
||||||
|
optionsDiv.querySelectorAll("button").forEach(b => b.disabled = true);
|
||||||
|
});
|
||||||
|
optionsDiv.appendChild(btn);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
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] ?? [];
|
||||||
const li = document.createElement("li");
|
userTracks.forEach(trackInfo => {
|
||||||
li.textContent = trackInfo
|
const li = document.createElement("li");
|
||||||
songList.appendChild(li);
|
li.textContent = trackInfo;
|
||||||
});
|
songList.appendChild(li);
|
||||||
}
|
});
|
||||||
|
//playLock = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9) Funktion zum Anzeigen des Ergebnisses
|
// 9) Funktion zum Anzeigen des Ergebnisses
|
||||||
|
|
@ -166,43 +184,45 @@ 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;
|
||||||
resultP.textContent = "";
|
nextBtn.disabled = false;
|
||||||
startBtn.hidden = true;
|
nextBtn.onclick = () => {
|
||||||
startBtn.disabled = true;
|
socket.send(JSON.stringify({ type: "next-round" }));
|
||||||
roundArea.hidden = true;
|
nextBtn.hidden = true;
|
||||||
}, 3000);
|
nextBtn.disabled = true;
|
||||||
|
resultP.textContent = "";
|
||||||
|
startBtn.hidden = true;
|
||||||
|
startBtn.disabled = true;
|
||||||
|
roundArea.hidden = true;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGameEnd({winner}) {
|
function handleGameEnd({winner}) {
|
||||||
resultP.textContent = `🎉 ${winner} hat gewonnen!`;
|
resultP.textContent = `🎉 ${winner} hat gewonnen!`;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue