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:
cpu
2025-05-08 15:36:17 +02:00
parent d741efa62d
commit ca3ba141a7
51 changed files with 7140 additions and 2 deletions

333
src/App.vue Normal file
View File

@@ -0,0 +1,333 @@
<template>
<div :class="[theme, 'min-h-screen flex flex-col no-select']">
<!-- Optional: Update Notification UI -->
<div v-if="showUpdateBar" class="bg-blue-600 text-white p-3 text-center text-sm shadow-md">
A new version is available!
<button @click="refreshApp" class="ml-4 font-semibold underline hover:text-blue-200">REFRESH</button>
<button @click="showUpdateBar = false" class="ml-4 font-semibold absolute right-3 top-1/2 transform -translate-y-1/2 p-1"></button>
</div>
<!-- Optional: Install Button (place somewhere appropriate, e.g., header or settings) -->
<!-- Example: Adding to App.vue for demo, ideally place in SetupView or a menu -->
<button v-if="showInstallButton" @click="promptInstall" class="fixed bottom-4 right-4 bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded shadow-lg z-50">
Install App
</button>
<router-view class="flex-grow" />
</div>
</template>
<script setup>
import { computed, onMounted, watch, ref } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { AudioService } from './services/AudioService';
import { MqttService } from './services/MqttService';
const store = useStore();
const router = useRouter();
const theme = computed(() => store.getters.theme);
const handleMqttMessage = (char) => { // This is the general command handler
console.log(`App.vue: Processing MQTT char (as command): '${char}'`);
const currentRouteName = router.currentRoute.value.name;
// Global MQTT Stop/Pause All
if (char === store.getters.globalMqttStopPause && store.getters.globalMqttStopPause) {
if (currentRouteName === 'Game') {
store.dispatch('globalStopPauseAll');
console.log("MQTT Command: Global Stop/Pause All triggered");
}
return;
}
// Global MQTT Run All Timers
if (char === store.getters.globalMqttRunAll && store.getters.globalMqttRunAll) {
if (currentRouteName === 'Game' && store.getters.gameMode === 'normal') {
store.dispatch('switchToAllTimersMode');
console.log("MQTT Command: Global Run All Timers triggered");
}
return;
}
// Global MQTT Pass Turn
if (char === store.getters.globalMqttPassTurn && store.getters.globalMqttPassTurn) {
if (currentRouteName === 'Game' && store.getters.gameMode === 'normal') {
// Similar logic to player-specific pass turn, but not tied to current player's MQTT char
const currentPlayer = store.getters.currentPlayer;
if (currentPlayer && !currentPlayer.isSkipped) { // Ensure there's a current player to pass from
AudioService.cancelPassTurnSound();
const wasRunning = currentPlayer.isTimerRunning;
store.dispatch('passTurn').then(() => {
const newCurrentPlayer = store.getters.currentPlayer;
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
AudioService.playPassTurnAlert();
}
});
console.log("MQTT Command: Global Pass Turn triggered");
}
}
return;
}
// Player specific MQTT Pass Turn / My Pause
if (currentRouteName === 'Game') {
const gameModeInStore = store.getters.gameMode;
const currentPlayerInStore = store.getters.currentPlayer;
if (gameModeInStore === 'normal' && currentPlayerInStore && char === currentPlayerInStore.mqttChar) {
const wasRunning = currentPlayerInStore.isTimerRunning;
store.dispatch('passTurn').then(() => {
const newCurrentPlayer = store.getters.currentPlayer;
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
AudioService.playPassTurnAlert();
}
});
console.log(`MQTT Command: Player ${currentPlayerInStore.name} Pass Turn triggered`);
return;
}
if (gameModeInStore === 'allTimers') {
// Find player by mqttChar. Ensure players array exists.
const playerToToggle = store.state.players?.find(p => p.mqttChar === char && !p.isSkipped);
if (playerToToggle) {
const playerIndex = store.state.players.indexOf(playerToToggle);
store.dispatch('togglePlayerTimerAllTimersMode', playerIndex);
console.log(`MQTT Command: Player ${playerToToggle.name} Timer Toggle triggered`);
}
}
}
};
// --- PWA Install Prompt Logic ---
const deferredPrompt = ref(null);
const showInstallButton = ref(false);
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault();
// Stash the event so it can be triggered later.
deferredPrompt.value = e;
// Update UI to notify the user they can add to home screen
showInstallButton.value = true;
console.log('`beforeinstallprompt` event was fired.');
});
const promptInstall = async () => {
if (!deferredPrompt.value) {
alert('Install prompt not available.');
return;
}
showInstallButton.value = false; // Hide the button once prompt is shown
// Show the prompt
deferredPrompt.value.prompt();
// Wait for the user to respond to the prompt
const { outcome } = await deferredPrompt.value.userChoice;
console.log(`User response to the install prompt: ${outcome}`);
// We've used the prompt, and can't use it again, throw it away
deferredPrompt.value = null;
};
window.addEventListener('appinstalled', () => {
// Hide the install button if the app is installed
showInstallButton.value = false;
deferredPrompt.value = null;
console.log('Nexus Timer was installed.');
// Optionally: Send analytics event
});
// --- End PWA Install Prompt Logic ---
// --- PWA Update Logic ---
const showUpdateBar = ref(false);
let newWorker;
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
if (registration.waiting) {
console.log("SW Update: Found waiting worker on load");
newWorker = registration.waiting;
showUpdateBar.value = true;
return;
}
registration.addEventListener('updatefound', () => {
console.log("SW Update: New worker found, installing...");
newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log("SW Update: New worker installed and ready");
showUpdateBar.value = true; // Show refresh bar
}
});
});
});
// Detect controller change and refresh the page.
// Can happen if skipWaiting() is used and the page wasn't manually refreshed.
let refreshing;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return;
console.log("SW Update: Controller changed, refreshing page.");
refreshing = true;
window.location.reload();
});
}
const refreshApp = () => {
showUpdateBar.value = false;
if (newWorker) {
console.log("SW Update: Sending skipWaiting message");
newWorker.postMessage({ action: 'skipWaiting' });
// Page should reload via 'controllerchange' listener above
} else {
console.warn("SW Update: Refresh called but no new worker found.");
window.location.reload(); // Fallback reload
}
};
// --- End PWA Update Logic ---
onMounted(() => {
store.dispatch('loadState').then(() => {
// Sync with OS theme preference on initial load if no theme is saved yet
// Note: Store initializer already loads saved theme if it exists.
// This logic should perhaps check if the loaded theme matches OS pref
// or only apply OS pref if no theme was loaded from storage.
// Simplified: Apply loaded theme regardless for now.
applyTheme();
// Example: Sync with OS theme only if no theme saved (requires store modification)
// syncWithOsThemeIfNeeded();
// Auto-connect MQTT only if URL is stored AND user desires connection
const storedBrokerUrl = store.getters.mqttBrokerUrl;
const connectDesired = store.getters.mqttConnectDesired; // Get the flag
if (storedBrokerUrl &&
connectDesired && // Check the flag
MqttService.connectionStatus.value !== 'connected' &&
MqttService.connectionStatus.value !== 'connecting') {
console.log("App.vue: Auto-connecting to stored MQTT broker (user desired):", storedBrokerUrl);
MqttService.connect(storedBrokerUrl);
} else if (storedBrokerUrl && !connectDesired) {
console.log("App.vue: MQTT Broker URL is stored, but user previously disconnected. Not auto-connecting.");
}
}).catch(error => {
console.error("App.vue: Error during store.dispatch('loadState'):", error);
});
document.addEventListener('keydown', handleGlobalKeyDown);
MqttService.setGeneralMessageHandler(handleMqttMessage); // Set handler regardless of initial connection
const resumeAudio = () => { AudioService.resumeContext(); /* ... remove listeners ... */ };
document.body.addEventListener('click', resumeAudio, { once: true });
document.body.addEventListener('touchstart', resumeAudio, { once: true });
MqttService.setGeneralMessageHandler(handleMqttMessage);
});
watch(theme, () => {
applyTheme();
});
watch(() => store.state.isMuted, (newMutedState) => {
AudioService.setMuted(newMutedState);
});
const applyTheme = () => {
if (store.getters.theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
// Optional: Function to sync with OS theme
// const syncWithOsThemeIfNeeded = () => {
// // Requires modification to store state/initializer to know if theme was explicitly set/saved
// const themeWasLoaded = store.getters.theme !== initialState.theme; // Example check needed
// if (!themeWasLoaded && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
// console.log("Syncing with OS dark theme preference.");
// store.dispatch('toggleTheme'); // Assumes toggle sets it correctly
// }
// }
// handleGlobalKeyDown (remains the same as previous version)
const handleGlobalKeyDown = (event) => {
const targetElement = event.target;
if (targetElement.tagName === 'INPUT' ||
targetElement.tagName === 'TEXTAREA' ||
targetElement.isContentEditable) {
return;
}
const keyPressed = event.key.toLowerCase();
const currentRouteName = router.currentRoute.value.name;
if (keyPressed === store.getters.globalHotkeyStopPause && store.getters.globalHotkeyStopPause) {
event.preventDefault();
if (currentRouteName === 'Game') {
store.dispatch('globalStopPauseAll');
}
return;
}
if (keyPressed === store.getters.globalHotkeyRunAll && store.getters.globalHotkeyRunAll) {
event.preventDefault();
if (currentRouteName === 'Game' && store.getters.gameMode === 'normal') {
store.dispatch('switchToAllTimersMode');
}
return;
}
if (keyPressed === store.getters.globalHotkeyPassTurn && store.getters.globalHotkeyPassTurn) {
event.preventDefault();
if (currentRouteName === 'Game' && store.getters.gameMode === 'normal') {
const currentPlayer = store.getters.currentPlayer;
if (currentPlayer && !currentPlayer.isSkipped) {
AudioService.cancelPassTurnSound();
const wasRunning = currentPlayer.isTimerRunning;
store.dispatch('passTurn').then(() => {
const newCurrentPlayer = store.getters.currentPlayer;
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
AudioService.playPassTurnAlert();
}
});
console.log("Hotkey: Global Pass Turn triggered");
}
}
return;
}
if (currentRouteName === 'Game') {
const currentPlayerInStore = store.getters.currentPlayer;
const gameModeInStore = store.getters.gameMode;
if (gameModeInStore === 'normal' && currentPlayerInStore && keyPressed === currentPlayerInStore.hotkey) {
event.preventDefault();
const wasRunning = currentPlayerInStore.isTimerRunning;
store.dispatch('passTurn').then(() => {
const newCurrentPlayer = store.getters.currentPlayer;
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
AudioService.playPassTurnAlert();
}
});
} else if (gameModeInStore === 'allTimers') {
const playerToToggle = store.state.players.find(p => p.hotkey === keyPressed && !p.isSkipped);
if (playerToToggle) {
event.preventDefault();
const playerIndex = store.state.players.indexOf(playerToToggle);
store.dispatch('togglePlayerTimerAllTimersMode', playerIndex);
}
}
}
};
</script>
<style>
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

