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

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,
};