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
327 lines
14 KiB
Vue
327 lines
14 KiB
Vue
<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> |