54
src/assets/tailwind.css Normal file
View File

@@ -0,0 +1,54 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 antialiased select-none;
overscroll-behavior-y: contain; /* Prevents pull-to-refresh on mobile */
}
/* Safe area insets for mobile devices (status bar, home indicator) */
.safe-area-padding {
padding-top: env(safe-area-inset-top);
/* padding-bottom: env(safe-area-inset-bottom); */
}
.safe-area-height {
height: calc(100dvh - env(safe-area-inset-top));
min-height: 0;
}
/* Basic button styling */
.btn {
@apply px-4 py-2 rounded font-semibold focus:outline-none focus:ring-2 focus:ring-opacity-50;
}
.btn-primary {
@apply bg-blue-500 hover:bg-blue-600 text-white focus:ring-blue-400;
}
.btn-secondary {
@apply bg-gray-500 hover:bg-gray-600 text-white focus:ring-gray-400;
}
.btn-danger {
@apply bg-red-500 hover:bg-red-600 text-white focus:ring-red-400;
}
.btn-warning {
@apply bg-yellow-500 hover:bg-yellow-600 text-black focus:ring-yellow-400;
}
.btn-icon {
@apply p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700;
}
/* Input styling */
.input-base {
@apply mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm;
}
/* For preventing text selection during swipes/taps */
.no-select {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Old versions of Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */
}

View File

@@ -0,0 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" />
</svg>
</template>
<script setup>
// No script needed for a simple SVG icon component
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div
v-if="visible"
class="fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-[100]"
@click.self="cancel"
@keydown.esc="cancel"
tabindex="0"
ref="overlay"
>
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
<h3 class="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4">
Press a Key
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6">
Waiting for a single key press to assign as the hotkey.
<br>
(Esc to cancel)
</p>
<div class="animate-pulse text-blue-500 dark:text-blue-400 text-xl">
Listening...
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue';
const props = defineProps({
visible: Boolean,
});
const emit = defineEmits(['captured', 'cancel']);
const overlay = ref(null);
const handleKeyPress = (event) => {
if (!props.visible) return;
event.preventDefault();
event.stopPropagation(); // Prevent further propagation if overlay is active
let key = event.key;
if (event.code === 'Space') {
key = ' ';
} else if (key.length > 1 && key !== ' ') { // Ignore modifiers, Enter, Tab etc.
if (key === 'Escape') { // Handle Escape specifically for cancellation
cancel();
}
return;
}
key = key.toLowerCase();
emit('captured', key);
};
const cancel = () => {
emit('cancel');
};
watch(() => props.visible, async (newValue) => {
if (newValue) {
await nextTick(); // Ensure overlay is in DOM
overlay.value?.focus(); // Focus the overlay to capture keydown events directly
document.addEventListener('keydown', handleKeyPress, { capture: true }); // Use capture phase
} else {
document.removeEventListener('keydown', handleKeyPress, { capture: true });
}
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyPress, { capture: true });
});
</script>
<style scoped>
/* Ensure overlay is above other modals if any (PlayerForm uses z-50) */
.z-\[100\] {
z-index: 100;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div
v-if="visible"
class="fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-[100]"
@click.self="handleCancel"
@keydown.esc="handleCancel"
tabindex="0"
ref="overlay"
>
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
<h3 class="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4">
Send MQTT Signal
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6">
Send a single character message to the MQTT topic '{{ MqttService.MQTT_TOPIC_GAME }}'.
<br>
(Press Esc on keyboard or click backdrop to cancel)
</p>
<div class="animate-pulse text-purple-500 dark:text-purple-400 text-xl">
Listening for MQTT...
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue';
import { MqttService } from '../services/MqttService'; // To display topic name
const props = defineProps({
visible: Boolean,
});
const emit = defineEmits(['cancel']); // Only emits 'cancel', 'captured' will be handled by parent via service
const overlay = ref(null);
// No direct key press handling here for MQTT capture, parent will manage via MqttService
const handleCancel = () => {
emit('cancel');
};
watch(() => props.visible, async (newValue) => {
if (newValue) {
await nextTick();
overlay.value?.focus(); // Focus for Esc key to cancel
// Add specific keyboard listener just for Esc on this overlay
document.addEventListener('keydown', escKeyHandler);
} else {
document.removeEventListener('keydown', escKeyHandler);
}
});
// Specific Esc handler for this overlay
const escKeyHandler = (event) => {
if (props.visible && event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
handleCancel();
}
};
onUnmounted(() => {
document.removeEventListener('keydown', escKeyHandler);
});
</script>

View File

@@ -0,0 +1,145 @@
<template>
<div
:class="['player-area p-2 md:p-4 flex flex-col items-center justify-center text-center relative no-select',
areaClass,
{ 'opacity-50': player.isSkipped,
'bg-green-100 dark:bg-green-800 animate-pulsePositive': player.isTimerRunning && player.currentTimerSec >=0 && !isNextPlayerArea,
'bg-red-100 dark:bg-red-900': player.isTimerRunning && player.currentTimerSec < 0 && !isNextPlayerArea,
}]"
@click="handleTap"
v-touch:swipe.up="handleSwipeUp"
>
<!-- Avatar -->
<div
class="mb-4 md:mb-4 relative"
:style="{
width: avatarSize.width,
height: avatarSize.height,
}"
>
<img
v-if="player.avatar"
:src="player.avatar"
alt="Player Avatar"
class="rounded-full object-cover border-2 md:border-4 shadow-lg w-full h-full"
:class="player.isSkipped ? 'border-gray-500 filter grayscale' : 'border-blue-500 dark:border-blue-400'"
/>
<DefaultAvatarIcon
v-else
class="rounded-full object-cover border-2 md:border-4 shadow-lg text-gray-400 dark:text-gray-500 bg-gray-200 dark:bg-gray-700 p-1 md:p-2 w-full h-full"
:class="player.isSkipped ? 'border-gray-500 filter grayscale !text-gray-300 dark:!text-gray-600' : 'border-blue-500 dark:border-blue-400'"
/>
</div>
<!-- Player Name -->
<h2 class="font-semibold mb-4 md:mb-6 text-5xl sm:text-2xl md:text-3xl lg:text-5xl">
{{ player.name }}
</h2>
<TimerDisplay
:seconds="player.currentTimerSec"
:is-negative="player.currentTimerSec < 0"
:is-pulsating="player.isTimerRunning"
class="text-4xl sm:text-5xl md:text-6xl lg:text-6xl xl:text-5xl"
/>
<p v-if="player.isSkipped" class="text-red-500 dark:text-red-400 mt-1 md:mt-2 font-semibold text-sm md:text-base lg:text-lg">SKIPPED</p>
</div>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue';
import TimerDisplay from './TimerDisplay.vue';
import DefaultAvatarIcon from './DefaultAvatarIcon.vue';
const vTouch = {
mounted: (el, binding) => {
if (binding.arg === 'swipe' && binding.modifiers.up) {
let touchstartX = 0;
let touchstartY = 0;
let touchendX = 0;
let touchendY = 0;
const swipeThreshold = 50;
el.addEventListener('touchstart', function(event) {
touchstartX = event.changedTouches[0].screenX;
touchstartY = event.changedTouches[0].screenY;
}, { passive: true });
el.addEventListener('touchend', function(event) {
touchendX = event.changedTouches[0].screenX;
touchendY = event.changedTouches[0].screenY;
handleGesture();
}, { passive: true });
function handleGesture() {
const deltaY = touchstartY - touchendY;
const deltaX = Math.abs(touchendX - touchstartX);
if (deltaY > swipeThreshold && deltaY > deltaX) {
if (typeof binding.value === 'function') {
binding.value();
}
}
}
}
}
};
const props = defineProps({
player: {
type: Object,
required: true
},
isCurrentPlayerArea: Boolean,
isNextPlayerArea: Boolean,
areaClass: String,
});
const emit = defineEmits(['tapped', 'swiped-up']);
const avatarSize = ref({ width: '120px', height: '120px' });
const calculateAvatarSize = () => {
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
const isNormalModeContext = document.querySelector('.player-area')?.parentElement?.classList.contains('flex-col');
if (isNormalModeContext) {
const availableHeight = screenHeight / 2;
let size = Math.min(availableHeight * 0.5, screenWidth * 0.4, 175);
if (screenWidth < 768) { // Mobile
size = Math.min(availableHeight * 0.7, screenWidth * 0.6, 230);
} else if (screenWidth < 1024) { // Tablet
size = Math.min(availableHeight * 0.55, screenWidth * 0.45, 180);
}
size = Math.max(size, 100);
avatarSize.value = { width: `${size}px`, height: `${size}px` };
} else {
avatarSize.value = { width: '120px', height: '120px' };
}
};
onMounted(() => {
calculateAvatarSize();
window.addEventListener('resize', calculateAvatarSize);
});
onUnmounted(() => {
window.removeEventListener('resize', calculateAvatarSize);
});
const handleTap = () => {
if (props.isCurrentPlayerArea && !props.player.isSkipped) {
emit('tapped');
}
};
const handleSwipeUp = () => {
if (props.isNextPlayerArea && !props.player.isSkipped) {
emit('swiped-up');
}
};
</script>

View File

