1720 lines
No EOL
59 KiB
Svelte
1720 lines
No EOL
59 KiB
Svelte
<script lang="ts">
|
|
import { TABotTokenStore, TAServerPort, TAServerUrl, authTokenStore, client } from "$lib/stores";
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { bkAPIUrl } from '$lib/config.json';
|
|
import Notification from '$lib/components/notifications/Popup.svelte';
|
|
//@ts-ignore
|
|
import {
|
|
Match,
|
|
Tournament,
|
|
TAClient,
|
|
Response_ResponseType,
|
|
User,
|
|
RealtimeScore,
|
|
Push_SongFinished,
|
|
Map,
|
|
User_DownloadStates,
|
|
User_PlayStates,
|
|
Tournament_TournamentSettings_Pool,
|
|
|
|
Command_ModifyGameplay_Modifier
|
|
|
|
} from 'moons-ta-client';
|
|
import { writable } from "svelte/store";
|
|
import { goto } from "$app/navigation";
|
|
import { fetchMapByLevelId, fetchMapsByLevelIds, type BeatSaverMap } from "$lib/services/beatsaver.js";
|
|
import SongQueue from "$lib/components/modules/SongQueue.svelte";
|
|
import EditMap from "$lib/components/popups/EditMap.svelte";
|
|
import Popup from "$lib/components/notifications/Popup.svelte";
|
|
import PreviouslyPlayedSongs from "$lib/components/modules/PreviouslyPlayedSongs.svelte";
|
|
import ResultsForMap from "$lib/components/popups/ResultsForMap.svelte";
|
|
import AddMapsFromTAPool from "$lib/components/popups/AddMapsFromTAPool.svelte";
|
|
import ModifyGameplay from "$lib/components/popups/ModifyGameplay.svelte";
|
|
|
|
// Types
|
|
import {
|
|
type CustomMap,
|
|
type RealTimeScoreForPlayers,
|
|
type PreviousResults,
|
|
type ButtonConfig,
|
|
type ColorPreset,
|
|
MapDifficulty,
|
|
|
|
type CustomTAMapPool
|
|
|
|
} from "$lib/interfaces/match.interfaces";
|
|
import AddMapsFromFile from "$lib/components/popups/AddMapsFromFile.svelte";
|
|
|
|
export let data;
|
|
|
|
// Route params
|
|
const tournamentGuid: string = data.tournamentGuid;
|
|
const matchGuid: string = data.matchGuid;
|
|
|
|
// Core state
|
|
let tournament: Tournament | undefined;
|
|
let match: Match | undefined;
|
|
let isLoading = false;
|
|
let error: string | null = null;
|
|
let matchPlayers: User[] = [];
|
|
let currentSong: any = null;
|
|
let currentSongData: BeatSaverMap;
|
|
let isMatchActive = false;
|
|
let streamSyncEnabled = false;
|
|
|
|
// Song queue and results
|
|
let upcomingQueueMaps: CustomMap[] = [];
|
|
let realTimeScores: RealTimeScoreForPlayers[] = [];
|
|
let previousMatchResults: PreviousResults[] = [];
|
|
let showingResultsData: PreviousResults;
|
|
|
|
// Modal states
|
|
let showKickConfirmModal = false;
|
|
let showBackToMenuModal = false;
|
|
let showExitMatchModal = false;
|
|
let playerToKick: User | null = null;
|
|
let showCannotExitPage: boolean = false;
|
|
let modalMessage: string = "";
|
|
let modalIcon: string = "danger";
|
|
let modalIconColor: ColorPreset = 'error';
|
|
let modalButtons: ButtonConfig[] = [];
|
|
let modalCustomImage: string = "";
|
|
let modalAutoClose: number = 0;
|
|
|
|
// Map editing states
|
|
let showMapModal: boolean = false;
|
|
let currentlyEditingMap: CustomMap | null = null;
|
|
|
|
// Song loading states
|
|
let loadingSongForPlayers: boolean = false;
|
|
let failedToLoadSongForPlayers: boolean = false;
|
|
|
|
// Results popup states
|
|
let showResultsPopup: boolean = false;
|
|
let dontShowResultsPopupForThisSong: boolean = false;
|
|
let currentlyShowingOldMapResults: boolean = false;
|
|
|
|
// Add maps from TA pool modal state
|
|
let taPools: Tournament_TournamentSettings_Pool[] = [];
|
|
let customTAPools: CustomTAMapPool[] = [];
|
|
let showAddFromTAPoolPopup: boolean = false;
|
|
let allowAddFromTAPool: boolean = true;
|
|
|
|
interface PlayerModState {
|
|
platformId: string;
|
|
isColorSwitched: boolean;
|
|
isHandSwitched: boolean;
|
|
isBlueDisabled: boolean;
|
|
isRedDisabled: boolean;
|
|
}
|
|
|
|
let playerModificationStates: PlayerModState[] = []
|
|
|
|
// User editing state
|
|
let isModifyGameplayPopupVisible: boolean = false;
|
|
let editingPlayerPlatformId: string, editingPlayerName: string, editingPlayerPfp: string = "";
|
|
|
|
// Active song tracking
|
|
const activeSongPlayers = new Set<`${string}-${string}`>(); // Format: "userGuid-levelId"
|
|
|
|
// Reactive statements
|
|
$: isAnyPlayerPlaying = matchPlayers.some(player => (player.playState as any) === User_PlayStates.InGame);
|
|
|
|
$: if(isAnyPlayerPlaying && currentSong) {
|
|
matchPlayers
|
|
.filter(x => x.playState == User_PlayStates.InGame)
|
|
.forEach(player => {
|
|
const playerKey: string = `${player.guid}-${currentSong.beatmap.levelId.toLowerCase()}`;
|
|
if (!activeSongPlayers.has(playerKey as any)) {
|
|
activeSongPlayers.add(playerKey as any);
|
|
}
|
|
});
|
|
}
|
|
|
|
if ($authTokenStore) {
|
|
const cleanToken = $authTokenStore.replace('Bearer ', '');
|
|
client.setAuthToken(cleanToken);
|
|
}
|
|
|
|
// Show notification function
|
|
function showNotification(message: string, type: 'warning' | 'error' | 'success' = 'warning') {
|
|
// Your existing notification function
|
|
console.log(`${type}: ${message}`);
|
|
}
|
|
|
|
function handleExitResultsPopupClicked() {
|
|
showResultsPopup = false;
|
|
if(!currentlyShowingOldMapResults) {
|
|
dontShowResultsPopupForThisSong = true;
|
|
}
|
|
currentlyShowingOldMapResults = false;
|
|
}
|
|
|
|
function showResultsPopupWithOldScores(event: CustomEvent) {
|
|
const oldData: PreviousResults = event.detail.map;
|
|
currentlyShowingOldMapResults = true;
|
|
showingResultsData = oldData;
|
|
showResultsPopup = true;
|
|
dontShowResultsPopupForThisSong = false;
|
|
}
|
|
|
|
// Handle back to matches with confirmation if needed
|
|
async function handleBackToMatches(endMatch: boolean) {
|
|
if(match!.leader !== client.stateManager.getSelfGuid()) {
|
|
await removeSelfFromMatch();
|
|
goto(`/tournaments/${tournamentGuid}`);
|
|
}
|
|
|
|
if(endMatch) {
|
|
if (isAnyPlayerPlaying) {
|
|
showExitMatchModal = true;
|
|
modalMessage = `Players are currently in-game. Exiting will close the match for all players. Continue?`;
|
|
modalIcon = 'warning';
|
|
modalButtons = [
|
|
{
|
|
text: `Close (NO)`,
|
|
type: 'primary',
|
|
action: () => showBackToMenuModal = false,
|
|
icon: 'close'
|
|
},
|
|
{
|
|
text: `Yes (YES)`,
|
|
type: 'secondary',
|
|
action: async() => await confirmExitMatch(),
|
|
icon: 'backspace'
|
|
}
|
|
];
|
|
} else {
|
|
await removeSelfFromMatch();
|
|
await client.deleteMatch(tournamentGuid, matchGuid);
|
|
goto(`/tournaments/${tournamentGuid}`);
|
|
}
|
|
}
|
|
|
|
showExitMatchModal = true;
|
|
modalMessage = `This should not cause issues as it should not end the match. But TA can be weird sometimes. THIS SHOULD BE SAFE. Do you want to continue?`;
|
|
modalIcon = 'info';
|
|
modalIconColor = 'info';
|
|
modalButtons = [
|
|
{
|
|
text: `Close (NO)`,
|
|
type: 'primary',
|
|
action: () => showBackToMenuModal = false,
|
|
icon: 'close'
|
|
},
|
|
{
|
|
text: `Yes (YES)`,
|
|
type: 'secondary',
|
|
action: async() => await confirmExitMatch(),
|
|
icon: 'backspace'
|
|
}
|
|
];
|
|
}
|
|
|
|
// Confirm exit match
|
|
function confirmExitMatch() {
|
|
showExitMatchModal = false;
|
|
showNotification('Match will be closed', 'warning');
|
|
goto(`/tournaments/${tournamentGuid}`);
|
|
}
|
|
|
|
// Handle kick player
|
|
function handleKickPlayer(player: User) {
|
|
playerToKick = player;
|
|
modalMessage = `Are you sure you want to kick ${player.name} from the match?`;
|
|
modalIcon = 'warning';
|
|
modalButtons = [
|
|
{
|
|
text: `Close (NO)`,
|
|
type: 'primary',
|
|
action: () => showKickConfirmModal = false,
|
|
icon: 'close'
|
|
},
|
|
{
|
|
text: `Kick ${player.name} (YES)`,
|
|
type: 'secondary',
|
|
action: async() => await confirmKickPlayer(),
|
|
icon: 'sports_martial_arts'
|
|
}
|
|
];
|
|
showKickConfirmModal = true;
|
|
}
|
|
|
|
// Confirm kick player
|
|
async function confirmKickPlayer() {
|
|
if (!playerToKick) {
|
|
showKickConfirmModal = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Implementation for kicking player from match
|
|
await kickPlayerFromMatch(playerToKick.guid);
|
|
showNotification(`${playerToKick.name} has been kicked from the match`, 'success');
|
|
await fetchMatchData();
|
|
} catch (err) {
|
|
console.error('Error kicking player:', err);
|
|
showNotification('Failed to kick player', 'error');
|
|
}
|
|
|
|
showKickConfirmModal = false;
|
|
playerToKick = null;
|
|
}
|
|
|
|
// Start song for all players
|
|
async function startSong() {
|
|
try {
|
|
if (!currentSong) {
|
|
showNotification('No song selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
await startSongForMatch();
|
|
isMatchActive = true;
|
|
} catch (err) {
|
|
console.error('Error starting song:', err);
|
|
showNotification('Failed to start song', 'error');
|
|
}
|
|
}
|
|
|
|
// Start song with stream sync
|
|
async function startSongWithStreamSync() {
|
|
if (!streamSyncEnabled) return;
|
|
|
|
try {
|
|
await startSongWithStreamSyncForMatch();
|
|
showNotification('Song started with stream sync', 'success');
|
|
isMatchActive = true;
|
|
} catch (err) {
|
|
console.error('Error starting song with stream sync:', err);
|
|
showNotification('Failed to start song with stream sync', 'error');
|
|
}
|
|
}
|
|
|
|
// Back to menu
|
|
function handleBackToMenu() {
|
|
showBackToMenuModal = true;
|
|
modalMessage = `This will send all players back to the menu and stop the current song. Continue?`;
|
|
modalIcon = 'warning';
|
|
modalButtons = [
|
|
{
|
|
text: `Close (NO)`,
|
|
type: 'primary',
|
|
action: () => showBackToMenuModal = false,
|
|
icon: 'close'
|
|
},
|
|
{
|
|
text: `Yes (YES)`,
|
|
type: 'secondary',
|
|
action: async() => await confirmBackToMenu(),
|
|
icon: 'backspace'
|
|
}
|
|
];
|
|
}
|
|
|
|
// Confirm back to menu
|
|
async function confirmBackToMenu() {
|
|
try {
|
|
await sendPlayersBackToMenu();
|
|
showNotification('All players sent back to menu', 'success');
|
|
isMatchActive = false;
|
|
currentSong = null;
|
|
} catch (err) {
|
|
console.error('Error sending players back to menu:', err);
|
|
showNotification('Failed to send players back to menu', 'error');
|
|
}
|
|
|
|
showBackToMenuModal = false;
|
|
}
|
|
|
|
// Return status color based on player status
|
|
function getStatusColor(playState: User_PlayStates, downloadState: User_DownloadStates): string {
|
|
if(downloadState == User_DownloadStates.Downloading) return "var(--danger-color)";
|
|
if(downloadState == User_DownloadStates.DownloadError) return "var(--danger-color)";
|
|
if(playState == User_PlayStates.InGame) return "var(--danger-color)";
|
|
|
|
switch (playState) {
|
|
case User_PlayStates.WaitingForCoordinator:
|
|
return "#FCD34D"; // Yellow
|
|
case User_PlayStates.InMenu:
|
|
return "#10B981"; // Green
|
|
default:
|
|
return "var(--text-secondary)";
|
|
}
|
|
}
|
|
|
|
// Handle match events
|
|
async function handleMatchUpdated(params: [Match, Tournament]) {
|
|
console.log("Match updated:", params);
|
|
if (params[0].guid === matchGuid) {
|
|
match = params[0];
|
|
await refreshMatchDetails(params[0]);
|
|
}
|
|
}
|
|
|
|
function handleMatchDeleted(params: [Match, Tournament]) {
|
|
console.log("Match deleted:", params);
|
|
if (params[0].guid === matchGuid) {
|
|
goto(`/tournaments/${tournamentGuid}`);
|
|
}
|
|
}
|
|
|
|
function handleSongChanged(songInfo: any) {
|
|
console.log("Song changed:", songInfo);
|
|
currentSong = songInfo;
|
|
}
|
|
|
|
async function addSelfToMatch() {
|
|
if(!match?.associatedUsers.includes(client.stateManager.getSelfGuid())) {
|
|
const response = await client.addUserToMatch(tournamentGuid, matchGuid, client.stateManager.getSelfGuid());
|
|
console.log("Added Self to match!", response);
|
|
}
|
|
}
|
|
|
|
async function removeSelfFromMatch() {
|
|
if(match?.associatedUsers.includes(client.stateManager.getSelfGuid())) {
|
|
const response = await client.removeUserFromMatch(tournamentGuid, matchGuid, client.stateManager.getSelfGuid());
|
|
console.log("Removed Self from match!", response);
|
|
}
|
|
}
|
|
|
|
async function refreshMatchDetails(match: Match) {
|
|
// Get match players
|
|
matchPlayers = match.associatedUsers
|
|
.map(userGuid => client.stateManager.getUser(tournamentGuid, userGuid))
|
|
.filter(user => user && user.clientType === 0) as User[];
|
|
|
|
// Get current song if available
|
|
currentSong = match.selectedMap?.gameplayParameters || null;
|
|
if(currentSong) {
|
|
try {
|
|
const tempMapData = await fetchMapByLevelId(currentSong.beatmap.levelId);
|
|
|
|
if(tempMapData) {
|
|
currentSongData = tempMapData;
|
|
if(!upcomingQueueMaps.some(x => x.taData.gameplayParameters?.beatmap?.levelId.toUpperCase() == currentSong.beatmap.levelId.toUpperCase())) {
|
|
upcomingQueueMaps = [
|
|
{
|
|
taData: match.selectedMap!,
|
|
beatsaverData: tempMapData!
|
|
},
|
|
...upcomingQueueMaps
|
|
];
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('Current song beatsaver data fetch failed');
|
|
}
|
|
}
|
|
|
|
const tournament = client.stateManager.getTournament(tournamentGuid);
|
|
|
|
if(!tournament!.settings!.enablePools) {
|
|
allowAddFromTAPool = false;
|
|
return;
|
|
}
|
|
|
|
taPools = tournament!.settings!.pools;
|
|
|
|
// First, collect all unique level IDs from all pools
|
|
const allLevelIds = taPools.flatMap(pool =>
|
|
pool.maps.map(map => map.gameplayParameters!.beatmap!.levelId)
|
|
);
|
|
|
|
// Remove duplicates to avoid fetching the same map multiple times
|
|
const uniqueLevelIds = [...new Set(allLevelIds)];
|
|
|
|
// Fetch all maps data in one go (your function handles chunking internally)
|
|
const allMapBeatSaverDatas = await fetchMapsByLevelIds(uniqueLevelIds);
|
|
|
|
// Create a lookup object for quick access
|
|
const mapDataLookup: { [levelId: string]: any } = {};
|
|
allMapBeatSaverDatas.forEach(mapData => {
|
|
if (mapData && mapData.versions[0].hash) {
|
|
mapDataLookup[mapData.versions[0].hash] = mapData;
|
|
}
|
|
});
|
|
|
|
// Now process each pool using the pre-fetched data
|
|
customTAPools = await Promise.all(
|
|
taPools.map(async (pool) => {
|
|
const poolCustomMaps: CustomMap[] = pool.maps
|
|
.map(map => {
|
|
const levelId = map.gameplayParameters!.beatmap!.levelId;
|
|
const beatsaverData = mapDataLookup[(levelId.toLowerCase().replace('custom_level_', ''))];
|
|
|
|
if (!beatsaverData) return null;
|
|
|
|
return {
|
|
taData: map,
|
|
beatsaverData: beatsaverData
|
|
};
|
|
})
|
|
.filter(result => result !== null) as CustomMap[];
|
|
|
|
return {
|
|
name: pool.name,
|
|
guid: pool.guid,
|
|
image: pool.image,
|
|
maps: poolCustomMaps
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
async function fetchMatchData() {
|
|
if (!$authTokenStore) {
|
|
window.location.href = '/discordAuth';
|
|
return;
|
|
}
|
|
|
|
isLoading = true;
|
|
error = null;
|
|
|
|
try {
|
|
if(!client.isConnected) {
|
|
const connectResult = await client.connect($TAServerUrl, $TAServerPort);
|
|
|
|
if (connectResult.details.oneofKind === "connect" && connectResult.type === Response_ResponseType.Fail) {
|
|
throw new Error(connectResult.details.connect.message);
|
|
}
|
|
}
|
|
|
|
// Get tournament and match data
|
|
tournament = client.stateManager.getTournament(tournamentGuid);
|
|
match = client.stateManager.getMatch(tournamentGuid, matchGuid);
|
|
|
|
if(!match?.associatedUsers.includes(client.stateManager.getSelfGuid())) {
|
|
await addSelfToMatch();
|
|
}
|
|
|
|
if (!match) {
|
|
throw new Error('Match not found');
|
|
}
|
|
|
|
await refreshMatchDetails(match);
|
|
} catch (err) {
|
|
console.error('Error fetching match data:', err);
|
|
error = err instanceof Error ? err.message : 'An unknown error occurred';
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function kickPlayerFromMatch(playerGuid: string) {
|
|
const response = await client.removeUserFromMatch(tournamentGuid, matchGuid, playerGuid);
|
|
console.log('Kicking player:', playerGuid);
|
|
console.log("removeResponse", response);
|
|
}
|
|
|
|
async function startSongForMatch() {
|
|
if (!currentSong) return;
|
|
|
|
showResultsPopup = false;
|
|
dontShowResultsPopupForThisSong = false;
|
|
|
|
const levelId = currentSong.beatmap.levelId;
|
|
const playingPlayerIds = matchPlayers.map(player => player.guid);
|
|
|
|
// Clear any existing entries for this levelId
|
|
Array.from(activeSongPlayers)
|
|
.filter(key => key.endsWith(`-${levelId}`))
|
|
.forEach(key => activeSongPlayers.delete(key));
|
|
|
|
// Add all current players for this song
|
|
playingPlayerIds.forEach(playerGuid => {
|
|
activeSongPlayers.add(`${playerGuid}-${levelId.toLowerCase()}`);
|
|
});
|
|
|
|
const response = await client.playSong(tournamentGuid, currentSong, playingPlayerIds);
|
|
console.log('startMap', response);
|
|
}
|
|
|
|
async function startSongWithStreamSyncForMatch() {
|
|
// Implement start song with stream sync logic
|
|
console.log('Starting song with stream sync');
|
|
}
|
|
|
|
async function sendPlayersBackToMenu() {
|
|
const backToMenuResponse = await client.returnToMenu(tournamentGuid, matchPlayers.filter(x => x.playState == User_PlayStates.InGame).map(x => x.guid));
|
|
console.log("Sent players back to menu!", backToMenuResponse)
|
|
}
|
|
|
|
async function handleUserDisconnected(params: [User, Tournament]) {
|
|
if(matchPlayers.some(user => user.guid == params[0].guid)) {
|
|
console.log("User disconnected from match. User: ", params[0])
|
|
matchPlayers = matchPlayers.filter(x => x.guid !== params[0].guid);
|
|
}
|
|
}
|
|
|
|
function getTimeStringFromSeconds(seconds: number) {
|
|
let newString: string = "";
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
if(minutes > 0) {
|
|
newString += `${minutes} minutes `;
|
|
}
|
|
|
|
const timeSeconds = (seconds - Math.floor(seconds / 60) * 60).toFixed(1);
|
|
|
|
if(!timeSeconds.startsWith("0")) {
|
|
newString += `${timeSeconds} seconds `;
|
|
}
|
|
return newString;
|
|
}
|
|
|
|
async function handleRealtimeScoreUpdate(rts: RealtimeScore) {
|
|
console.log(`Player ${matchPlayers.find(x => x.guid == rts.userGuid)?.name}: ${rts.accuracy}`, rts);
|
|
|
|
const player = matchPlayers.find(x => x.guid === rts.userGuid)!;
|
|
|
|
if(!player) return;
|
|
|
|
const index = realTimeScores.findIndex(x => x.player.guid === rts.userGuid);
|
|
|
|
if (index !== -1) {
|
|
// Update the existing entry
|
|
realTimeScores[index] = { player, recentScore: rts };
|
|
} else {
|
|
// Append only if it's a new player
|
|
realTimeScores = [
|
|
...realTimeScores,
|
|
{ player, recentScore: rts }
|
|
];
|
|
}
|
|
}
|
|
|
|
function getPlayerRtsScore(userGuid: string) {
|
|
let playerScore = realTimeScores.find(x => x.player.guid == userGuid);
|
|
if(!playerScore) {
|
|
playerScore = {
|
|
player: matchPlayers.find(x => x.guid == userGuid)!,
|
|
recentScore: {
|
|
userGuid: userGuid,
|
|
score: 0,
|
|
scoreWithModifiers: 0,
|
|
maxScore: 0,
|
|
maxScoreWithModifiers: 0,
|
|
combo: 0,
|
|
playerHealth: 0,
|
|
accuracy: 0,
|
|
songPosition: 0,
|
|
notesMissed: 0,
|
|
badCuts: 0,
|
|
bombHits: 0,
|
|
wallHits: 0,
|
|
maxCombo: 0
|
|
}
|
|
}
|
|
}
|
|
|
|
return playerScore;
|
|
}
|
|
|
|
function getMaxScore(songInfo: BeatSaverMap, characteristic: string = "standard", difficulty: string): number {
|
|
const diff = songInfo.versions[0].diffs.find(
|
|
(x) => {
|
|
const charMatch = x.characteristic.toLowerCase() === characteristic.toLowerCase();
|
|
|
|
// Normalize difficulty strings for comparison
|
|
const searchDiff = difficulty.toLowerCase() === "expert+" ? "expertplus" : difficulty.toLowerCase();
|
|
const diffDiff = x.difficulty.toLowerCase() === "expert+" ? "expertplus" : x.difficulty.toLowerCase();
|
|
|
|
return charMatch && diffDiff === searchDiff;
|
|
}
|
|
);
|
|
|
|
return diff?.maxScore ?? 100000;
|
|
}
|
|
|
|
// Method to convert difficulty number to string
|
|
function getDifficultyAsString(difficulty: number): string {
|
|
return MapDifficulty[difficulty] || "ExpertPlus";
|
|
}
|
|
|
|
// Main accuracy calculation logic
|
|
function calculateAccuracy(score: Push_SongFinished, mapWithSongInfo: CustomMap): number {
|
|
const maxScore = getMaxScore(
|
|
mapWithSongInfo.beatsaverData,
|
|
score.beatmap?.characteristic?.serializedName ?? "Standard",
|
|
getDifficultyAsString(score.beatmap?.difficulty ?? 4) || "ExpertPlus",
|
|
);
|
|
|
|
const accuracy = parseFloat(((score.score / maxScore) * 100).toFixed(2));
|
|
return accuracy;
|
|
}
|
|
|
|
async function handleSongFinished(rts: Push_SongFinished) {
|
|
if (rts.matchId !== matchGuid) return;
|
|
|
|
const levelId = rts.beatmap?.levelId;
|
|
if (!levelId) return;
|
|
|
|
// Remove player from active song tracking
|
|
activeSongPlayers.delete(`${rts.player!.guid}-${levelId.toLowerCase()}`);
|
|
|
|
const map = upcomingQueueMaps.find(x =>
|
|
x.taData.gameplayParameters!.beatmap!.levelId.toUpperCase() === levelId.toUpperCase()
|
|
);
|
|
|
|
if (!map) {
|
|
console.log('Received a score for unknown map', rts);
|
|
return;
|
|
}
|
|
|
|
let newRts: Push_SongFinished = {...rts, accuracy: 0};
|
|
newRts.accuracy = calculateAccuracy(rts, map);
|
|
|
|
// Check if any players are still playing this song
|
|
const playersStillPlaying = Array.from(activeSongPlayers).some(key => key.endsWith(`-${levelId.toLowerCase()}`));
|
|
console.log("Players still playing:", playersStillPlaying)
|
|
|
|
const completionType = playersStillPlaying
|
|
? 'Still Awaiting Scores'
|
|
: 'Completed';
|
|
console.log("CompletionType", completionType)
|
|
|
|
// Find or create the result record
|
|
const existingRecordIndex = previousMatchResults.findIndex(x =>
|
|
x.completionType === 'Still Awaiting Scores' &&
|
|
x.taData.gameplayParameters?.beatmap?.levelId.toUpperCase() === levelId.toUpperCase()
|
|
);
|
|
console.log("existingRecordIndex", existingRecordIndex)
|
|
|
|
if (existingRecordIndex === -1) {
|
|
console.log("creating new record")
|
|
previousMatchResults = [
|
|
{
|
|
taData: map.taData,
|
|
beatsaverData: map.beatsaverData,
|
|
completionType,
|
|
scores: [newRts]
|
|
},
|
|
...previousMatchResults
|
|
];
|
|
} else {
|
|
const existingRecord = previousMatchResults[existingRecordIndex];
|
|
previousMatchResults = [
|
|
{
|
|
taData: map.taData,
|
|
beatsaverData: map.beatsaverData,
|
|
completionType,
|
|
scores: [...existingRecord.scores, newRts]
|
|
},
|
|
...previousMatchResults.filter((_, i) => i !== existingRecordIndex)
|
|
];
|
|
}
|
|
|
|
playerModificationStates = [];
|
|
|
|
showingResultsData = previousMatchResults[0];
|
|
showResultsPopup = true;
|
|
}
|
|
|
|
async function handleMapUpdated(event: CustomEvent) {
|
|
const map = upcomingQueueMaps.find(x => x.taData.guid == event.detail.map.guid);
|
|
if(!map) return;
|
|
upcomingQueueMaps = [
|
|
...upcomingQueueMaps.filter(x => x.taData.guid !== event.detail.map.guid),
|
|
{
|
|
taData: (event.detail.map as Map),
|
|
beatsaverData: map.beatsaverData
|
|
}
|
|
];
|
|
}
|
|
|
|
async function handleMapAdded(event: CustomEvent) {
|
|
const beatsaverData: BeatSaverMap | null = await fetchMapByLevelId(event.detail.map.gameplayParameters.beatmap.levelId);
|
|
if(!beatsaverData) {
|
|
console.log('Failed to fetch beatsaver map data for new map, aborting map addition!');
|
|
return;
|
|
}
|
|
const newMap: CustomMap = {
|
|
taData: event.detail.map,
|
|
beatsaverData: beatsaverData
|
|
};
|
|
|
|
upcomingQueueMaps = [
|
|
...upcomingQueueMaps,
|
|
newMap
|
|
];
|
|
}
|
|
|
|
function handleShowAddMapsFromTAPool() {
|
|
showAddFromTAPoolPopup = true;
|
|
}
|
|
|
|
function handleCloseAddMapsFromTAPool() {
|
|
showAddFromTAPoolPopup = false;
|
|
}
|
|
|
|
function handleAddMapsFromTAPool(event: CustomEvent) {
|
|
showAddFromTAPoolPopup = false;
|
|
const mapsToAdd: CustomMap[] = event.detail.maps;
|
|
upcomingQueueMaps = [
|
|
...mapsToAdd,
|
|
...upcomingQueueMaps
|
|
];
|
|
}
|
|
|
|
function closeEditMapModal() {
|
|
showMapModal = false;
|
|
}
|
|
|
|
async function handleAddMap() {
|
|
currentlyEditingMap = null;
|
|
showMapModal = true;
|
|
}
|
|
|
|
async function handleEditMap(event: CustomEvent) {
|
|
currentlyEditingMap = event.detail.map;
|
|
showMapModal = true;
|
|
}
|
|
|
|
async function handleRemoveMapFromQueue(event: CustomEvent) {
|
|
upcomingQueueMaps = [
|
|
...upcomingQueueMaps.filter(x => x.taData.guid !== event.detail.map.taData.guid)
|
|
];
|
|
}
|
|
|
|
async function handleLoadMap(event: CustomEvent) {
|
|
const map: CustomMap = event.detail.map;
|
|
loadingSongForPlayers = false;
|
|
failedToLoadSongForPlayers = false;
|
|
dontShowResultsPopupForThisSong = false;
|
|
|
|
let playingPlayerIds = matchPlayers
|
|
.filter(player => player.playState !== User_PlayStates.InGame)
|
|
.map(player => player.guid);
|
|
|
|
if(!playingPlayerIds || playingPlayerIds.length == 0) {
|
|
playingPlayerIds = [];
|
|
}
|
|
|
|
loadingSongForPlayers = true;
|
|
const matchResponse = await client.setMatchMap(tournamentGuid, matchGuid, map.taData);
|
|
if(matchResponse.type == Response_ResponseType.Fail) return;
|
|
const loadResponse = await client.loadSong(tournamentGuid, map.taData.gameplayParameters!.beatmap!.levelId.toUpperCase().replace('CUSTOM_LEVEL_', 'custom_level_'), playingPlayerIds, 3 * 60 * 1000);
|
|
failedToLoadSongForPlayers = false;
|
|
|
|
const failedLoads = loadResponse.filter(x => x.response.type == Response_ResponseType.Fail);
|
|
|
|
if(failedLoads.length > 0) {
|
|
failedToLoadSongForPlayers = true;
|
|
showNotification('Failed to load song as one or more players failed to load! Please try again', 'error');
|
|
} else {
|
|
loadingSongForPlayers = false;
|
|
showNotification('Successfully loaded song!', 'success');
|
|
}
|
|
|
|
console.log("songLoaded", loadResponse);
|
|
}
|
|
|
|
async function handleUserUpdated(params: [User, Tournament]) {
|
|
if(params[1].guid !== tournamentGuid) return;
|
|
|
|
const userIndex = matchPlayers.findIndex(x => x.guid === params[0].guid);
|
|
if(userIndex !== -1) {
|
|
matchPlayers = [
|
|
...matchPlayers.slice(0, userIndex),
|
|
params[0],
|
|
...matchPlayers.slice(userIndex + 1)
|
|
];
|
|
} else {
|
|
matchPlayers = [...matchPlayers, params[0]];
|
|
}
|
|
}
|
|
|
|
async function handleSetGameplayModifiersForUsersVerbose(userId: string, modifiers: number) {
|
|
try {
|
|
const results: any[] = [];
|
|
|
|
const isModifierEnabled = (modifier: Command_ModifyGameplay_Modifier): boolean => {
|
|
return (modifiers & (1 << modifier)) !== 0;
|
|
};
|
|
|
|
let flipColorUserIds: string[] = [];
|
|
let flipHandUserIds: string[] = [];
|
|
let flipBlueUserIds: string[] = [];
|
|
let flipRedUserIds: string[] = [];
|
|
|
|
// Determine the desired state from modifiers
|
|
const desiredColorSwitched = isModifierEnabled(Command_ModifyGameplay_Modifier.InvertColors);
|
|
const desiredHandSwitched = isModifierEnabled(Command_ModifyGameplay_Modifier.InvertHandedness);
|
|
const desiredBlueDisabled = isModifierEnabled(Command_ModifyGameplay_Modifier.DisableBlueNotes);
|
|
const desiredRedDisabled = isModifierEnabled(Command_ModifyGameplay_Modifier.DisableRedNotes);
|
|
|
|
const userObject = playerModificationStates.find(x => x.platformId == userId);
|
|
|
|
if(!userObject) {
|
|
// User doesn't exist in state, so current state is all false
|
|
// Send toggle packets for any modifiers that should be enabled
|
|
if(desiredColorSwitched) flipColorUserIds = [...flipColorUserIds, userId];
|
|
if(desiredHandSwitched) flipHandUserIds = [...flipHandUserIds, userId];
|
|
if(desiredBlueDisabled) flipBlueUserIds = [...flipBlueUserIds, userId];
|
|
if(desiredRedDisabled) flipRedUserIds = [...flipRedUserIds, userId];
|
|
|
|
// Add new user with desired state
|
|
playerModificationStates = [...playerModificationStates, {
|
|
platformId: userId,
|
|
isColorSwitched: desiredColorSwitched,
|
|
isHandSwitched: desiredHandSwitched,
|
|
isBlueDisabled: desiredBlueDisabled,
|
|
isRedDisabled: desiredRedDisabled
|
|
}];
|
|
} else {
|
|
// User exists, check which states need to change
|
|
if(desiredColorSwitched !== userObject.isColorSwitched) flipColorUserIds = [...flipColorUserIds, userId];
|
|
if(desiredHandSwitched !== userObject.isHandSwitched) flipHandUserIds = [...flipHandUserIds, userId];
|
|
if(desiredBlueDisabled !== userObject.isBlueDisabled) flipBlueUserIds = [...flipBlueUserIds, userId];
|
|
if(desiredRedDisabled !== userObject.isRedDisabled) flipRedUserIds = [...flipRedUserIds, userId];
|
|
|
|
// Update user state to desired state
|
|
playerModificationStates = [...playerModificationStates.filter(x => x.platformId != userId), {
|
|
platformId: userId,
|
|
isColorSwitched: desiredColorSwitched,
|
|
isHandSwitched: desiredHandSwitched,
|
|
isBlueDisabled: desiredBlueDisabled,
|
|
isRedDisabled: desiredRedDisabled
|
|
}];
|
|
}
|
|
|
|
// Only send packets if there are users to modify
|
|
if(flipColorUserIds.length > 0) {
|
|
console.log('Inverting colors for users:', flipColorUserIds);
|
|
const response1 = await client.flipColors(tournamentGuid, flipColorUserIds);
|
|
results.push({ type: 'InvertColors', response: response1 });
|
|
}
|
|
|
|
if(flipHandUserIds.length > 0) {
|
|
console.log('Inverting handedness for users:', flipHandUserIds);
|
|
const response2 = await client.flipHands(tournamentGuid, flipHandUserIds);
|
|
results.push({ type: 'InvertHandedness', response: response2 });
|
|
}
|
|
|
|
if(flipBlueUserIds.length > 0) {
|
|
console.log('Disabling blue notes for users:', flipBlueUserIds);
|
|
const response3 = await client.disableBlueNotes(tournamentGuid, flipBlueUserIds);
|
|
results.push({ type: 'DisableBlueNotes', response: response3 });
|
|
}
|
|
|
|
if(flipRedUserIds.length > 0) {
|
|
console.log('Disabling red notes for users:', flipRedUserIds);
|
|
const response4 = await client.disableRedNotes(tournamentGuid, flipRedUserIds);
|
|
results.push({ type: 'DisableRedNotes', response: response4 });
|
|
}
|
|
|
|
console.log(`Applied ${results.length} modifiers successfully`);
|
|
console.log("Modifier apply res", results);
|
|
return results;
|
|
|
|
} catch (error) {
|
|
console.error('Error applying gameplay modifiers:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function handleModifyGameplayForPlayer(player: User) {
|
|
editingPlayerName = player.name;
|
|
editingPlayerPfp = player.discordInfo?.avatarUrl || "";
|
|
editingPlayerPlatformId = player.guid;
|
|
isModifyGameplayPopupVisible = true;
|
|
}
|
|
|
|
async function handleSendModifyGameplay(event: CustomEvent) {
|
|
const { platformId, modifiers } = event.detail;
|
|
|
|
await handleSetGameplayModifiersForUsersVerbose(platformId, modifiers);
|
|
}
|
|
|
|
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
|
|
event.preventDefault(); // Not necessary in some browsers but safe
|
|
};
|
|
|
|
onMount(async() => {
|
|
if ($authTokenStore) {
|
|
// Fetch the match data that we need to display
|
|
await fetchMatchData();
|
|
// Using stateManger listen to global changes to our match
|
|
client.stateManager.on('matchDeleted', handleMatchDeleted);
|
|
client.stateManager.on('matchUpdated', handleMatchUpdated);
|
|
client.stateManager.on('userDisconnected', handleUserDisconnected);
|
|
client.stateManager.on('userUpdated', handleUserUpdated);
|
|
client.on('realtimeScore', handleRealtimeScoreUpdate);
|
|
client.on('songFinished', handleSongFinished);
|
|
window.addEventListener('beforeunload', beforeUnloadHandler);
|
|
} else {
|
|
window.location.href = "/discordAuth"
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
// Remove the listeners that were added on mount
|
|
client.stateManager.removeListener('matchUpdated', handleMatchUpdated);
|
|
client.stateManager.removeListener('matchDeleted', handleMatchDeleted);
|
|
client.stateManager.removeListener('userDisconnected', handleUserDisconnected);
|
|
client.stateManager.removeListener('userUpdated', handleUserUpdated);
|
|
client.removeListener('realtimeScore', handleRealtimeScoreUpdate);
|
|
client.removeListener('songFinished', handleSongFinished);
|
|
|
|
window.removeEventListener('beforeunload', beforeUnloadHandler);
|
|
});
|
|
</script>
|
|
|
|
<div class="layout">
|
|
<main class="match-dashboard">
|
|
<div class="content-area">
|
|
<div class="content-header">
|
|
<div class="header-left">
|
|
<button
|
|
class="back-button {isAnyPlayerPlaying ? 'disabled' : ''}"
|
|
on:click={() => handleBackToMatches(false)}
|
|
disabled={isAnyPlayerPlaying}
|
|
>
|
|
<span class="material-icons">arrow_back</span>
|
|
Back to Matches
|
|
{#if isAnyPlayerPlaying}
|
|
<span class="material-icons warning-icon">warning</span>
|
|
{/if}
|
|
</button>
|
|
<button class="kick-button" on:click={() => handleBackToMatches(true)}>
|
|
<span class="material-icons">person_remove</span>
|
|
End Match
|
|
</button>
|
|
<div class="match-info">
|
|
<h2>Match: {match?.guid || 'Undefined'}</h2>
|
|
<p class="tournament-name">{tournament?.settings?.tournamentName}</p>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="action-button refresh" on:click={fetchMatchData}>
|
|
<span class="material-icons">refresh</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if isLoading}
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<p>Loading match data...</p>
|
|
</div>
|
|
{:else if error}
|
|
<div class="error-container">
|
|
<span class="material-icons">error_outline</span>
|
|
<p>{error}</p>
|
|
<button class="retry-button" on:click={fetchMatchData}>Retry</button>
|
|
</div>
|
|
{:else}
|
|
<div class="match-content">
|
|
<!-- Current Song Section -->
|
|
<div class="song-section">
|
|
<h3>Current Song</h3>
|
|
{#if currentSong && currentSongData}
|
|
<div class="current-song">
|
|
<div class="song-cover">
|
|
<img src={currentSongData.versions[0].coverURL || '/default-song-cover.png'} alt="Song Cover" />
|
|
</div>
|
|
<div class="song-info">
|
|
<h4>{currentSong.beatmap.name || 'Unknown Song'}</h4>
|
|
<p>{currentSongData.metadata.songAuthorName || 'Unknown Artist'}</p>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="empty-state">
|
|
<span class="material-icons">music_note</span>
|
|
<p>No song selected</p>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if failedToLoadSongForPlayers}
|
|
<div class="warning-box">
|
|
<span class="material-icons">warning</span>
|
|
<div>
|
|
<strong>FAILED TO LOAD SONG FOR ALL PLAYERS!</strong> You can still start the map, but it will not start for those whos state is "Downloading"!
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if loadingSongForPlayers}
|
|
<div class="warning-box">
|
|
<span class="material-icons">warning</span>
|
|
<div>
|
|
<strong>LOADING SONG FOR PLAYERS!</strong> You can still start the map, but it will not start for those whos state is "Downloading"!
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if isAnyPlayerPlaying}
|
|
<div class="warning-box">
|
|
<span class="material-icons">warning</span>
|
|
<div>
|
|
<strong>PLAYERS ARE IN GAME!</strong> Some players are currently in game. Please refrain from loading new songs, starting maps, or exiting this match. You may of course add new maps to the queue etc.
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="song-controls">
|
|
<button class="action-button start-song" on:click={startSong} disabled={!currentSong}>
|
|
<span class="material-icons">play_arrow</span>
|
|
Start Song
|
|
</button>
|
|
<button
|
|
class="action-button start-sync {!streamSyncEnabled ? 'disabled' : ''}"
|
|
on:click={startSongWithStreamSync}
|
|
disabled={!streamSyncEnabled || !currentSong}
|
|
>
|
|
<span class="material-icons">sync</span>
|
|
Start with Stream Sync
|
|
</button>
|
|
{#if isAnyPlayerPlaying}
|
|
<button class="action-button back-to-menu" on:click={handleBackToMenu}>
|
|
<span class="material-icons">home</span>
|
|
Back to Menu
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Real Time Score Display -->
|
|
{#if matchPlayers.some(player => (player.playState) === User_PlayStates.InGame)}
|
|
<div class="rts-section">
|
|
<div class="players-list">
|
|
{#each realTimeScores as rtsPlayer}
|
|
<div class="player-card">
|
|
<div class="player-status" style="background-color: {getStatusColor(rtsPlayer.player.playState, rtsPlayer.player.downloadState)}"></div>
|
|
<img
|
|
src={rtsPlayer.player.discordInfo?.avatarUrl || "https://api.dicebear.com/7.x/identicon/svg?seed=" + rtsPlayer.player.guid}
|
|
alt="Player"
|
|
class="player-avatar"
|
|
/>
|
|
<div class="player-info">
|
|
<h4>{rtsPlayer.player.name} ({rtsPlayer.player.discordInfo?.username || 'Discord Unknown'})</h4>
|
|
<p class="player-status-text">{(rtsPlayer.recentScore.accuracy * 100).toFixed(2)}%</p>
|
|
<p class="player-status-text">{rtsPlayer.recentScore.notesMissed} Misses - {rtsPlayer.recentScore.badCuts} Bad Cuts - {rtsPlayer.recentScore.bombHits} Bomb Hits - {rtsPlayer.recentScore.wallHits} Wall Hits</p>
|
|
</div>
|
|
<div class="player-info">
|
|
<h4>Song Progress: {((rtsPlayer.recentScore.songPosition / currentSongData.metadata.duration) * 100).toFixed(2)}%</h4>
|
|
<p>{getTimeStringFromSeconds(rtsPlayer.recentScore.songPosition)} of {getTimeStringFromSeconds(currentSongData.metadata.duration)}</p>
|
|
</div>
|
|
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Players Section -->
|
|
<div class="players-section">
|
|
<h3>Players ({matchPlayers.length})</h3>
|
|
{#if matchPlayers.length === 0}
|
|
<div class="empty-state">
|
|
<span class="material-icons">person_off</span>
|
|
<p>No players in match</p>
|
|
</div>
|
|
{:else}
|
|
<div class="players-list">
|
|
{#each matchPlayers as player}
|
|
<div class="player-card">
|
|
<div class="player-status" style="background-color: {getStatusColor(player.playState, player.downloadState)}"></div>
|
|
<img
|
|
src={player.discordInfo?.avatarUrl || "https://api.dicebear.com/7.x/identicon/svg?seed=" + player.guid}
|
|
alt="Player"
|
|
class="player-avatar"
|
|
/>
|
|
<div class="player-info">
|
|
<h4>{player.name} ({player.discordInfo?.username || 'Unknown'})</h4>
|
|
<p class="player-status-text">{User_DownloadStates[player.downloadState]}</p>
|
|
<p class="player-status-text">{User_PlayStates[player.playState]}</p>
|
|
</div>
|
|
<div style="display: inline-block; margin-right: 0.4rem;">
|
|
<button class="kick-button" on:click={() => handleKickPlayer(player)}>
|
|
<span class="material-icons">person_remove</span>
|
|
</button>
|
|
<button class="modify-button" on:click={() => handleModifyGameplayForPlayer(player)}>
|
|
<span class="material-icons">settings</span>
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Song Upcoming Queue Section -->
|
|
<div class="queue-section">
|
|
<SongQueue
|
|
bind:maps={upcomingQueueMaps}
|
|
on:addMap={handleAddMap}
|
|
on:editMap={handleEditMap}
|
|
on:removeMap={handleRemoveMapFromQueue}
|
|
on:songLoad={handleLoadMap}
|
|
on:addMapsFromTAPool={handleShowAddMapsFromTAPool}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Previously Played Maps Section -->
|
|
<div class="queue-section">
|
|
<PreviouslyPlayedSongs
|
|
maps={previousMatchResults}
|
|
on:showMapDetails={showResultsPopupWithOldScores}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
{#if showMapModal}
|
|
<EditMap
|
|
tournamentGuid={tournamentGuid}
|
|
map={currentlyEditingMap}
|
|
on:close={closeEditMapModal}
|
|
on:mapUpdated={handleMapUpdated}
|
|
on:mapAdded={handleMapAdded}
|
|
mode='playing'
|
|
/>
|
|
{/if}
|
|
|
|
<Popup
|
|
open={showKickConfirmModal || showBackToMenuModal || showExitMatchModal}
|
|
message={modalMessage}
|
|
icon={modalIcon}
|
|
iconColor={modalIconColor}
|
|
buttons={modalButtons}
|
|
customImage={modalCustomImage}
|
|
autoClose={modalAutoClose}
|
|
/>
|
|
|
|
<ResultsForMap
|
|
mapData={showingResultsData}
|
|
isVisible={showResultsPopup && !dontShowResultsPopupForThisSong}
|
|
on:close={handleExitResultsPopupClicked}
|
|
/>
|
|
|
|
{#if allowAddFromTAPool}
|
|
<AddMapsFromTAPool
|
|
isOpen={showAddFromTAPoolPopup}
|
|
mapPools={customTAPools}
|
|
on:close={handleCloseAddMapsFromTAPool}
|
|
on:addMaps={handleAddMapsFromTAPool}
|
|
/>
|
|
{/if}
|
|
|
|
<ModifyGameplay
|
|
bind:isVisible={isModifyGameplayPopupVisible}
|
|
playerName={editingPlayerName}
|
|
playerPfp={editingPlayerPfp}
|
|
playerPlatformId={editingPlayerPlatformId}
|
|
on:sendModifyGameplay={handleSendModifyGameplay}
|
|
/>
|
|
|
|
<style>
|
|
.match-dashboard {
|
|
display: flex;
|
|
height: calc(100vh - var(--navbar-height));
|
|
}
|
|
|
|
.content-area {
|
|
flex: 1;
|
|
padding: 1.5rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.content-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.back-button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.375rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
border: none;
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.back-button:hover:not(.disabled) {
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
.back-button.disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.warning-icon {
|
|
color: var(--danger-color);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.match-info h2 {
|
|
font-size: 1.75rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.tournament-name {
|
|
color: var(--text-secondary);
|
|
font-size: 0.875rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.action-button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.375rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
border: none;
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.action-button.refresh:hover {
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
.action-button.start-song {
|
|
background-color: var(--accent-color);
|
|
}
|
|
|
|
.action-button.start-song:hover:not(:disabled) {
|
|
background-color: var(--accent-hover);
|
|
box-shadow: 0 0 10px var(--accent-glow);
|
|
}
|
|
|
|
.action-button.start-sync {
|
|
background-color: #8B5CF6;
|
|
}
|
|
|
|
.action-button.start-sync:hover:not(.disabled) {
|
|
background-color: #7C3AED;
|
|
box-shadow: 0 0 10px rgba(139, 92, 246, 0.5);
|
|
}
|
|
|
|
.action-button.back-to-menu {
|
|
background-color: #F59E0B;
|
|
}
|
|
|
|
.action-button.back-to-menu:hover {
|
|
background-color: #D97706;
|
|
box-shadow: 0 0 10px rgba(245, 158, 11, 0.5);
|
|
}
|
|
|
|
.action-button.disabled,
|
|
.action-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Match Content Layout */
|
|
.match-content {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
grid-template-rows: auto auto;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.song-section {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.rts-section {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.players-section {
|
|
grid-column: 1;
|
|
}
|
|
|
|
.queue-section {
|
|
grid-column: 2;
|
|
background-color: var(--bg-secondary);
|
|
border-radius: 0.75rem;
|
|
padding-top: 1rem;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
max-height: 20rem;
|
|
overflow-y: scroll;
|
|
}
|
|
|
|
/* Section Styles */
|
|
.song-section, .players-section {
|
|
background-color: var(--bg-secondary);
|
|
border-radius: 0.75rem;
|
|
padding: 1.5rem;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.song-section h3, .players-section h3, .queue-section h3 {
|
|
font-size: 1.25rem;
|
|
font-weight: 500;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
/* Current Song */
|
|
.current-song {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
background-color: var(--bg-tertiary);
|
|
padding: 1rem;
|
|
border-radius: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.song-cover {
|
|
width: 4rem;
|
|
height: 4rem;
|
|
border-radius: 0.5rem;
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.song-cover img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.song-info h4 {
|
|
font-size: 1.125rem;
|
|
font-weight: 500;
|
|
margin: 0 0 0.25rem 0;
|
|
}
|
|
|
|
.song-info p {
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
}
|
|
|
|
.song-controls {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* Players List */
|
|
.players-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
max-height: 20rem;
|
|
overflow-y: scroll;
|
|
}
|
|
|
|
.player-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 1rem;
|
|
background-color: var(--bg-tertiary);
|
|
border-radius: 0.5rem;
|
|
}
|
|
|
|
.player-status {
|
|
width: 0.75rem;
|
|
height: 0.75rem;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.player-avatar {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.player-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.player-info h4 {
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
margin: 0 0 0.25rem 0;
|
|
}
|
|
|
|
.player-status-text {
|
|
color: var(--text-secondary);
|
|
font-size: 0.875rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.kick-button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.375rem 0.75rem;
|
|
background-color: var(--danger-color);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.kick-button:hover {
|
|
background-color: var(--danger-hover);
|
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5);
|
|
}
|
|
|
|
.modify-button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.375rem 0.75rem;
|
|
background-color: var(--accent-color);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.modify-button:hover {
|
|
background-color: var(--accent-hover);
|
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5);
|
|
}
|
|
|
|
/* Empty State */
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 2rem;
|
|
color: var(--text-secondary);
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.empty-state .material-icons {
|
|
font-size: 2.5rem;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
/* Loading */
|
|
.loading {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 50vh;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.spinner {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border: 3px solid rgba(79, 70, 229, 0.2);
|
|
border-radius: 50%;
|
|
border-top-color: var(--accent-color);
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Error Container */
|
|
.error-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 2rem;
|
|
background-color: rgba(239, 68, 68, 0.1);
|
|
border-radius: 0.75rem;
|
|
color: var(--danger-color);
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.error-container .material-icons {
|
|
font-size: 2.5rem;
|
|
}
|
|
|
|
.retry-button {
|
|
padding: 0.5rem 1rem;
|
|
background-color: var(--danger-color);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
.retry-button:hover {
|
|
background-color: var(--danger-hover);
|
|
}
|
|
|
|
/* Modal Styles */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(0, 0, 0, 0.7);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal {
|
|
background-color: var(--bg-secondary);
|
|
border-radius: 0.75rem;
|
|
max-width: 400px;
|
|
width: 90%;
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 1.5rem 1.5rem 1rem 1.5rem;
|
|
}
|
|
|
|
.modal-header .material-icons.warning {
|
|
color: var(--danger-color);
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.modal-header h3 {
|
|
font-size: 1.25rem;
|
|
font-weight: 500;
|
|
margin: 0;
|
|
}
|
|
|
|
.modal-content {
|
|
padding: 0 1.5rem 1rem 1.5rem;
|
|
}
|
|
|
|
.modal-content p {
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
padding: 1rem 1.5rem 1.5rem 1.5rem;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.modal-button {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.375rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
border: none;
|
|
}
|
|
|
|
.modal-button.cancel {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.modal-button.cancel:hover {
|
|
background-color: var(--bg-primary);
|
|
}
|
|
|
|
.modal-button.confirm {
|
|
background-color: var(--danger-color);
|
|
color: white;
|
|
}
|
|
|
|
.modal-button.confirm:hover {
|
|
background-color: var(--danger-hover);
|
|
}
|
|
|
|
.warning-box {
|
|
border-radius: 0.5rem;
|
|
padding: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: flex-start;
|
|
background-color: rgba(245, 158, 11, 0.1);
|
|
border-left: 4px solid #f59e0b;
|
|
}
|
|
|
|
/* Material Icons */
|
|
.material-icons {
|
|
font-family: 'Material Icons';
|
|
font-weight: normal;
|
|
font-style: normal;
|
|
font-size: 24px;
|
|
line-height: 1;
|
|
letter-spacing: normal;
|
|
text-transform: none;
|
|
display: inline-block;
|
|
white-space: nowrap;
|
|
word-wrap: normal;
|
|
direction: ltr;
|
|
-webkit-font-feature-settings: 'liga';
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
/* Responsive Design */
|
|
@media (max-width: 768px) {
|
|
.match-content {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.players-section {
|
|
grid-column: 1;
|
|
}
|
|
|
|
.queue-section {
|
|
grid-column: 1;
|
|
}
|
|
|
|
.song-controls {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.current-song {
|
|
flex-direction: column;
|
|
text-align: center;
|
|
}
|
|
|
|
.player-card {
|
|
flex-direction: column;
|
|
text-align: center;
|
|
gap: 0.75rem;
|
|
}
|
|
}
|
|
</style> |