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:
133
src/services/AudioService.js
Normal file
133
src/services/AudioService.js
Normal 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
69
src/services/CameraService.js
Normal file
69
src/services/CameraService.js
Normal 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
164
src/services/MqttService.js
Normal 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
|
||||
};
|
||||
23
src/services/StorageService.js
Normal file
23
src/services/StorageService.js
Normal 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);
|
||||
}
|
||||
};
|
||||
65
src/services/WakeLockService.js
Normal file
65
src/services/WakeLockService.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user