@@ -0,0 +1,327 @@
<template>
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex justify-center items-center p-4" @click.self="closeModal">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl w-full max-w-md">
<h2 class="text-2xl font-semibold mb-4">{{ isEditing ? 'Edit Player' : 'Add New Player' }}</h2>
<form @submit.prevent="submitForm">
<div class="mb-4">
<label for="playerName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input type="text" id="playerName" v-model="editablePlayer.name" required class="input-base mt-1">
</div>
<div class="mb-4">
<label for="remainingTime" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Remaining Time (MM:SS or -MM:SS)</label>
<input type="text" id="remainingTime" v-model="currentTimeFormatted" @blur="validateCurrentTimeFormat" placeholder="e.g., 55:30 or -02:15" required class="input-base mt-1">
<p v-if="currentTimeFormatError" class="text-red-500 text-xs mt-1">{{ currentTimeFormatError }}</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar</label>
<div class="mt-1 flex items-center">
<img
v-if="editablePlayer.avatar"
:src="editablePlayer.avatar"
alt="Avatar"
class="w-16 h-16 rounded-full object-cover mr-4 border"
/>
<DefaultAvatarIcon v-else class="w-16 h-16 rounded-full object-cover mr-4 border text-gray-400 bg-gray-200 p-1"/>
<button type="button" @click="capturePhoto" class="btn btn-secondary text-sm mr-2">Take Photo</button>
<button type="button" @click="useDefaultAvatar" class="btn btn-secondary text-sm">Default</button>
</div>
<p v-if="cameraError" class="text-red-500 text-xs mt-1">{{ cameraError }}</p>
</div>
<div class="mb-4">
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">"Pass Turn / My Pause" Trigger:</p>
<div class="flex items-center justify-between space-x-4">
<div class="flex items-center">
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">Hotkey:</span>
<button
type="button"
@click="startCapturePlayerHotkey"
class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-center"
>
<span>{{ editablePlayer.hotkey ? editablePlayer.hotkey.toUpperCase() : '-' }}</span>
</button>
<button v-if="editablePlayer.hotkey" type="button" @click="clearPlayerHotkey" class="playerform-clear-btn">Clear</button>
<span v-else class="playerform-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="startCapturePlayerMqttChar"
class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-center"
>
<span>{{ editablePlayer.mqttChar ? editablePlayer.mqttChar.toUpperCase() : '-' }}</span>
</button>
<button v-if="editablePlayer.mqttChar" type="button" @click="clearPlayerMqttChar" class="playerform-clear-btn">Clear</button>
<span v-else class="playerform-clear-btn-placeholder"></span>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" @click="closeModal" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="!!currentTimeFormatError">{{ isEditing ? 'Save Changes' : 'Add Player' }}</button>
</div>
</form>
</div>
<HotkeyCaptureOverlay
:visible="isCapturingPlayerHotkey"
@captured="handlePlayerHotkeyCaptured"
@cancel="cancelCapturePlayerHotkey"
/>
<MqttCharCaptureOverlay
:visible="isCapturingPlayerMqttChar"
@cancel="cancelCapturePlayerMqttChar"
/>
</div>
</template>
<style scoped>
.playerform-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 to match "Clear" button width */
display: inline-block;
text-align: center;
}
.playerform-clear-btn-placeholder {
@apply ml-2 px-2 py-1;
min-width: 40px; /* Match width */
display: inline-block;
visibility: hidden;
}
</style>
<script setup>
import { ref, reactive, watch, onMounted, onUnmounted, computed } from 'vue';
import { useStore } from 'vuex';
import { CameraService } from '../services/CameraService';
import { formatTime, parseTime } from '../utils/timeFormatter';
import DefaultAvatarIcon from './DefaultAvatarIcon.vue';
import HotkeyCaptureOverlay from './HotkeyCaptureOverlay.vue';
import MqttCharCaptureOverlay from './MqttCharCaptureOverlay.vue';
import { AudioService } from '../services/AudioService';
import { MqttService } from '../services/MqttService';
const props = defineProps({ player: Object });
const emit = defineEmits(['close', 'save']);
const store = useStore();
const DEFAULT_AVATAR_MARKER = null;
const isEditing = computed(() => !!props.player);
const editablePlayer = reactive({
id: null, name: '', avatar: DEFAULT_AVATAR_MARKER,
initialTimerSec: 3600, currentTimerSec: 3600,
hotkey: '', mqttChar: ''
});
const currentTimeFormatted = ref('60:00');
const currentTimeFormatError = ref('');
const cameraError = ref('');
const maxNegativeSeconds = computed(() => store.getters.maxNegativeTimeReached);
const isCapturingPlayerHotkey = ref(false);
const isCapturingPlayerMqttChar = ref(false);
const startCapturePlayerHotkey = () => { isCapturingPlayerHotkey.value = true; };
const cancelCapturePlayerHotkey = () => { isCapturingPlayerHotkey.value = false; };
const clearPlayerHotkey = () => { editablePlayer.hotkey = ''; };
const startCapturePlayerMqttChar = () => {
if (MqttService.connectionStatus.value !== 'connected') {
alert('MQTT broker is not connected. Please connect in Setup first.');
return;
}
isCapturingPlayerMqttChar.value = true;
MqttService.startMqttCharCapture(handlePlayerMqttCharCaptured);
};
const cancelCapturePlayerMqttChar = () => {
isCapturingPlayerMqttChar.value = false;
MqttService.stopMqttCharCapture();
};
const clearPlayerMqttChar = () => { editablePlayer.mqttChar = ''; };
const checkGlobalConflict = (charKey) => {
if (store.state.globalHotkeyStopPause === charKey) return `"${charKey.toUpperCase()}" is Global Stop/Pause Hotkey.`;
if (store.state.globalHotkeyRunAll === charKey) return `"${charKey.toUpperCase()}" is Global Run All Hotkey.`;
if (store.state.globalMqttStopPause === charKey) return `"${charKey.toUpperCase()}" is Global Stop/Pause MQTT.`;
if (store.state.globalMqttRunAll === charKey) return `"${charKey.toUpperCase()}" is Global Run All MQTT.`;
if (store.state.globalHotkeyPassTurn === charKey) return `"${charKey.toUpperCase()}" is Global Pass Turn Hotkey.`; // Check new global
if (store.state.globalMqttPassTurn === charKey) return `"${charKey.toUpperCase()}" is Global Pass Turn MQTT.`; // Check new global
return null;
};
const handlePlayerHotkeyCaptured = (key) => {
isCapturingPlayerHotkey.value = false;
if (key.length !== 1) return;
const globalConflictMsg = checkGlobalConflict(key);
if (globalConflictMsg) { alert(globalConflictMsg); return; }
const otherPlayerHotkeyConflict = store.state.players.find(p => p.id !== editablePlayer.id && p.hotkey === key);
if (otherPlayerHotkeyConflict) { alert(`Hotkey "${key.toUpperCase()}" is already used by player "${otherPlayerHotkeyConflict.name}".`); return; }
editablePlayer.hotkey = key;
};
const handlePlayerMqttCharCaptured = (charKey) => {
isCapturingPlayerMqttChar.value = false;
if (charKey.length !== 1) return;
const globalConflictMsg = checkGlobalConflict(charKey);
if (globalConflictMsg) { alert(globalConflictMsg); return; }
const otherPlayerMqttConflict = store.state.players.find(p => p.id !== editablePlayer.id && p.mqttChar === charKey);
if (otherPlayerMqttConflict) { alert(`MQTT Char "${charKey.toUpperCase()}" is already used by player "${otherPlayerMqttConflict.name}".`); return; }
editablePlayer.mqttChar = charKey;
};
onMounted(() => {
if (isEditing.value && props.player) {
editablePlayer.id = props.player.id;
editablePlayer.name = props.player.name;
editablePlayer.avatar = props.player.avatar;
editablePlayer.initialTimerSec = props.player.initialTimerSec;
editablePlayer.currentTimerSec = props.player.currentTimerSec;
editablePlayer.hotkey = props.player.hotkey || '';
editablePlayer.mqttChar = props.player.mqttChar || '';
currentTimeFormatted.value = formatTime(props.player.currentTimerSec);
} else {
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
editablePlayer.initialTimerSec = 3600;
editablePlayer.currentTimerSec = 3600;
currentTimeFormatted.value = formatTime(editablePlayer.currentTimerSec);
}
});
onUnmounted(() => {
if (isCapturingPlayerMqttChar.value) {
MqttService.stopMqttCharCapture();
}
});
watch(currentTimeFormatted, (newTime) => {
validateCurrentTimeFormat();
if (!currentTimeFormatError.value) {
editablePlayer.currentTimerSec = parseTime(newTime);
if (!isEditing.value) {
editablePlayer.initialTimerSec = editablePlayer.currentTimerSec;
}
}
});
const validateCurrentTimeFormat = () => {
const time = currentTimeFormatted.value;
const isNegativeInput = time.startsWith('-');
if (!/^-?(?:[0-9]+:[0-5]\d)$/.test(time)) {
currentTimeFormatError.value = 'Invalid format. Use MM:SS or -MM:SS (e.g., 05:30, -02:15).';
} else {
const parsedSeconds = parseTime(time);
if (isNegativeInput && parsedSeconds > 0) {
currentTimeFormatError.value = 'Negative time should parse to negative seconds.';
} else if (parsedSeconds < maxNegativeSeconds.value) {
currentTimeFormatError.value = `Time cannot be less than ${formatTime(maxNegativeSeconds.value)}.`;
}
else {
currentTimeFormatError.value = '';
}
}
};
const capturePhoto = async () => {
cameraError.value = '';
try {
AudioService.resumeContext();
const photoDataUrl = await CameraService.getPhoto();
editablePlayer.avatar = photoDataUrl;
} catch (error) {
console.error('Failed to capture photo:', error);
cameraError.value = error.message || 'Could not capture photo.';
}
};
const useDefaultAvatar = () => { editablePlayer.avatar = DEFAULT_AVATAR_MARKER; };
const submitForm = () => {
validateCurrentTimeFormat();
if (currentTimeFormatError.value) return;
const playerPayload = { ...editablePlayer };
if (playerPayload.currentTimerSec <= maxNegativeSeconds.value) {
playerPayload.isSkipped = true;
} else if (playerPayload.isSkipped && playerPayload.currentTimerSec > maxNegativeSeconds.value) {
playerPayload.isSkipped = false;
}
if (!isEditing.value) {
playerPayload.initialTimerSec = playerPayload.currentTimerSec;
}
emit('save', playerPayload);
closeModal();
};
const closeModal = () => { emit('close'); };
// Fill in full definitions from previous version if needed
onMounted.value = () => {
if (isEditing.value && props.player) {
editablePlayer.id = props.player.id;
editablePlayer.name = props.player.name;
editablePlayer.avatar = props.player.avatar;
editablePlayer.initialTimerSec = props.player.initialTimerSec;
editablePlayer.currentTimerSec = props.player.currentTimerSec;
editablePlayer.hotkey = props.player.hotkey || '';
editablePlayer.mqttChar = props.player.mqttChar || '';
currentTimeFormatted.value = formatTime(props.player.currentTimerSec);
} else {
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
editablePlayer.initialTimerSec = 3600;
editablePlayer.currentTimerSec = 3600;
currentTimeFormatted.value = formatTime(editablePlayer.currentTimerSec);
}
};
onUnmounted.value = () => {
if (isCapturingPlayerMqttChar.value) {
MqttService.stopMqttCharCapture();
}
};
watch(currentTimeFormatted, (newTime) => {
validateCurrentTimeFormat();
if (!currentTimeFormatError.value) {
editablePlayer.currentTimerSec = parseTime(newTime);
if (!isEditing.value) {
editablePlayer.initialTimerSec = editablePlayer.currentTimerSec;
}
}
});
validateCurrentTimeFormat.value = () => {
const time = currentTimeFormatted.value;
const isNegativeInput = time.startsWith('-');
if (!/^-?(?:[0-9]+:[0-5]\d)$/.test(time)) {
currentTimeFormatError.value = 'Invalid format. Use MM:SS or -MM:SS (e.g., 05:30, -02:15).';
} else {
const parsedSeconds = parseTime(time);
if (isNegativeInput && parsedSeconds > 0) {
currentTimeFormatError.value = 'Negative time should parse to negative seconds.';
} else if (parsedSeconds < maxNegativeSeconds.value) {
currentTimeFormatError.value = `Time cannot be less than ${formatTime(maxNegativeSeconds.value)}.`;
}
else {
currentTimeFormatError.value = '';
}
}
};
capturePhoto.value = async () => {
cameraError.value = '';
try {
AudioService.resumeContext();
const photoDataUrl = await CameraService.getPhoto();
editablePlayer.avatar = photoDataUrl;
} catch (error) {
console.error('Failed to capture photo:', error);
cameraError.value = error.message || 'Could not capture photo.';
}
};
useDefaultAvatar.value = () => { editablePlayer.avatar = DEFAULT_AVATAR_MARKER; };
submitForm.value = () => {
validateCurrentTimeFormat();
if (currentTimeFormatError.value) return;
const playerPayload = { ...editablePlayer };
if (playerPayload.currentTimerSec <= maxNegativeSeconds.value) {
playerPayload.isSkipped = true;
} else if (playerPayload.isSkipped && playerPayload.currentTimerSec > maxNegativeSeconds.value) {
playerPayload.isSkipped = false;
}
if (!isEditing.value) {
playerPayload.initialTimerSec = playerPayload.currentTimerSec;
}
emit('save', playerPayload);
closeModal();
};
closeModal.value = () => { emit('close'); };
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div
:class="['player-list-item flex items-center justify-between p-3 my-2 rounded-lg shadow cursor-pointer transition-all duration-200 ease-in-out',
itemStateClasses,
{ 'opacity-50 filter grayscale contrast-75': player.isSkipped },
{ 'opacity-75': !player.isTimerRunning && !player.isSkipped }
]"
@click="handleTap"
>
<div class="flex items-center">
<img
v-if="player.avatar"
:src="player.avatar"
alt="Player Avatar"
class="w-12 h-12 rounded-full object-cover mr-3 border-2"
:class="player.isSkipped ? 'border-gray-400 dark:border-gray-600' :
player.isTimerRunning ? 'border-green-400 dark:border-green-500' :
'border-yellow-400 dark:border-yellow-500'"
/>
<DefaultAvatarIcon
v-else
class="w-12 h-12 rounded-full object-cover mr-3 border-2 p-1"
:class="player.isSkipped ? 'text-gray-400 bg-gray-200 border-gray-400 dark:text-gray-600 dark:bg-gray-700 dark:border-gray-600' :
player.isTimerRunning ? 'text-green-600 bg-green-100 border-green-400 dark:text-green-300 dark:bg-green-800 dark:border-green-500' :
'text-yellow-600 bg-yellow-50 border-yellow-400 dark:text-yellow-300 dark:bg-yellow-800 dark:border-yellow-500'"
/>
<div>
<h3 class="text-lg font-medium" :class="{'text-gray-500 dark:text-gray-400': !player.isTimerRunning && !player.isSkipped}">
{{ player.name }}
</h3>
<p v-if="player.isSkipped" class="text-xs text-red-500 dark:text-red-400">SKIPPED</p>
<p v-else-if="!player.isTimerRunning" class="text-xs text-yellow-600 dark:text-yellow-400">Paused</p>
</div>
</div>
<div class="flex flex-col items-end">
<TimerDisplay
:seconds="player.currentTimerSec"
:is-negative="player.currentTimerSec < 0"
:is-pulsating="player.isTimerRunning"
class="text-2xl"
:class="{'opacity-80': !player.isTimerRunning && !player.isSkipped}"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import TimerDisplay from './TimerDisplay.vue';
import DefaultAvatarIcon from './DefaultAvatarIcon.vue';
const props = defineProps({
player: {
type: Object,
required: true
}
});
const emit = defineEmits(['tapped']);
const handleTap = () => {
// Allow tapping only if not skipped, to pause/resume their timer
if (!props.player.isSkipped) {
emit('tapped');
}
};
const itemStateClasses = computed(() => {
if (props.player.isSkipped) {
return 'bg-gray-200 dark:bg-gray-800 border border-gray-300 dark:border-gray-700';
}
if (props.player.isTimerRunning) {
return props.player.currentTimerSec < 0
? 'bg-red-100 dark:bg-red-900/80 border border-red-400 dark:border-red-600 animate-pulsePositive' // Pulsate background if running positive
: 'bg-green-50 dark:bg-green-900/70 border border-green-400 dark:border-green-600 animate-pulsePositive';
}
// Paused state
return 'bg-yellow-50 dark:bg-yellow-900/50 border border-yellow-300 dark:border-yellow-700 hover:bg-yellow-100 dark:hover:bg-yellow-900/70';
});
</script>

