PWA fixed
added systemd service howto traefik nginix set_real_ip_from improved readme visuals fixed on mobile labels removed updated readme fixed visuals overlay for the hotkey disable screen lock clean up git precommit hooks clean up clean up update check for update feature added build-time information fixed date clean up added hook script fix fix fix hooks fixed webhook setup players stay in run all timers mode mqtt mqtt allways connected mqtt messages work capturing mqtt in edit player mqtt in Setup updated readme state of the mqtt Global Pass turn offline mode docs: update documentation to reflect current codebase and MQTT features - Update README.md with global MQTT commands - Enhance architecture.md with comprehensive data model and MQTT state - Update development.md with project structure and workflow - Remove redundant script listings - Fix formatting and organization rebase
This commit is contained in:
263
src/views/GameView.vue
Normal file
263
src/views/GameView.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-screen overflow-hidden" :class="{'dark': theme === 'dark'}">
|
||||
<!-- Header Bar -->
|
||||
<header class="p-3 bg-gray-100 dark:bg-gray-800 shadow-md flex justify-between items-center shrink-0">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="navigateToSetup" class="btn btn-secondary text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-1" viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" /></svg>
|
||||
Setup
|
||||
</button>
|
||||
<button v-if="gameMode === 'normal'" @click="switchToAllTimersMode" class="btn btn-warning text-sm">
|
||||
Run All Timers
|
||||
</button>
|
||||
<button v-if="gameMode === 'allTimers'" @click="switchToNormalMode" class="btn btn-warning text-sm">
|
||||
Back to Normal Mode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="navigateToInfo" class="btn-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="toggleMute" class="btn-icon">
|
||||
<svg v-if="!isMuted" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" /></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15zm9.949-9.949a1 1 0 00-1.414 0L12 7.05l-2.085-2.085a1 1 0 00-1.414 1.414L10.586 8.5l-2.085 2.085a1 1 0 001.414 1.414L12 9.914l2.085 2.085a1 1 0 001.414-1.414L13.414 8.5l2.085-2.085a1 1 0 000-1.414z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow overflow-hidden flex flex-col" ref="gameArea">
|
||||
<!-- Normal Mode -->
|
||||
<div v-if="gameMode === 'normal' && currentPlayer && nextPlayer" class="h-full flex flex-col">
|
||||
<PlayerDisplay
|
||||
:player="currentPlayer"
|
||||
is-current-player-area
|
||||
area-class="bg-gray-100 dark:bg-gray-800"
|
||||
@tapped="handleCurrentPlayerTap"
|
||||
class="flex-1 min-h-0"
|
||||
/>
|
||||
<div class="shrink-0 h-1 bg-blue-500"></div>
|
||||
<PlayerDisplay
|
||||
:player="nextPlayer"
|
||||
is-next-player-area
|
||||
area-class="bg-gray-200 dark:bg-gray-700"
|
||||
@swiped-up="handlePassTurn"
|
||||
class="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- All Timers Running Mode -->
|
||||
<div v-if="gameMode === 'allTimers'" class="p-4 h-full flex flex-col">
|
||||
<div class="mb-4 flex justify-start items-center">
|
||||
<h2 class="text-2xl font-semibold">All Timers Mode</h2>
|
||||
</div>
|
||||
<div class="flex-grow overflow-y-auto space-y-2">
|
||||
<!-- Use playersToListInAllTimersMode -->
|
||||
<PlayerListItem
|
||||
v-for="(player) in playersToListInAllTimersMode"
|
||||
:key="player.id"
|
||||
:player="player"
|
||||
@tapped="() => handlePlayerTapAllTimers(indexInFullList(player.id))"
|
||||
/>
|
||||
<p v-if="playersToListInAllTimersMode.length === 0 && players.length > 0 && anyTimerCouldRun" class="text-center text-gray-500 dark:text-gray-400 py-6">
|
||||
All players are skipped or an issue occurred.
|
||||
</p>
|
||||
<p v-if="playersToListInAllTimersMode.length === 0 && players.length > 0 && !anyTimerCouldRun" class="text-center text-gray-500 dark:text-gray-400 py-6">
|
||||
All players are skipped.
|
||||
</p>
|
||||
<p v-if="players.length === 0" class="text-center text-gray-500 dark:text-gray-400 py-6">
|
||||
No players to display.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PlayerDisplay from '../components/PlayerDisplay.vue';
|
||||
import PlayerListItem from '../components/PlayerListItem.vue';
|
||||
import { AudioService } from '../services/AudioService';
|
||||
import { WakeLockService } from '../services/WakeLockService';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const theme = computed(() => store.getters.theme);
|
||||
const players = computed(() => store.getters.players);
|
||||
const currentPlayer = computed(() => store.getters.currentPlayer);
|
||||
const nextPlayer = computed(() => store.getters.nextPlayer);
|
||||
const gameMode = computed(() => store.getters.gameMode);
|
||||
const isMuted = computed(() => store.getters.isMuted);
|
||||
const gameRunning = computed(() => store.getters.gameRunning);
|
||||
|
||||
let timerInterval = null;
|
||||
|
||||
// Shows all non-skipped players when in 'allTimers' mode.
|
||||
const playersToListInAllTimersMode = computed(() => {
|
||||
if (gameMode.value === 'allTimers' && players.value) {
|
||||
return players.value.filter(p => !p.isSkipped);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// This computed is still used for audio logic and auto-revert
|
||||
const anyTimerRunningInAllMode = computed(() => {
|
||||
if (!players.value) return false;
|
||||
return players.value.some(p => p.isTimerRunning && !p.isSkipped);
|
||||
});
|
||||
|
||||
const anyTimerCouldRun = computed(() => {
|
||||
if (!players.value) return false;
|
||||
return players.value.some(p => !p.isSkipped);
|
||||
});
|
||||
|
||||
|
||||
const indexInFullList = (playerId) => {
|
||||
if (!players.value) return -1;
|
||||
return players.value.findIndex(p => p.id === playerId);
|
||||
}
|
||||
|
||||
// ... (onMounted, onUnmounted, watchers, navigation, and other methods remain the same)
|
||||
onMounted(async () => {
|
||||
if (!players.value || players.value.length < 2) {
|
||||
router.push({ name: 'Setup' });
|
||||
return;
|
||||
}
|
||||
|
||||
timerInterval = setInterval(() => {
|
||||
store.dispatch('tick');
|
||||
}, 1000);
|
||||
|
||||
if (gameRunning.value) {
|
||||
await WakeLockService.request();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
clearInterval(timerInterval);
|
||||
AudioService.stopContinuousTick();
|
||||
AudioService.cancelPassTurnSound();
|
||||
await WakeLockService.release();
|
||||
});
|
||||
|
||||
watch(gameRunning, async (isRunning) => {
|
||||
if (isRunning) {
|
||||
await WakeLockService.request();
|
||||
} else {
|
||||
if (!WakeLockService.isActive()) return;
|
||||
setTimeout(async () => {
|
||||
if (!store.getters.gameRunning && WakeLockService.isActive()) {
|
||||
await WakeLockService.release();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
watch(gameMode, (newMode) => {
|
||||
AudioService.stopContinuousTick();
|
||||
AudioService.cancelPassTurnSound();
|
||||
|
||||
if (newMode === 'allTimers') {
|
||||
if (anyTimerRunningInAllMode.value) {
|
||||
AudioService.startContinuousTick();
|
||||
}
|
||||
} else {
|
||||
if (currentPlayer.value && currentPlayer.value.isTimerRunning) {
|
||||
AudioService.playPassTurnAlert();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(anyTimerRunningInAllMode, (isRunning) => {
|
||||
if (gameMode.value === 'allTimers') {
|
||||
if (isRunning) {
|
||||
AudioService.startContinuousTick();
|
||||
} else {
|
||||
AudioService.stopContinuousTick();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentPlayer, (newPlayer, oldPlayer) => {
|
||||
if (gameMode.value === 'normal' && newPlayer && newPlayer.isTimerRunning && oldPlayer && newPlayer.id !== oldPlayer.id) {
|
||||
AudioService.playPassTurnAlert();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
watch(() => currentPlayer.value?.isTimerRunning, (isRunning, wasRunning) => {
|
||||
if (gameMode.value === 'normal' && currentPlayer.value) {
|
||||
if (isRunning === true && wasRunning === false) {
|
||||
AudioService.playPassTurnAlert();
|
||||
} else if (isRunning === false && wasRunning === true) {
|
||||
AudioService.cancelPassTurnSound();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const navigateToSetup = async () => {
|
||||
await WakeLockService.release();
|
||||
const isAnyTimerActive = store.getters.gameRunning;
|
||||
if (isAnyTimerActive) {
|
||||
if (window.confirm('Game is active. Going to Setup will pause all timers. Continue?')) {
|
||||
store.commit('PAUSE_ALL_TIMERS');
|
||||
router.push({ name: 'Setup' });
|
||||
}
|
||||
} else {
|
||||
router.push({ name: 'Setup' });
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToInfo = async () => {
|
||||
await WakeLockService.release();
|
||||
if (store.getters.gameRunning) {
|
||||
store.commit('PAUSE_ALL_TIMERS');
|
||||
}
|
||||
router.push({ name: 'Info' });
|
||||
};
|
||||
|
||||
const toggleMute = () => { store.dispatch('setMuted', !isMuted.value); };
|
||||
const handleCurrentPlayerTap = () => { store.dispatch('toggleCurrentPlayerTimerNormalMode'); };
|
||||
const handlePassTurn = () => {
|
||||
if(currentPlayer.value && !currentPlayer.value.isSkipped) {
|
||||
AudioService.cancelPassTurnSound();
|
||||
const wasRunning = currentPlayer.value.isTimerRunning;
|
||||
store.dispatch('passTurn').then(() => {
|
||||
const newCurrentPlayer = store.getters.currentPlayer;
|
||||
if (wasRunning && gameMode.value === 'normal' && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
|
||||
AudioService.playPassTurnAlert();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const handlePlayerTapAllTimers = (playerIndex) => { store.dispatch('togglePlayerTimerAllTimersMode', playerIndex); };
|
||||
const switchToAllTimersMode = () => { store.dispatch('switchToAllTimersMode'); };
|
||||
const switchToNormalMode = () => { store.dispatch('switchToNormalMode'); };
|
||||
|
||||
// Auto-revert logic
|
||||
watch(anyTimerRunningInAllMode, (anyRunning) => {
|
||||
// Only revert if we are IN allTimers mode and NO timers are running
|
||||
if (gameMode.value === 'allTimers' && !anyRunning && players.value && players.value.length > 0) {
|
||||
const nonSkippedPlayersExist = players.value.some(p => !p.isSkipped);
|
||||
if (nonSkippedPlayersExist) {
|
||||
setTimeout(() => {
|
||||
// Double check condition before switching, state might change rapidly
|
||||
if(gameMode.value === 'allTimers' && !store.getters.players.some(p => p.isTimerRunning && !p.isSkipped)){
|
||||
console.log("All timers paused in AllTimersMode, reverting to Normal Mode.");
|
||||
store.dispatch('switchToNormalMode');
|
||||
}
|
||||
}, 250); // A small delay to prevent flickering if a timer is immediately restarted
|
||||
} else {
|
||||
// All players are skipped, so stay in all timers mode but paused.
|
||||
console.log("All players skipped in AllTimersMode, staying paused.");
|
||||
AudioService.stopContinuousTick();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
127
src/views/InfoView.vue
Normal file
127
src/views/InfoView.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="flex-grow flex flex-col p-4 md:p-6 lg:p-8 items-center dark:bg-gray-800 text-gray-700 dark:text-gray-200">
|
||||
<header class="w-full max-w-2xl mb-2 text-center">
|
||||
<h1 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-1">About Nexus Timer</h1>
|
||||
<div class="flex items-center justify-center space-x-2 text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
<span>Build: <span class="font-mono">{{ buildTime }}</span></span>
|
||||
<button
|
||||
v-if="canCheckForUpdate"
|
||||
@click="checkForUpdates"
|
||||
class="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 underline text-xs"
|
||||
:disabled="checkingForUpdate"
|
||||
>
|
||||
{{ checkingForUpdate ? 'Checking...' : 'Check for Update' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="updateStatusMessage" class="text-sm mt-1" :class="updateError ? 'text-red-500' : 'text-green-500'">
|
||||
{{ updateStatusMessage }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="w-full max-w-2xl bg-white dark:bg-gray-700 p-6 rounded-lg shadow-md prose dark:prose-invert">
|
||||
<p>
|
||||
Nexus Timer is a dynamic multi-player timer designed for games, workshops, or any activity where turns pass sequentially in a circular fashion.
|
||||
It provides a clear visual focus on the current participant and their immediate successor, ensuring everyone stays engaged and aware of who is next.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-6 mb-2">Key Features:</h2>
|
||||
<ul>
|
||||
<li>Circular player display focusing on Current and Next player.</li>
|
||||
<li>Individual customizable countdown timers (MM:SS) per player.</li>
|
||||
<li>Timers can go into negative time.</li>
|
||||
<li>Two game modes: Normal (one timer runs) and All Timers Running.</li>
|
||||
<li>Player management: Add, edit, delete, reorder (max 99 players).</li>
|
||||
<li>Photo avatars using device camera or defaults.</li>
|
||||
<li>Configurable hotkeys for passing turns and global actions.</li>
|
||||
<li>**MQTT integration for remote control using unique characters per action.**</li>
|
||||
<li>Audio feedback for timer events.</li>
|
||||
<li>Light/Dark theme options.</li>
|
||||
<li>Persistent storage of game state.</li>
|
||||
<li>Screen Wake Lock to keep screen on during active gameplay.</li>
|
||||
<li>PWA installability with update checks.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-6 mb-2">Source Code</h2>
|
||||
<p>
|
||||
The source code for Nexus Timer is available:
|
||||
<a href="https://gitea.virtonline.eu/2HoursProject/nexus-timer.git" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:underline">
|
||||
https://gitea.virtonline.eu/2HoursProject/nexus-timer.git
|
||||
</a>
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<footer class="mt-8 w-full max-w-2xl text-center">
|
||||
<button @click="goBack" class="btn btn-primary">
|
||||
Back to Game
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const buildTime = ref('N/A');
|
||||
|
||||
onMounted(() => {
|
||||
buildTime.value = import.meta.env.VITE_APP_BUILD_TIME || 'Unknown Build Time';
|
||||
});
|
||||
|
||||
const canCheckForUpdate = ref('serviceWorker' in navigator);
|
||||
const checkingForUpdate = ref(false);
|
||||
const updateStatusMessage = ref('');
|
||||
const updateError = ref(false);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
updateStatusMessage.value = 'Service Worker API not supported.';
|
||||
updateError.value = true;
|
||||
return;
|
||||
}
|
||||
checkingForUpdate.value = true;
|
||||
updateStatusMessage.value = 'Checking for updates...';
|
||||
updateError.value = false;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (!registration) {
|
||||
updateStatusMessage.value = 'No active service worker. Try reloading.';
|
||||
updateError.value = true;
|
||||
checkingForUpdate.value = false;
|
||||
return;
|
||||
}
|
||||
await registration.update();
|
||||
setTimeout(() => {
|
||||
const newWorker = registration.waiting;
|
||||
if (newWorker) {
|
||||
updateStatusMessage.value = 'New version downloaded! Refresh prompt may appear.';
|
||||
updateError.value = false;
|
||||
} else if (registration.active && registration.installing) {
|
||||
updateStatusMessage.value = 'New version installing...';
|
||||
updateError.value = false;
|
||||
} else {
|
||||
updateStatusMessage.value = 'You are on the latest version.';
|
||||
updateError.value = false;
|
||||
}
|
||||
checkingForUpdate.value = false;
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error checking for PWA updates:', error);
|
||||
updateStatusMessage.value = 'Error checking updates. See console.';
|
||||
updateError.value = true;
|
||||
checkingForUpdate.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (store.getters.players && store.getters.players.length >= 2) {
|
||||
router.push({ name: 'Game' });
|
||||
} else {
|
||||
router.push({ name: 'Setup' });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
417
src/views/SetupView.vue
Normal file
417
src/views/SetupView.vue
Normal file
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<div class="flex-grow flex flex-col p-4 md:p-6 lg:p-8 items-center dark:bg-gray-800">
|
||||
<header class="w-full max-w-3xl mb-6 text-center">
|
||||
<h1 class="text-4xl font-bold text-blue-600 dark:text-blue-400">Nexus Timer Setup</h1>
|
||||
</header>
|
||||
|
||||
<!-- Player Management (no changes) -->
|
||||
<section class="w-full max-w-3xl bg-white dark:bg-gray-700 p-6 rounded-lg shadow-md mb-6">
|
||||
<!-- ... Player list ... -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-semibold">Players ({{ players.length }})</h2>
|
||||
<button @click="openAddPlayerModal" class="btn btn-primary" :disabled="players.length >= 99">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-1" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /></svg>
|
||||
Add Player
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="players.length < 2" class="text-sm text-yellow-600 dark:text-yellow-400 mb-3">At least 2 players are required to start.</p>
|
||||
<div v-if="players.length > 0" class="space-y-3 mb-4">
|
||||
<div v-for="(player, index) in players" :key="player.id" class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-600 rounded shadow-sm">
|
||||
<div class="flex items-center flex-wrap">
|
||||
<img
|
||||
v-if="player.avatar"
|
||||
:src="player.avatar"
|
||||
alt="Player Avatar"
|
||||
class="w-10 h-10 rounded-full object-cover mr-3"
|
||||
/>
|
||||
<DefaultAvatarIcon v-else class="w-10 h-10 rounded-full object-cover mr-3 text-gray-400 bg-gray-200 p-0.5"/>
|
||||
<span class="font-medium mr-2">{{ player.name }}</span>
|
||||
<span class="mr-2 text-xs text-gray-500 dark:text-gray-400">({{ formatTime(player.currentTimerSec) }})</span>
|
||||
<span v-if="player.hotkey" class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-700 text-blue-700 dark:text-blue-200 rounded mr-1">
|
||||
HK: {{ player.hotkey.toUpperCase() }}
|
||||
</span>
|
||||
<span v-if="player.mqttChar" class="text-xs px-1.5 py-0.5 bg-purple-100 dark:bg-purple-700 text-purple-700 dark:text-purple-200 rounded">
|
||||
MQTT: {{ player.mqttChar.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-x-1 flex items-center flex-shrink-0">
|
||||
<button @click="movePlayerUp(index)" :disabled="index === 0" class="btn-icon"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" /></svg></button>
|
||||
<button @click="movePlayerDown(index)" :disabled="index === players.length - 1" class="btn-icon"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg></button>
|
||||
<button @click="openEditPlayerModal(player)" class="btn-icon text-yellow-500 hover:text-yellow-600"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" /><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" /></svg></button>
|
||||
<button @click="confirmDeletePlayer(player.id)" class="btn-icon text-red-500 hover:text-red-600"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-4">No players added. Add at least 2.</div>
|
||||
<div v-if="players.length > 1" class="flex space-x-2 mt-4">
|
||||
<button @click="shufflePlayers" class="btn btn-secondary text-sm">Shuffle Order</button>
|
||||
<button @click="reversePlayers" class="btn btn-secondary text-sm">Reverse Order</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="w-full max-w-3xl bg-white dark:bg-gray-700 p-6 rounded-lg shadow-md mb-6">
|
||||
<!-- MQTT Broker URL -->
|
||||
<div class="mb-6">
|
||||
<label for="mqttBrokerUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300">MQTT Broker URL</label>
|
||||
<div class="mt-1 flex rounded-md shadow-sm">
|
||||
<input type="text" id="mqttBrokerUrl" v-model="localMqttBrokerUrl" placeholder="ws://broker.example.com:9001" class="input-base flex-1 rounded-none rounded-l-md" :disabled="mqttConnectionStatus === 'connected' || mqttConnectionStatus === 'connecting'">
|
||||
<button @click="toggleMqttConnection"
|
||||
:class="['btn inline-flex items-center px-4 rounded-l-none rounded-r-md border border-l-0 text-white',
|
||||
mqttConnectionStatus === 'connected' ? 'bg-red-500 hover:bg-red-600 border-red-600' :
|
||||
mqttConnectionStatus === 'connecting' ? 'bg-yellow-500 hover:bg-yellow-600 border-yellow-600 !text-black' :
|
||||
'btn-primary border-blue-500']"
|
||||
>
|
||||
{{ mqttConnectionStatus === 'connected' ? 'Disconnect' : (mqttConnectionStatus === 'connecting' ? 'Stop' : 'Connect') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="mqttError" class="text-xs text-red-500 mt-1">{{ mqttError }}</p>
|
||||
<p v-else-if="mqttConnectionStatus === 'connected'" class="text-xs text-green-500 mt-1">Connected to {{ store.getters.mqttBrokerUrl }}. Subscribed to '{{ MqttService.MQTT_TOPIC_GAME }}'.</p>
|
||||
<p v-else-if="mqttConnectionStatus === 'connecting'" class="text-xs text-yellow-600 mt-1">Connecting to {{ localMqttBrokerUrl }}... (Click "Stop" to cancel)</p>
|
||||
<p v-else-if="mqttConnectionStatus === 'disconnected'" class="text-xs text-gray-500 mt-1">Not connected.</p>
|
||||
</div>
|
||||
|
||||
<!-- Global Triggers -->
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Global "Toggle Current/Pause All" Trigger:</p>
|
||||
<div class="flex items-center justify-between space-x-4 mb-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">Hotkey:</span>
|
||||
<button type="button" @click="startCaptureGlobalHotkey('stopPause')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalHotkeyStopPauseDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalHotkeyStopPause" type="button" @click="clearGlobalHotkey('stopPause')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">MQTT:</span>
|
||||
<button type="button" @click="startCaptureGlobalMqttChar('stopPause')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalMqttStopPauseDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalMqttStopPause" type="button" @click="clearGlobalMqttChar('stopPause')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Global "Run All Timers" Trigger:</p>
|
||||
<div class="flex items-center justify-between space-x-4 mb-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">Hotkey:</span>
|
||||
<button type="button" @click="startCaptureGlobalHotkey('runAll')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalHotkeyRunAllDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalHotkeyRunAll" type="button" @click="clearGlobalHotkey('runAll')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">MQTT:</span>
|
||||
<button type="button" @click="startCaptureGlobalMqttChar('runAll')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalMqttRunAllDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalMqttRunAll" type="button" @click="clearGlobalMqttChar('runAll')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Global "Pass Turn" Trigger:</p>
|
||||
<div class="flex items-center justify-between space-x-4 mb-6">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">Hotkey:</span>
|
||||
<button type="button" @click="startCaptureGlobalHotkey('passTurn')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalHotkeyPassTurnDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalHotkeyPassTurn" type="button" @click="clearGlobalHotkey('passTurn')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">MQTT:</span>
|
||||
<button type="button" @click="startCaptureGlobalMqttChar('passTurn')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalMqttPassTurnDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalMqttPassTurn" type="button" @click="clearGlobalMqttChar('passTurn')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-4 mt-6">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Dark Mode</span>
|
||||
<button @click="toggleTheme" class="px-3 py-1.5 rounded-md text-sm font-medium" :class="theme === 'dark' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-800'">
|
||||
{{ theme === 'dark' ? 'On' : 'Off' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Mute Audio</span>
|
||||
<button @click="toggleMute" class="px-3 py-1.5 rounded-md text-sm font-medium" :class="isMuted ? 'bg-red-500 text-white' : 'bg-gray-200 text-gray-800'">
|
||||
{{ isMuted ? 'Muted' : 'Unmuted' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="w-full max-w-3xl mt-4 space-y-3">
|
||||
<button @click="saveAndClose" class="w-full btn btn-primary btn-lg text-xl py-3" :disabled="players.length < 2 && players.length !==0">Save & Close</button>
|
||||
<button @click="resetPlayerTimersConfirm" class="w-full btn btn-warning py-2">Reset Player Timers</button>
|
||||
<button @click="fullResetAppConfirm" class="w-full btn btn-danger py-2">Reset Entire App Data</button>
|
||||
</section>
|
||||
<PlayerForm v-if="showPlayerModal" :player="editingPlayer" @close="closePlayerModal" @save="savePlayer"/>
|
||||
|
||||
<HotkeyCaptureOverlay
|
||||
:visible="isCapturingGlobalHotkey"
|
||||
@captured="handleGlobalHotkeyCaptured"
|
||||
@cancel="cancelCaptureGlobalHotkey"
|
||||
/>
|
||||
<MqttCharCaptureOverlay
|
||||
:visible="isCapturingGlobalMqttChar"
|
||||
@cancel="cancelCaptureGlobalMqttChar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trigger-clear-btn {
|
||||
@apply text-xs text-blue-500 hover:underline ml-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-600;
|
||||
min-width: 40px; /* Adjust this width to match the "Clear" button's typical width */
|
||||
display: inline-block; /* Important for width and centering if text-align used */
|
||||
text-align: center; /* Center the "Clear" text if button width is larger */
|
||||
}
|
||||
.trigger-clear-btn-placeholder {
|
||||
@apply ml-2 px-2 py-1; /* Match horizontal spacing and padding */
|
||||
min-width: 40px; /* Match the width of the actual clear button */
|
||||
display: inline-block; /* To take up space */
|
||||
visibility: hidden; /* Makes it take space but not be visible */
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PlayerForm from '../components/PlayerForm.vue';
|
||||
import { formatTime } from '../utils/timeFormatter';
|
||||
import DefaultAvatarIcon from '../components/DefaultAvatarIcon.vue';
|
||||
import HotkeyCaptureOverlay from '../components/HotkeyCaptureOverlay.vue';
|
||||
import MqttCharCaptureOverlay from '../components/MqttCharCaptureOverlay.vue';
|
||||
import { MqttService } from '../services/MqttService';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const players = computed(() => store.getters.players);
|
||||
const theme = computed(() => store.getters.theme);
|
||||
const isMuted = computed(() => store.getters.isMuted);
|
||||
|
||||
const localMqttBrokerUrl = ref(store.getters.mqttBrokerUrl);
|
||||
watch(() => store.getters.mqttBrokerUrl, (newUrl) => { localMqttBrokerUrl.value = newUrl; });
|
||||
const mqttConnectionStatus = MqttService.connectionStatus;
|
||||
const mqttError = MqttService.error;
|
||||
|
||||
const globalHotkeyStopPause = computed(() => store.getters.globalHotkeyStopPause);
|
||||
const globalHotkeyStopPauseDisplay = computed(() => globalHotkeyStopPause.value ? globalHotkeyStopPause.value.toUpperCase() : '');
|
||||
const globalMqttStopPause = computed(() => store.getters.globalMqttStopPause);
|
||||
const globalMqttStopPauseDisplay = computed(() => globalMqttStopPause.value ? globalMqttStopPause.value.toUpperCase() : '');
|
||||
|
||||
const globalHotkeyRunAll = computed(() => store.getters.globalHotkeyRunAll);
|
||||
const globalHotkeyRunAllDisplay = computed(() => globalHotkeyRunAll.value ? globalHotkeyRunAll.value.toUpperCase() : '');
|
||||
const globalMqttRunAll = computed(() => store.getters.globalMqttRunAll);
|
||||
const globalMqttRunAllDisplay = computed(() => globalMqttRunAll.value ? globalMqttRunAll.value.toUpperCase() : '');
|
||||
|
||||
const globalHotkeyPassTurn = computed(() => store.getters.globalHotkeyPassTurn);
|
||||
const globalHotkeyPassTurnDisplay = computed(() => globalHotkeyPassTurn.value ? globalHotkeyPassTurn.value.toUpperCase() : '');
|
||||
const globalMqttPassTurn = computed(() => store.getters.globalMqttPassTurn);
|
||||
const globalMqttPassTurnDisplay = computed(() => globalMqttPassTurn.value ? globalMqttPassTurn.value.toUpperCase() : '');
|
||||
|
||||
const showPlayerModal = ref(false);
|
||||
const editingPlayer = ref(null);
|
||||
|
||||
const openAddPlayerModal = () => {
|
||||
if (players.value.length < 99) {
|
||||
editingPlayer.value = null;
|
||||
showPlayerModal.value = true;
|
||||
} else {
|
||||
alert("Maximum player limit (99) reached.");
|
||||
}
|
||||
};
|
||||
const openEditPlayerModal = (player) => {
|
||||
editingPlayer.value = player;
|
||||
showPlayerModal.value = true;
|
||||
};
|
||||
const closePlayerModal = () => {
|
||||
showPlayerModal.value = false;
|
||||
editingPlayer.value = null;
|
||||
};
|
||||
const savePlayer = (playerData) => {
|
||||
if (playerData.id) {
|
||||
store.dispatch('updatePlayer', playerData);
|
||||
} else {
|
||||
store.dispatch('addPlayer', {
|
||||
name: playerData.name,
|
||||
avatar: playerData.avatar,
|
||||
initialTimerSec: playerData.initialTimerSec,
|
||||
currentTimerSec: playerData.currentTimerSec,
|
||||
hotkey: playerData.hotkey,
|
||||
mqttChar: playerData.mqttChar
|
||||
});
|
||||
}
|
||||
closePlayerModal();
|
||||
};
|
||||
const confirmDeletePlayer = (playerId) => {
|
||||
if (window.confirm('Are you sure you want to delete this player?')) {
|
||||
store.dispatch('deletePlayer', playerId);
|
||||
}
|
||||
};
|
||||
const shufflePlayers = () => { store.dispatch('shufflePlayers'); };
|
||||
const reversePlayers = () => { store.dispatch('reversePlayers'); };
|
||||
const movePlayerUp = (index) => {
|
||||
if (index > 0) {
|
||||
const newPlayersOrder = [...players.value];
|
||||
const temp = newPlayersOrder[index];
|
||||
newPlayersOrder[index] = newPlayersOrder[index - 1];
|
||||
newPlayersOrder[index - 1] = temp;
|
||||
store.dispatch('reorderPlayers', newPlayersOrder);
|
||||
}
|
||||
};
|
||||
const movePlayerDown = (index) => {
|
||||
if (index < players.value.length - 1) {
|
||||
const newPlayersOrder = [...players.value];
|
||||
const temp = newPlayersOrder[index];
|
||||
newPlayersOrder[index] = newPlayersOrder[index + 1];
|
||||
newPlayersOrder[index + 1] = temp;
|
||||
store.dispatch('reorderPlayers', newPlayersOrder);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const isCapturingGlobalHotkey = ref(false);
|
||||
const isCapturingGlobalMqttChar = ref(false);
|
||||
const currentGlobalActionType = ref(null); // 'stopPause', 'runAll', or 'passTurn'
|
||||
|
||||
|
||||
const startCaptureGlobalHotkey = (actionType) => {
|
||||
currentGlobalActionType.value = actionType;
|
||||
isCapturingGlobalHotkey.value = true;
|
||||
};
|
||||
const cancelCaptureGlobalHotkey = () => {
|
||||
isCapturingGlobalHotkey.value = false;
|
||||
currentGlobalActionType.value = null;
|
||||
};
|
||||
const clearGlobalHotkey = (actionType) => {
|
||||
if (actionType === 'stopPause') store.dispatch('setGlobalHotkeyStopPause', null);
|
||||
else if (actionType === 'runAll') store.dispatch('setGlobalHotkeyRunAll', null);
|
||||
else if (actionType === 'passTurn') store.dispatch('setGlobalHotkeyPassTurn', null);
|
||||
};
|
||||
const handleGlobalHotkeyCaptured = (key) => {
|
||||
isCapturingGlobalHotkey.value = false;
|
||||
const actionType = currentGlobalActionType.value;
|
||||
if (!actionType || key.length !== 1) { currentGlobalActionType.value = null; return; }
|
||||
let conflictMessage = '';
|
||||
if (store.state.players.some(p => p.hotkey === key)) {
|
||||
conflictMessage = `Hotkey "${key.toUpperCase()}" is already used by a player.`;
|
||||
}
|
||||
else if (actionType === 'stopPause') {
|
||||
if (store.state.globalHotkeyRunAll === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Run All Timers Hotkey.`;
|
||||
if (store.state.globalHotkeyPassTurn === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Pass Turn Hotkey.`;
|
||||
} else if (actionType === 'runAll') {
|
||||
if (store.state.globalHotkeyStopPause === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Stop/Pause Hotkey.`;
|
||||
if (store.state.globalHotkeyPassTurn === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Pass Turn Hotkey.`;
|
||||
} else if (actionType === 'passTurn') {
|
||||
if (store.state.globalHotkeyStopPause === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Stop/Pause Hotkey.`;
|
||||
if (store.state.globalHotkeyRunAll === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Run All Hotkey.`;
|
||||
}
|
||||
if (conflictMessage) { alert(conflictMessage); currentGlobalActionType.value = null; return; }
|
||||
if (actionType === 'stopPause') store.dispatch('setGlobalHotkeyStopPause', key);
|
||||
else if (actionType === 'runAll') store.dispatch('setGlobalHotkeyRunAll', key);
|
||||
else if (actionType === 'passTurn') store.dispatch('setGlobalHotkeyPassTurn', key);
|
||||
currentGlobalActionType.value = null;
|
||||
};
|
||||
|
||||
const startCaptureGlobalMqttChar = (actionType) => {
|
||||
if (MqttService.connectionStatus.value !== 'connected') {
|
||||
alert('MQTT broker is not connected. Please connect first.');
|
||||
return;
|
||||
}
|
||||
currentGlobalActionType.value = actionType;
|
||||
isCapturingGlobalMqttChar.value = true;
|
||||
MqttService.startMqttCharCapture(handleGlobalMqttCharCapturedDirect);
|
||||
};
|
||||
const cancelCaptureGlobalMqttChar = () => {
|
||||
isCapturingGlobalMqttChar.value = false;
|
||||
currentGlobalActionType.value = null;
|
||||
MqttService.stopMqttCharCapture();
|
||||
};
|
||||
const clearGlobalMqttChar = (actionType) => {
|
||||
if (actionType === 'stopPause') store.dispatch('setGlobalMqttStopPause', null);
|
||||
else if (actionType === 'runAll') store.dispatch('setGlobalMqttRunAll', null);
|
||||
else if (actionType === 'passTurn') store.dispatch('setGlobalMqttPassTurn', null);
|
||||
};
|
||||
const handleGlobalMqttCharCapturedDirect = (charKey) => {
|
||||
isCapturingGlobalMqttChar.value = false;
|
||||
const actionType = currentGlobalActionType.value;
|
||||
if (!actionType || charKey.length !== 1) { currentGlobalActionType.value = null; return; }
|
||||
let conflictMessage = '';
|
||||
if (store.state.players.some(p => p.mqttChar === charKey)) {
|
||||
conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is already used by a player.`;
|
||||
}
|
||||
else if (actionType === 'stopPause') {
|
||||
if (store.state.globalMqttRunAll === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Run All Timers MQTT Char.`;
|
||||
if (store.state.globalMqttPassTurn === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Pass Turn MQTT Char.`;
|
||||
} else if (actionType === 'runAll') {
|
||||
if (store.state.globalMqttStopPause === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Stop/Pause MQTT Char.`;
|
||||
if (store.state.globalMqttPassTurn === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Pass Turn MQTT Char.`;
|
||||
} else if (actionType === 'passTurn') {
|
||||
if (store.state.globalMqttStopPause === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Stop/Pause MQTT Char.`;
|
||||
if (store.state.globalMqttRunAll === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Run All Timers MQTT Char.`;
|
||||
}
|
||||
if (conflictMessage) { alert(conflictMessage); currentGlobalActionType.value = null; return; }
|
||||
if (actionType === 'stopPause') store.dispatch('setGlobalMqttStopPause', charKey);
|
||||
else if (actionType === 'runAll') store.dispatch('setGlobalMqttRunAll', charKey);
|
||||
else if (actionType === 'passTurn') store.dispatch('setGlobalMqttPassTurn', charKey);
|
||||
currentGlobalActionType.value = null;
|
||||
};
|
||||
|
||||
const toggleMqttConnection = async () => {
|
||||
if (MqttService.connectionStatus.value === 'connected' || MqttService.connectionStatus.value === 'connecting') {
|
||||
MqttService.disconnect();
|
||||
await store.dispatch('setMqttConnectDesired', false);
|
||||
} else {
|
||||
if (!localMqttBrokerUrl.value.trim()) { alert("Please enter MQTT Broker URL (e.g., ws://host:port)."); return; }
|
||||
try {
|
||||
const url = new URL(localMqttBrokerUrl.value);
|
||||
if(!url.protocol.startsWith('ws')) { throw new Error("URL must start with ws:// or wss://"); }
|
||||
} catch (e) { alert(`Invalid MQTT Broker URL: ${e.message}. Please use format ws://host:port or wss://host:port.`); return; }
|
||||
await store.dispatch('setMqttBrokerUrl', localMqttBrokerUrl.value);
|
||||
MqttService.connect(localMqttBrokerUrl.value);
|
||||
}
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (MqttService.isCapturingMqttChar.value) { MqttService.stopMqttCharCapture(); }
|
||||
});
|
||||
|
||||
const toggleTheme = () => { store.dispatch('toggleTheme'); };
|
||||
const toggleMute = () => { store.dispatch('setMuted', !isMuted.value); };
|
||||
const saveAndClose = () => {
|
||||
store.dispatch('saveState');
|
||||
if (players.value.length >= 2) {
|
||||
if (store.state.currentPlayerIndex >= players.value.length || store.state.currentPlayerIndex < 0) {
|
||||
store.commit('SET_CURRENT_PLAYER_INDEX', 0);
|
||||
}
|
||||
store.commit('PAUSE_ALL_TIMERS');
|
||||
store.commit('SET_GAME_MODE', 'normal');
|
||||
router.push({ name: 'Game' });
|
||||
} else if (players.value.length === 0) {
|
||||
alert('Add at least 2 players to start a game.');
|
||||
} else {
|
||||
alert('At least 2 players are required to start a game.');
|
||||
}
|
||||
};
|
||||
const resetPlayerTimersConfirm = () => {
|
||||
if (window.confirm('Are you sure you want to reset all current players\' timers to their initial values? This will not delete players.')) {
|
||||
store.dispatch('resetGame');
|
||||
}
|
||||
};
|
||||
const fullResetAppConfirm = () => {
|
||||
if (window.confirm('Are you sure you want to reset all app data? This includes all players, settings, and timer states. The app will revert to its default state with 2 predefined players.')) {
|
||||
store.dispatch('fullResetApp');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user