View File

@@ -0,0 +1,27 @@
<template>
<span :class="timerClasses">
{{ formattedTime }}
</span>
</template>
<script setup>
import { computed } from 'vue';
import { formatTime } from '../utils/timeFormatter';
const props = defineProps({
seconds: {
type: Number,
required: true
},
isPulsating: Boolean, // For active timer
isNegative: Boolean, // For negative time text color
});
const formattedTime = computed(() => formatTime(props.seconds));
const timerClasses = computed(() => ({
'font-mono text-5xl md:text-7xl lg:text-8xl font-bold': true,
'text-red-500 dark:text-red-400': props.isNegative,
'animate-pulseNegative': props.isNegative && props.isPulsating, // Pulsate text if negative and active
}));
</script>

26
src/main.js Normal file
View File

@@ -0,0 +1,26 @@
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router' // router will be initialized here
import store from './store' // store will be initialized here
import './assets/tailwind.css'
const app = createApp(App)
// Dispatch loadState immediately after store is created and before app is mounted
// and before router is fully used by the app.
store.dispatch('loadState').then(() => {
// Now that the state is loaded (or attempted to be loaded),
// we can safely use the router and mount the app.
app.use(router)
app.use(store) // Using store here is fine, it's already created.
app.mount('#app')
}).catch(error => {
console.error("Failed to load initial state for the store:", error);
// Fallback: Mount the app even if state loading fails, guards should handle it.
// Or display an error message to the user.
// For now, let's still try to mount.
app.use(router)
app.use(store)
app.mount('#app')
});

59
src/router/index.js Normal file
View File

@@ -0,0 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router';
import SetupView from '../views/SetupView.vue';
import GameView from '../views/GameView.vue';
import InfoView from '../views/InfoView.vue';
import store from '../store';
const routes = [
{
path: '/',
name: 'Setup',
component: SetupView,
beforeEnter: (to, from, next) => {
// Check if we are navigating FROM the Game view.
// If so, the user explicitly clicked the Setup button, so allow it.
if (from.name === 'Game') {
console.log('Router Guard: Allowing navigation from Game to Setup.');
next(); // Allow navigation to Setup
return; // Stop further processing of this guard
}
// Original logic for initial load or other navigations TO Setup:
if (store.state.players && store.state.players.length >= 2) {
// If 2 or more players exist (and not coming from Game), redirect to Game.
console.log('Router Guard: Players found, redirecting to Game (not coming from Game).');
next({ name: 'Game', replace: true });
} else {
// Otherwise (fewer than 2 players), allow navigation to the Setup view.
console.log('Router Guard: Not enough players, proceeding to Setup.');
next();
}
}
},
{
path: '/game',
name: 'Game',
component: GameView,
beforeEnter: (to, from, next) => {
// Keep this guard: prevent direct access to /game without enough players
if (!store.state.players || store.state.players.length < 2) {
console.log('Router Guard: Attempted to access Game without enough players, redirecting to Setup.');
next({ name: 'Setup' });
} else {
next();
}
}
},
{
path: '/info',
name: 'Info',
component: InfoView
}
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
});
export default router;

View File

@@ -0,0 +1,133 @@
let audioContext;
let tickSoundBuffer; // For short tick
let passTurnSoundBuffer; // For 3s pass turn alert
let isMutedGlobally = false;
let continuousTickInterval = null; // For setInterval based continuous ticking
let passTurnSoundTimeout = null;
function getAudioContext() {
if (!audioContext && (window.AudioContext || window.webkitAudioContext)) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
function createBeepBuffer(frequency = 440, duration = 0.1, type = 'sine') {
const ctx = getAudioContext();
if (!ctx) return null;
const sampleRate = ctx.sampleRate;
const numFrames = duration * sampleRate;
const buffer = ctx.createBuffer(1, numFrames, sampleRate);
const data = buffer.getChannelData(0);
const gain = 0.1; // Reduce gain to make beeps softer
for (let i = 0; i < numFrames; i++) {
// Simple fade out
const currentGain = gain * (1 - (i / numFrames));
if (type === 'square') {
data[i] = (Math.sin(2 * Math.PI * frequency * (i / sampleRate)) >= 0 ? 1 : -1) * currentGain;
} else { // sine
data[i] = Math.sin(2 * Math.PI * frequency * (i / sampleRate)) * currentGain;
}
}
return buffer;
}
async function initSounds() {
const ctx = getAudioContext();
if (!ctx) return;
// Tick sound (shorter, slightly different pitch)
if (!tickSoundBuffer) {
// Using a square wave for a more 'digital' tick, short duration
tickSoundBuffer = createBeepBuffer(1000, 0.03, 'square');
}
// Pass turn alert sound (3 beeps)
if (!passTurnSoundBuffer) {
passTurnSoundBuffer = createBeepBuffer(660, 0.08, 'sine');
}
}
initSounds();
function playSoundBuffer(buffer) {
if (isMutedGlobally || !buffer || !audioContext || audioContext.state === 'suspended') return;
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
source.start();
}
export const AudioService = {
setMuted(muted) {
isMutedGlobally = muted;
if (muted) {
this.stopContinuousTick();
this.cancelPassTurnSound();
}
},
// This is the single, short tick sound for "All Timers Running" mode.
_playSingleTick() {
playSoundBuffer(tickSoundBuffer);
},
playPassTurnAlert() {
if (isMutedGlobally || !audioContext || audioContext.state === 'suspended') return;
this.cancelPassTurnSound();
let count = 0;
const playAndSchedule = () => {
if (count < 3 && !isMutedGlobally && audioContext.state !== 'suspended') {
playSoundBuffer(passTurnSoundBuffer);
count++;
passTurnSoundTimeout = setTimeout(playAndSchedule, 1000); // Beep every second for 3s
} else {
passTurnSoundTimeout = null;
}
};
playAndSchedule();
},
cancelPassTurnSound() {
if (passTurnSoundTimeout) {
clearTimeout(passTurnSoundTimeout);
passTurnSoundTimeout = null;
}
},
startContinuousTick() {
this.stopContinuousTick(); // Clear any existing interval
if (isMutedGlobally || !audioContext || audioContext.state === 'suspended') return;
// Play immediately once, then set interval
this._playSingleTick();
continuousTickInterval = setInterval(() => {
if (!isMutedGlobally && audioContext.state !== 'suspended') {
this._playSingleTick();
} else {
this.stopContinuousTick(); // Stop if muted or context suspended during interval
}
}, 1000); // Tick every second
},
stopContinuousTick() {
if (continuousTickInterval) {
clearInterval(continuousTickInterval);
continuousTickInterval = null;
}
// Ensure no rogue oscillators are playing.
// If an oscillator was ever used directly and not disconnected, it could persist.
// The current implementation relies on BufferSource which stops automatically.
},
resumeContext() {
const ctx = getAudioContext();
if (ctx && ctx.state === 'suspended') {
ctx.resume().then(() => {
console.log("AudioContext resumed successfully.");
initSounds(); // Re-initialize sounds if context was suspended for long
}).catch(e => console.error("Error resuming AudioContext:", e));
} else if (ctx && !tickSoundBuffer) { // If context was fine but sounds not loaded
initSounds();
}
}
};

View File

@@ -0,0 +1,69 @@
export const CameraService = {
async getPhoto() {
return new Promise(async (resolve, reject) => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
reject(new Error('Camera API not available.'));
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false });
// Create a modal or an overlay to show the video stream and a capture button
const videoElement = document.createElement('video');
videoElement.srcObject = stream;
videoElement.setAttribute('playsinline', ''); // Required for iOS
videoElement.style.cssText = "position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); max-width: 90%; max-height: 70vh; z-index: 1001; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.2);";
const captureButton = document.createElement('button');
captureButton.textContent = 'Capture';
captureButton.style.cssText = "position: fixed; bottom: 10%; left: 50%; transform: translateX(-50%); z-index: 1002; padding: 12px 24px; background-color: #3b82f6; color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer;";
const closeButton = document.createElement('button');
closeButton.textContent = 'Cancel';
closeButton.style.cssText = "position: fixed; top: 10px; right: 10px; z-index: 1002; padding: 8px 12px; background-color: #ef4444; color: white; border: none; border-radius: 8px; font-size: 14px; cursor: pointer;";
const overlay = document.createElement('div');
overlay.style.cssText = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 1000;";
document.body.appendChild(overlay);
document.body.appendChild(videoElement);
document.body.appendChild(captureButton);
document.body.appendChild(closeButton);
videoElement.onloadedmetadata = () => {
videoElement.play();
};
const cleanup = () => {
stream.getTracks().forEach(track => track.stop());
document.body.removeChild(videoElement);
document.body.removeChild(captureButton);
document.body.removeChild(closeButton);
document.body.removeChild(overlay);
};
captureButton.onclick = () => {
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const context = canvas.getContext('2d');
context.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/png');
cleanup();
resolve(dataUrl);
};
closeButton.onclick = () => {
cleanup();
reject(new Error('User cancelled photo capture.'));
};
} catch (err) {
console.error("Error accessing camera: ", err);
reject(err);
}
});
}
};

164
src/services/MqttService.js Normal file
View File

@@ -0,0 +1,164 @@
// src/services/MqttService.js
import mqtt from 'mqtt';
import { ref } from 'vue';
const client = ref(null);
const connectionStatus = ref('disconnected');
const error = ref(null);
const receivedMessages = ref([]); // For debugging
const MQTT_TOPIC_GAME = 'game';
let generalMessageHandlerCallback = null; // For App.vue to handle game commands
// --- MQTT Character Capture State ---
const isCapturingMqttChar = ref(false);
const mqttCharCaptureCallback = ref(null); // Function to call when a char is captured
const startMqttCharCapture = (onCapturedCallback) => {
console.log("MQTT Service: Starting character capture mode.");
isCapturingMqttChar.value = true;
mqttCharCaptureCallback.value = onCapturedCallback;
};
const stopMqttCharCapture = () => {
console.log("MQTT Service: Stopping character capture mode.");
isCapturingMqttChar.value = false;
mqttCharCaptureCallback.value = null;
};
// --- End MQTT Character Capture State ---
const connect = async (brokerUrl = 'ws://localhost:9001') => {
// ... (existing connect logic)
if (client.value && client.value.connected) { return; }
if (connectionStatus.value === 'connecting') { return; }
console.log(`MQTT: Attempting to connect to ${brokerUrl}...`);
connectionStatus.value = 'connecting';
error.value = null;
let fullBrokerUrl = brokerUrl;
if (!brokerUrl.startsWith('ws://') && !brokerUrl.startsWith('wss://')) {
if (brokerUrl.includes(':1883')) {
fullBrokerUrl = `ws://${brokerUrl}`;
} else if (!brokerUrl.includes(':')) {
fullBrokerUrl = `ws://${brokerUrl}:9001`;
} else {
fullBrokerUrl = `ws://${brokerUrl}`;
}
}
try {
const connectFn = typeof mqtt === 'function' ? mqtt : (mqtt.connect || (mqtt.default && mqtt.default.connect));
if (typeof connectFn !== 'function') {
throw new Error("MQTT connect function not found.");
}
client.value = connectFn(fullBrokerUrl, {
reconnectPeriod: 5000,
connectTimeout: 10000,
});
client.value.on('connect', () => {
console.log('MQTT: Connected!');
connectionStatus.value = 'connected';
error.value = null;
client.value.subscribe(MQTT_TOPIC_GAME, (err) => {
if (!err) { console.log(`MQTT: Subscribed to "${MQTT_TOPIC_GAME}"`); }
else {
console.error('MQTT: Subscr. error:', err);
connectionStatus.value = 'error'; error.value = 'Subscription failed.';
}
});
});
client.value.on('message', (topic, message) => {
const msgString = message.toString();
const char = msgString.charAt(0).toLowerCase(); // Process as lowercase single char
console.log(`MQTT: Received on "${topic}": '${msgString}' -> processed char: '${char}'`);
receivedMessages.value.push({ topic, message: msgString, time: new Date() });
if (topic === MQTT_TOPIC_GAME && char.length === 1) {
if (isCapturingMqttChar.value && mqttCharCaptureCallback.value) {
console.log(`MQTT Service: Captured char '${char}' for registration.`);
mqttCharCaptureCallback.value(char); // Pass char to the specific capture handler
stopMqttCharCapture(); // Stop capture mode after one char
} else if (generalMessageHandlerCallback) {
// Normal message processing if not in capture mode
generalMessageHandlerCallback(char);
}
}
});
client.value.on('error', (err) => { /* ... existing error handling ... */
console.error('MQTT: Connection error:', err);
connectionStatus.value = 'error';
error.value = err.message || 'Connection error';
});
client.value.on('reconnect', () => { /* ... existing reconnect handling ... */
console.log('MQTT: Reconnecting...');
connectionStatus.value = 'connecting';
});
client.value.on('offline', () => { /* ... existing offline handling ... */
console.log('MQTT: Client offline.');
});
client.value.on('close', () => { /* ... existing close handling ... */
console.log('MQTT: Connection closed.');
if (connectionStatus.value !== 'error' && connectionStatus.value !== 'connecting') {
connectionStatus.value = 'disconnected';
}
});
} catch (err) { /* ... existing catch ... */
console.error('MQTT: Setup error during connect call:', err);
connectionStatus.value = 'error';
error.value = err.message || 'Setup failed.';
if(client.value && typeof client.value.end === 'function') client.value.end(true);
client.value = null;
}
};
const disconnect = () => {
if (client.value) {
console.log('MQTT: Disconnecting/Stopping connection attempt...');
// Set status immediately to give user feedback, 'close' event will confirm
// but if it was 'connecting', it might not emit 'close' if it never truly connected.
const wasConnecting = connectionStatus.value === 'connecting';
client.value.end(true, () => { // true forces close and stops reconnect attempts
console.log('MQTT: client.end() callback executed.');
// The 'close' event listener on the client should handle final cleanup
// like setting client.value = null and connectionStatus.value = 'disconnected'.
// If it was just 'connecting' and never connected, 'close' might not fire reliably.
if (wasConnecting && connectionStatus.value !== 'disconnected') {
connectionStatus.value = 'disconnected';
client.value = null; // Ensure cleanup if 'close' doesn't fire from 'connecting' state
}
});
// If it was 'connecting', we might want to immediately reflect disconnected state
// as 'end(true)' stops further attempts.
if (wasConnecting) {
connectionStatus.value = 'disconnected';
// Note: client.value will be fully nulled on the 'close' event or after end() callback.
}
} else {
console.log('MQTT: No active client to disconnect.');
connectionStatus.value = 'disconnected'; // Ensure status is correct
}
};
const setGeneralMessageHandler = (handler) => { // Renamed for clarity
generalMessageHandlerCallback = handler;
};
export const MqttService = {
connect,
disconnect,
setGeneralMessageHandler, // Use this for App.vue game commands
connectionStatus,
error,
receivedMessages,
MQTT_TOPIC_GAME,
getClient: () => client.value,
// New methods for capture mode
startMqttCharCapture,
stopMqttCharCapture,
isCapturingMqttChar // Expose reactive state if needed elsewhere, though not directly used by components
};

View File

@@ -0,0 +1,23 @@
const STORAGE_KEY = 'nexusTimerState';
export const StorageService = {
getState() {
const savedState = localStorage.getItem(STORAGE_KEY);
if (savedState) {
try {
return JSON.parse(savedState);
} catch (e) {
console.error("Error parsing saved state from localStorage", e);
localStorage.removeItem(STORAGE_KEY); // Clear corrupted data
return null;
}
}
return null;
},
saveState(state) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
},
clearState() {
localStorage.removeItem(STORAGE_KEY);
}
};

View File

@@ -0,0 +1,65 @@
let wakeLock = null;
let wakeLockActive = false;
const requestWakeLock = async () => {
if ('wakeLock' in navigator && !wakeLockActive) {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLockActive = true;
console.log('Screen Wake Lock activated.');
wakeLock.addEventListener('release', () => {
console.log('Screen Wake Lock was released.');
wakeLockActive = false;
wakeLock = null; // Clear the reference
// Optionally, re-request if it was released unexpectedly and should be active
// For now, we'll let it be re-requested manually by the app logic
});
} catch (err) {
console.error(`Failed to acquire Screen Wake Lock: ${err.name}, ${err.message}`);
wakeLock = null;
wakeLockActive = false;
}
} else {
console.warn('Screen Wake Lock API not supported or already active.');
}
};
const releaseWakeLock = async () => {
if (wakeLock && wakeLockActive) {
try {
await wakeLock.release();
// The 'release' event listener on wakeLock itself will set wakeLockActive = false and wakeLock = null
} catch (err) {
console.error(`Failed to release Screen Wake Lock: ${err.name}, ${err.message}`);
// Even if release fails, mark as inactive to allow re-request
wakeLock = null;
wakeLockActive = false;
}
} else {
// console.log('No active Screen Wake Lock to release or already released.');
}
};
// Handle visibility changes to re-acquire lock if necessary
const handleVisibilityChange = () => {
if (wakeLock !== null && document.visibilityState === 'visible') {
// If we had a wake lock and the page became visible again,
// it might have been released by the browser. Try to re-acquire.
// This behavior is usually handled automatically by the browser with the 'release' event
// but can be a fallback. For now, we rely on manual re-request.
// console.log('Page visible, checking wake lock status.');
} else if (document.visibilityState === 'hidden' && wakeLockActive) {
// The browser usually releases the wake lock when tab is hidden.
// Our 'release' event listener should handle this.
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// document.addEventListener('fullscreenchange', handleVisibilityChange); // Also useful for fullscreen
export const WakeLockService = {
request: requestWakeLock,
release: releaseWakeLock,
isActive: () => wakeLockActive,
};

531
src/store/index.js Normal file
View File

@@ -0,0 +1,531 @@
import { createStore } from 'vuex';
import { StorageService } from '../services/StorageService';
import { parseTime, formatTime } from '../utils/timeFormatter';
const MAX_NEGATIVE_SECONDS = -(59 * 60 + 59); // -59:59
const DEFAULT_AVATAR_MARKER = null; // Ensure this is defined
// Define predefined players
const predefinedPlayers = [
{
id: 'predefined-1', // Unique ID for predefined player 1
name: 'Player 1',
avatar: DEFAULT_AVATAR_MARKER, // Or a specific avatar path if you have one
initialTimerSec: 60 * 60, // 60:00
currentTimerSec: 60 * 60,
hotkey: '1', // Hotkey '1'
isSkipped: false,
isTimerRunning: false,
},
{
id: 'predefined-2', // Unique ID for predefined player 2
name: 'Player 2',
avatar: DEFAULT_AVATAR_MARKER, // Or a specific avatar path
initialTimerSec: 60 * 60, // 60:00
currentTimerSec: 60 * 60,
hotkey: '2', // Hotkey '2'
isSkipped: false,
isTimerRunning: false,
}
];
const initialState = {
players: JSON.parse(JSON.stringify(predefinedPlayers)), // Start with predefined players (deep copy)
globalHotkeyStopPause: null,
globalHotkeyRunAll: null,
globalHotkeyPassTurn: null,
globalMqttStopPause: null,
globalMqttRunAll: null,
globalMqttPassTurn: null,
mqttBrokerUrl: 'ws://localhost:9001',
mqttConnectDesired: false,
currentPlayerIndex: 0,
gameMode: 'normal',
isMuted: false,
theme: 'dark',
gameRunning: false,
};
// Helper function to create a new player object
const createPlayerObject = (playerData = {}) => ({
id: playerData.id || Date.now().toString() + Math.random(),
name: playerData.name || `Player ${store.state.players.length + 1}`, // Access store carefully here or pass length
avatar: playerData.avatar === undefined ? DEFAULT_AVATAR_MARKER : playerData.avatar,
initialTimerSec: playerData.initialTimerSec || 3600,
currentTimerSec: playerData.currentTimerSec || playerData.initialTimerSec || 3600,
hotkey: playerData.hotkey || null,
mqttChar: playerData.mqttChar || null, // New
isSkipped: playerData.isSkipped || false,
isTimerRunning: playerData.isTimerRunning || false,
});
export default createStore({
state: () => {
const persistedState = StorageService.getState();
if (persistedState) {
let playersToUse = persistedState.players;
if (!playersToUse || (playersToUse.length === 0 && !persistedState.hasOwnProperty('players'))) {
playersToUse = JSON.parse(JSON.stringify(predefinedPlayers.map(p => createPlayerObject(p))));
} else if (persistedState.hasOwnProperty('players') && playersToUse.length === 0) {
playersToUse = [];
}
playersToUse = playersToUse.map(p_persisted => {
const p_base = predefinedPlayers.find(p_def => p_def.id === p_persisted.id) || {};
return createPlayerObject({ ...p_base, ...p_persisted, isTimerRunning: false });
});
return {
...initialState, // Start with all initial state defaults
...persistedState, // Override with persisted values
players: playersToUse, // Specifically set processed players
globalHotkeyPassTurn: persistedState.globalHotkeyPassTurn || initialState.globalHotkeyPassTurn,
globalMqttPassTurn: persistedState.globalMqttPassTurn || initialState.globalMqttPassTurn,
globalHotkeyStopPause: persistedState.globalHotkeyStopPause || initialState.globalHotkeyStopPause,
globalMqttStopPause: persistedState.globalMqttStopPause || initialState.globalMqttStopPause,
globalHotkeyRunAll: persistedState.globalHotkeyRunAll || initialState.globalHotkeyRunAll,
globalMqttRunAll: persistedState.globalMqttRunAll || initialState.globalMqttRunAll,
mqttBrokerUrl: persistedState.mqttBrokerUrl || initialState.mqttBrokerUrl,
mqttConnectDesired: persistedState.hasOwnProperty('mqttConnectDesired') ? persistedState.mqttConnectDesired : initialState.mqttConnectDesired,
gameRunning: false, // Always start non-running
currentPlayerIndex: persistedState.currentPlayerIndex !== undefined && persistedState.currentPlayerIndex < playersToUse.length ? persistedState.currentPlayerIndex : 0,
};
}
// If no persisted state, deep copy initialState which includes new MQTT fields
const newInitialState = JSON.parse(JSON.stringify(initialState));
newInitialState.players = newInitialState.players.map(p => createPlayerObject(p));
return newInitialState;
},
mutations: {
ADD_PLAYER(state, playerConfig) {
if (state.players.length < 99) {
const newPlayer = createPlayerObject({
name: playerConfig.name,
avatar: playerConfig.avatar,
initialTimerSec: playerConfig.initialTimerSec,
currentTimerSec: playerConfig.currentTimerSec, // Set from form
hotkey: playerConfig.hotkey,
mqttChar: playerConfig.mqttChar // From form
});
state.players.push(newPlayer);
} else {
alert("Maximum player limit (99) reached.");
}
},
UPDATE_PLAYER(state, updatedPlayer) {
const index = state.players.findIndex(p => p.id === updatedPlayer.id);
if (index !== -1) {
state.players[index] = { ...state.players[index], ...updatedPlayer };
}
},
SET_MQTT_BROKER_URL(state, url) {
state.mqttBrokerUrl = url;
state.mqttConnectDesired = true;
},
SET_MQTT_CONNECT_DESIRED(state, desired) {
state.mqttConnectDesired = desired;
},
SET_GLOBAL_HOTKEY_STOP_PAUSE(state, key) {
state.globalHotkeyStopPause = key;
},
SET_GLOBAL_HOTKEY_RUN_ALL(state, key) {
state.globalHotkeyRunAll = key;
},
SET_GLOBAL_HOTKEY_PASS_TURN(state, key) {
state.globalHotkeyPassTurn = key;
},
SET_GLOBAL_MQTT_PASS_TURN(state, char) {
state.globalMqttPassTurn = char;
},
SET_GLOBAL_MQTT_STOP_PAUSE(state, char) {
state.globalMqttStopPause = char;
},
SET_GLOBAL_MQTT_RUN_ALL(state, char) {
state.globalMqttRunAll = char;
},
SET_PLAYERS(state, playersData) { // Used by fullResetApp and potentially reorder
state.players = playersData.map(p => createPlayerObject(p));
},
SET_THEME(state, theme) { state.theme = theme; },
DELETE_PLAYER(state, playerId) {
state.players = state.players.filter(p => p.id !== playerId);
if (state.currentPlayerIndex >= state.players.length && state.players.length > 0) {
state.currentPlayerIndex = state.players.length - 1;
} else if (state.players.length === 0) {
state.currentPlayerIndex = 0;
}
},
REORDER_PLAYERS(state, players) {
state.players = players;
},
SHUFFLE_PLAYERS(state) {
for (let i = state.players.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[state.players[i], state.players[j]] = [state.players[j], state.players[i]];
}
},
REVERSE_PLAYERS(state) {
state.players.reverse();
},
SET_CURRENT_PLAYER_INDEX(state, index) {
state.currentPlayerIndex = index;
},
SET_GAME_MODE(state, mode) {
state.gameMode = mode;
},
SET_IS_MUTED(state, muted) {
state.isMuted = muted;
},
TOGGLE_THEME(state) {
state.theme = state.theme === 'light' ? 'dark' : 'light';
},
SET_THEME(state, theme) {
state.theme = theme;
},
DECREMENT_TIMER(state, { playerIndex }) {
const player = state.players[playerIndex];
if (player && player.isTimerRunning && !player.isSkipped) {
player.currentTimerSec--;
if (player.currentTimerSec < MAX_NEGATIVE_SECONDS) {
player.currentTimerSec = MAX_NEGATIVE_SECONDS;
player.isSkipped = true; // Auto-skip if max negative time reached
player.isTimerRunning = false;
}
}
},
RESET_PLAYER_TIMER(state, playerIndex) {
if (state.players[playerIndex]) {
state.players[playerIndex].currentTimerSec = state.players[playerIndex].initialTimerSec;
state.players[playerIndex].isSkipped = false;
state.players[playerIndex].isTimerRunning = false;
}
},
RESET_ALL_TIMERS(state) {
// When resetting, decide if you want to go back to *only* predefined players
// or reset existing players' timers. The current spec "restores all timers to initial values"
// implies resetting existing players. If it meant reverting to the initial player set,
// this logic would need to change to:
// state.players = JSON.parse(JSON.stringify(predefinedPlayers));
// For now, sticking to resetting current players' timers:
state.players.forEach(player => {
player.currentTimerSec = player.initialTimerSec;
player.isSkipped = false;
player.isTimerRunning = false;
});
state.currentPlayerIndex = 0;
state.gameMode = 'normal';
state.gameRunning = false;
},
START_PLAYER_TIMER(state, playerIndex) {
if(state.players[playerIndex] && !state.players[playerIndex].isSkipped) {
state.players[playerIndex].isTimerRunning = true;
state.gameRunning = true;
}
},
PAUSE_PLAYER_TIMER(state, playerIndex) {
if(state.players[playerIndex]) {
state.players[playerIndex].isTimerRunning = false;
}
if (!state.players.some(p => p.isTimerRunning)) {
state.gameRunning = false;
}
},
PAUSE_ALL_TIMERS(state) {
state.players.forEach(p => p.isTimerRunning = false);
state.gameRunning = false;
},
SET_GAME_RUNNING(state, isRunning) {
state.gameRunning = isRunning;
},
},
actions: {
loadState({ commit, state }) {
// The state initializer already did the main loading from localStorage.
// This action can be used for any *additional* setup after initial hydration
// or to re-apply certain defaults if needed.
// For now, it's mainly a confirmation that persisted state is used.
// Example: ensure theme is applied if it was loaded
// This is already handled by App.vue's watcher, but could be centralized.
// if (state.theme === 'dark') {
// document.documentElement.classList.add('dark');
// } else {
// document.documentElement.classList.remove('dark');
// }
console.log("Store state loaded/initialized.");
// It's good practice for actions to return a Promise if they are async
// or if other parts of the app expect to chain .then()
return Promise.resolve(); // Resolve immediately
},
saveState({ state }) {
StorageService.saveState({
players: state.players.map(p => ({ // Persist mqttChar for players
id: p.id, name: p.name, avatar: p.avatar,
initialTimerSec: p.initialTimerSec, currentTimerSec: p.currentTimerSec,
hotkey: p.hotkey, mqttChar: p.mqttChar, // Save mqttChar
isSkipped: p.isSkipped,
})),
globalHotkeyPassTurn: state.globalHotkeyPassTurn,
globalMqttPassTurn: state.globalMqttPassTurn,
globalHotkeyStopPause: state.globalHotkeyStopPause,
globalHotkeyRunAll: state.globalHotkeyRunAll,
globalMqttStopPause: state.globalMqttStopPause,
globalMqttRunAll: state.globalMqttRunAll,
mqttBrokerUrl: state.mqttBrokerUrl,
mqttConnectDesired: state.mqttConnectDesired,
currentPlayerIndex: state.currentPlayerIndex,
gameMode: state.gameMode,
isMuted: state.isMuted,
theme: state.theme,
});
},
addPlayer({ commit, dispatch }, player) {
commit('ADD_PLAYER', player);
dispatch('saveState');
},
setMqttBrokerUrl({ commit, dispatch }, url) {
commit('SET_MQTT_BROKER_URL', url);
dispatch('saveState');
},
setMqttConnectDesired({ commit, dispatch }, desired) {
commit('SET_MQTT_CONNECT_DESIRED', desired);
dispatch('saveState');
},
setGlobalMqttStopPause({ commit, dispatch }, char) {
commit('SET_GLOBAL_MQTT_STOP_PAUSE', char);
dispatch('saveState');
},
setGlobalMqttRunAll({ commit, dispatch }, char) {
commit('SET_GLOBAL_MQTT_RUN_ALL', char);
dispatch('saveState');
},
setGlobalHotkeyPassTurn({ commit, dispatch }, key) {
commit('SET_GLOBAL_HOTKEY_PASS_TURN', key);
dispatch('saveState');
},
setGlobalMqttPassTurn({ commit, dispatch }, char) {
commit('SET_GLOBAL_MQTT_PASS_TURN', char);
dispatch('saveState');
},
updatePlayer({ commit, dispatch }, player) {
commit('UPDATE_PLAYER', player);
dispatch('saveState');
},
deletePlayer({ commit, dispatch }, playerId) {
commit('DELETE_PLAYER', playerId);
dispatch('saveState');
},
reorderPlayers({commit, dispatch}, players) {
commit('REORDER_PLAYERS', players);
dispatch('saveState');
},
shufflePlayers({commit, dispatch}) {
commit('SHUFFLE_PLAYERS');
dispatch('saveState');
},
reversePlayers({commit, dispatch}) {
commit('REVERSE_PLAYERS');
dispatch('saveState');
},
toggleTheme({ commit, dispatch }) {
commit('TOGGLE_THEME');
dispatch('saveState');
},
setGlobalHotkey({ commit, dispatch }, key) {
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', key);
dispatch('saveState');
},
setMuted({ commit, dispatch }, muted) {
commit('SET_IS_MUTED', muted);
dispatch('saveState');
},
resetGame({ commit, dispatch }) {
commit('RESET_ALL_TIMERS');
dispatch('saveState');
},
setGlobalHotkeyStopPause({ commit, dispatch }, key) {
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', key);
dispatch('saveState');
},
setGlobalHotkeyRunAll({ commit, dispatch }, key) {
commit('SET_GLOBAL_HOTKEY_RUN_ALL', key);
dispatch('saveState');
},
fullResetApp({ commit, dispatch, state: currentGlobalState }) {
StorageService.clearState();
const freshInitialState = JSON.parse(JSON.stringify(initialState));
freshInitialState.players = freshInitialState.players.map(p => createPlayerObject(p));
commit('SET_PLAYERS', freshInitialState.players);
commit('SET_CURRENT_PLAYER_INDEX', freshInitialState.currentPlayerIndex);
commit('SET_GAME_MODE', freshInitialState.gameMode);
commit('SET_IS_MUTED', freshInitialState.isMuted);
commit('SET_MQTT_BROKER_URL', freshInitialState.mqttBrokerUrl);
commit('SET_MQTT_CONNECT_DESIRED', freshInitialState.mqttConnectDesired);
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', freshInitialState.globalHotkeyStopPause);
commit('SET_GLOBAL_MQTT_STOP_PAUSE', freshInitialState.globalMqttStopPause);
commit('SET_GLOBAL_HOTKEY_RUN_ALL', freshInitialState.globalHotkeyRunAll);
commit('SET_GLOBAL_MQTT_RUN_ALL', freshInitialState.globalMqttRunAll);
commit('SET_GLOBAL_HOTKEY_PASS_TURN', freshInitialState.globalHotkeyPassTurn);
commit('SET_GLOBAL_MQTT_PASS_TURN', freshInitialState.globalMqttPassTurn);
if (currentGlobalState.theme !== freshInitialState.theme) {
commit('SET_THEME', freshInitialState.theme);
}
commit('SET_GAME_RUNNING', false);
dispatch('saveState');
},
tick({ commit, state }) {
if (state.gameMode === 'normal') {
if (state.players[state.currentPlayerIndex]?.isTimerRunning) {
commit('DECREMENT_TIMER', { playerIndex: state.currentPlayerIndex });
}
} else if (state.gameMode === 'allTimers') {
state.players.forEach((player, index) => {
if (player.isTimerRunning) {
commit('DECREMENT_TIMER', { playerIndex: index });
}
});
}
},
passTurn({ commit, state, dispatch }) {
const numPlayers = state.players.length;
if (numPlayers === 0) return;
const currentIdx = state.currentPlayerIndex;
const currentPlayerTimerWasRunning = state.players[currentIdx]?.isTimerRunning;
commit('PAUSE_PLAYER_TIMER', currentIdx);
let nextPlayerIndex = (currentIdx + 1) % numPlayers;
let skippedCount = 0;
while(state.players[nextPlayerIndex]?.isSkipped && skippedCount < numPlayers) {
nextPlayerIndex = (nextPlayerIndex + 1) % numPlayers;
skippedCount++;
}
if (skippedCount === numPlayers) {
commit('PAUSE_ALL_TIMERS');
dispatch('saveState');
return;
}
commit('SET_CURRENT_PLAYER_INDEX', nextPlayerIndex);
if (currentPlayerTimerWasRunning && !state.players[nextPlayerIndex].isSkipped) {
commit('START_PLAYER_TIMER', nextPlayerIndex);
} else {
if (state.players[nextPlayerIndex] && !state.players[nextPlayerIndex].isSkipped) {
commit('PAUSE_PLAYER_TIMER', nextPlayerIndex);
}
}
dispatch('saveState');
},
toggleCurrentPlayerTimerNormalMode({ commit, state, dispatch }) {
const player = state.players[state.currentPlayerIndex];
if (!player) return;
if (player.isTimerRunning) {
commit('PAUSE_PLAYER_TIMER', state.currentPlayerIndex);
} else if (!player.isSkipped) {
commit('START_PLAYER_TIMER', state.currentPlayerIndex);
}
dispatch('saveState');
},
togglePlayerTimerAllTimersMode({ commit, state, dispatch }, playerIndex) {
const player = state.players[playerIndex];
if (!player) return;
if (player.isTimerRunning) {
commit('PAUSE_PLAYER_TIMER', playerIndex);
} else if (!player.isSkipped) {
commit('START_PLAYER_TIMER', playerIndex);
}
const anyTimerRunning = state.players.some(p => p.isTimerRunning && !p.isSkipped);
if (!anyTimerRunning && state.players.length > 0 && state.gameMode === 'allTimers') {
// This auto-revert logic is now in GameView.vue watcher for better control over timing
}
dispatch('saveState');
},
globalStopPauseAll({ commit, state, dispatch }) {
if (state.gameMode === 'normal') {
dispatch('toggleCurrentPlayerTimerNormalMode');
} else {
const anyTimerRunning = state.players.some(p => p.isTimerRunning && !p.isSkipped);
if (anyTimerRunning) {
commit('PAUSE_ALL_TIMERS');
} else {
state.players.forEach((player, index) => {
if (!player.isSkipped) {
commit('START_PLAYER_TIMER', index);
}
});
}
}
dispatch('saveState');
},
switchToAllTimersMode({ commit, state, dispatch }) {
commit('SET_GAME_MODE', 'allTimers');
let anyStarted = false;
state.players.forEach((player, index) => {
if (!player.isSkipped) {
commit('START_PLAYER_TIMER', index);
anyStarted = true;
}
});
if(anyStarted) commit('SET_GAME_RUNNING', true);
else commit('SET_GAME_RUNNING', false);
dispatch('saveState');
},
switchToNormalMode({commit, state, dispatch}) {
commit('PAUSE_ALL_TIMERS');
commit('SET_GAME_MODE', 'normal');
// Determine current player for normal mode, respecting skips
let currentIdx = state.currentPlayerIndex;
let skippedCount = 0;
while(state.players[currentIdx]?.isSkipped && skippedCount < state.players.length) {
currentIdx = (currentIdx + 1) % state.players.length;
skippedCount++;
}
if (skippedCount < state.players.length) {
commit('SET_CURRENT_PLAYER_INDEX', currentIdx);
// Timer for this player should remain paused as per PAUSE_ALL_TIMERS
} else {
// All players skipped, game is effectively paused.
commit('SET_GAME_RUNNING', false);
}
dispatch('saveState');
}
},
getters: {
players: state => state.players,
currentPlayer: state => state.players[state.currentPlayerIndex],
nextPlayer: state => {
if (!state.players || state.players.length < 1) return null;
let nextIndex = (state.currentPlayerIndex + 1) % state.players.length;
let count = 0;
while(state.players[nextIndex]?.isSkipped && count < state.players.length) {
nextIndex = (nextIndex + 1) % state.players.length;
count++;
}
return state.players[nextIndex];
},
getPlayerById: (state) => (id) => state.players.find(p => p.id === id),
gameMode: state => state.gameMode,
isMuted: state => state.isMuted,
theme: state => state.theme,
mqttBrokerUrl: state => state.mqttBrokerUrl,
mqttConnectDesired: state => state.mqttConnectDesired,
globalHotkeyStopPause: state => state.globalHotkeyStopPause,
globalMqttStopPause: state => state.globalMqttStopPause,
globalHotkeyRunAll: state => state.globalHotkeyRunAll,
globalMqttRunAll: state => state.globalMqttRunAll,
globalHotkeyPassTurn: state => state.globalHotkeyPassTurn,
globalMqttPassTurn: state => state.globalMqttPassTurn,
totalPlayers: state => state.players.length,
gameRunning: state => state.gameRunning,
maxNegativeTimeReached: () => MAX_NEGATIVE_SECONDS,
}
});

166
src/sw.js Normal file
View File

@@ -0,0 +1,166 @@
const CACHE_VERSION = typeof __APP_CACHE_VERSION__ !== 'undefined'
? __APP_CACHE_VERSION__
: 'nexus-timer-cache-fallback-dev-vManual';
const APP_SHELL_URLS = [
// Precache the root (index.html) explicitly for better offline fallback
'/',
'/manifest.json',
'/favicon.ico',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png',
'/icons/maskable-icon-192x192.png',
'/icons/maskable-icon-512x512.png',
'/icons/shortcut-setup-96x96.png',
'/icons/shortcut-info-96x96.png',
];
self.addEventListener('install', event => {
console.log(`[SW ${CACHE_VERSION}] Install`);
event.waitUntil(
caches.open(CACHE_VERSION)
.then(cache => {
console.log(`[SW ${CACHE_VERSION}] Caching app shell essentials`);
return cache.addAll(APP_SHELL_URLS);
})
.then(() => {
console.log(`[SW ${CACHE_VERSION}] Skip waiting on install.`);
return self.skipWaiting();
})
);
});
self.addEventListener('activate', event => {
console.log(`[SW ${CACHE_VERSION}] Activate`);
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_VERSION) {
console.log(`[SW ${CACHE_VERSION}] Deleting old cache: ${cacheName}`);
return caches.delete(cacheName);
}
})
);
}).then(() => {
console.log(`[SW ${CACHE_VERSION}] Clients claimed.`);
return self.clients.claim();
})
);
});
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET' || !url.protocol.startsWith('http')) {
return;
}
// Strategy 1: Network First, then Cache for Navigation/HTML requests
if (request.mode === 'navigate' || request.destination === 'document' || url.pathname === '/') {
// console.log(`[SW ${CACHE_VERSION}] NetworkFirst for navigation/document: ${request.url}`);
event.respondWith(
fetch(request)
.then(networkResponse => {
// If successful, cache the response and return it
if (networkResponse.ok) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_VERSION).then(cache => {
// For navigations, it's often best to cache the specific URL requested
// as well as potentially updating the '/' cache if this is the root.
cache.put(request, responseToCache);
if (url.pathname === '/') { // Also update root cache if it's the index
const rootResponseClone = networkResponse.clone(); // Need another clone
cache.put('/', rootResponseClone);
}
});
}
return networkResponse;
})
.catch(async () => {
// Network failed. Try to serve from cache.
// console.warn(`[SW ${CACHE_VERSION}] Network fetch failed for ${request.url}. Attempting cache.`);
// 1. Try matching the specific request first (e.g. /info, /game)
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// console.log(`[SW ${CACHE_VERSION}] Serving from cache (specific request): ${request.url}`);
return cachedResponse;
}
// 2. If specific request not found, try serving the app shell ('/')
// This is crucial for SPAs to work offline.
const appShellResponse = await caches.match('/');
if (appShellResponse) {
// console.log(`[SW ${CACHE_VERSION}] Serving app shell ('/') from cache for: ${request.url}`);
return appShellResponse;
}
// 3. If even the app shell is not in cache (shouldn't happen if install was successful)
console.error(`[SW ${CACHE_VERSION}] CRITICAL: Network and cache miss for navigation AND app shell ('/') for: ${request.url}`);
// Return a very basic offline message, but ideally this state is avoided.
return new Response(
`<h1>Offline</h1><p>The application is currently offline and the requested page could not be loaded from the cache. Please check your connection.</p>`,
{ headers: { 'Content-Type': 'text/html' } }
);
})
);
return;
}
// Strategy 2: Stale-While-Revalidate for assets (CSS, JS, images, fonts, workers)
if (request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'worker' ||
request.destination === 'image' ||
request.destination === 'font') {
// console.log(`[SW ${CACHE_VERSION}] StaleWhileRevalidate for asset: ${request.url}`);
event.respondWith(
caches.open(CACHE_VERSION).then(cache => {
return cache.match(request).then(cachedResponse => {
const fetchPromise = fetch(request).then(networkResponse => {
if (networkResponse.ok) {
const responseToCache = networkResponse.clone();
cache.put(request, responseToCache);
}
return networkResponse;
}).catch(err => {
// If fetch fails, and we served from cache, it's fine.
// If cache also missed, this error will propagate.
// console.warn(`[SW ${CACHE_VERSION}] SWR: Network fetch error for ${request.url}`, err);
throw err;
});
return cachedResponse || fetchPromise;
}).catch(() => {
// Fallback to network if cache.match fails
// console.warn(`[SW ${CACHE_VERSION}] SWR: Cache match error for ${request.url}, trying network directly.`);
return fetch(request);
});
})
);
return;
}
// Strategy 3: Cache First for other types of requests (e.g., manifest.json if not in APP_SHELL_URLS)
// console.log(`[SW ${CACHE_VERSION}] CacheFirst for: ${request.url}`);
event.respondWith(
caches.match(request)
.then(response => {
return response || fetch(request).then(networkResponse => {
if(networkResponse.ok) {
const responseClone = networkResponse.clone();
caches.open(CACHE_VERSION).then(cache => cache.put(request, responseClone));
}
return networkResponse;
});
})
);
});
self.addEventListener('message', event => {
if (event.data && event.data.action === 'skipWaiting') {
console.log(`[SW ${CACHE_VERSION}] Received skipWaiting message, activating new SW.`);
self.skipWaiting();
}
});

View File

@@ -0,0 +1,32 @@
export function formatTime(totalSeconds) {
const isNegative = totalSeconds < 0;
if (isNegative) {
totalSeconds = -totalSeconds;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const paddedMinutes = String(minutes).padStart(2, '0');
const paddedSeconds = String(seconds).padStart(2, '0');
return `${isNegative ? '-' : ''}${paddedMinutes}:${paddedSeconds}`;
}
export function parseTime(timeString) { // MM:SS or -MM:SS
if (!timeString || typeof timeString !== 'string') return 0;
const isNegative = timeString.startsWith('-');
if (isNegative) {
timeString = timeString.substring(1);
}
const parts = timeString.split(':');
if (parts.length !== 2) return 0;
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
if (isNaN(minutes) || isNaN(seconds)) return 0;
let totalSeconds = (minutes * 60) + seconds;
return isNegative ? -totalSeconds : totalSeconds;
}

263
src/views/GameView.vue Normal file
View 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
View 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
View 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>