From 3bc5c0eb3416afa839c2eb694fbe0768967ff0b3 Mon Sep 17 00:00:00 2001 From: Mathias Malmqvist Date: Mon, 22 Sep 2025 00:23:34 +0200 Subject: [PATCH] Add new "Quick test" modal --- css/main.css | 40 ++ index.html | 18 +- js/controller-manager.js | 13 +- js/controllers/ds5-controller.js | 10 +- js/core.js | 359 +----------------- js/modals/quick-test-modal.js | 614 +++++++++++++++++++++++++++++++ js/template-loader.js | 3 +- templates/quick-test-modal.html | 173 +++++++++ 8 files changed, 859 insertions(+), 371 deletions(-) create mode 100644 js/modals/quick-test-modal.js create mode 100644 templates/quick-test-modal.html diff --git a/css/main.css b/css/main.css index cfa634d..a457ed0 100644 --- a/css/main.css +++ b/css/main.css @@ -26,3 +26,43 @@ dl.row dd { background-color: #0d6efd !important; color: white !important; } + +/* Quick Test Icon Animations */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); } + 20%, 40%, 60%, 80% { transform: translateX(2px); } +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.1); opacity: 0.8; } +} + +@keyframes bounce { + 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } + 40% { transform: translateY(-3px); } + 60% { transform: translateY(-2px); } +} + +@keyframes glow { + 0%, 100% { text-shadow: 0 0 5px rgba(13, 110, 253, 0.5); } + 50% { text-shadow: 0 0 15px rgba(13, 110, 253, 0.8), 0 0 25px rgba(13, 110, 253, 0.6); } +} + +/* Animation classes for different test types */ +.test-icon-haptic { + animation: shake 0.5s ease-in-out infinite; +} + +.test-icon-adaptive { + animation: pulse 1s ease-in-out infinite; +} + +.test-icon-speaker { + animation: bounce 0.6s ease-in-out infinite; +} + +.test-icon-microphone { + animation: glow 1.5s ease-in-out infinite; +} diff --git a/index.html b/index.html index 4a2f1a7..de3aebb 100644 --- a/index.html +++ b/index.html @@ -150,21 +150,9 @@ - - - -
diff --git a/js/controller-manager.js b/js/controller-manager.js index ccff057..810f684 100644 --- a/js/controller-manager.js +++ b/js/controller-manager.js @@ -349,19 +349,20 @@ class ControllerManager { // } // } - return await this.currentController.setAdaptiveTrigger(leftPreset, rightPreset); + return await this.currentController?.setAdaptiveTrigger(leftPreset, rightPreset); } /** * Set vibration motors for haptic feedback (DS5 only) - * @param {number} leftMotor - Left motor intensity (0-255) - * @param {number} rightMotor - Right motor intensity (0-255) - * @param {number} duration - Duration in milliseconds (optional) + * @param {Object} options - Vibration options + * @param {number} options.heavyLeft - Left motor intensity (0-255) + * @param {number} options.lightRight - Right motor intensity (0-255) + * @param {number} options.duration - Duration in milliseconds (optional) * @param {Function} doneCb - Callback function called when vibration ends (optional) */ - async setVibration(leftMotor, rightMotor, duration = 0, doneCb = ({success}) => {}) { + async setVibration({heavyLeft, lightRight, duration = 0}, doneCb = ({success}) => {}) { try { - await this.currentController.setVibration(leftMotor, rightMotor); + await this.currentController.setVibration(heavyLeft, lightRight); // If duration is specified, automatically turn off vibration after the duration if (duration > 0) { diff --git a/js/controllers/ds5-controller.js b/js/controllers/ds5-controller.js index 3b53d10..e2f1817 100644 --- a/js/controllers/ds5-controller.js +++ b/js/controllers/ds5-controller.js @@ -661,16 +661,16 @@ class DS5Controller extends BaseController { /** * Set vibration motors for haptic feedback - * @param {number} leftMotor - Left motor intensity (0-255) - * @param {number} rightMotor - Right motor intensity (0-255) + * @param {number} heavyLeft - Left motor intensity (0-255) + * @param {number} lightRight - Right motor intensity (0-255) */ - async setVibration(leftMotor = 0, rightMotor = 0) { + async setVibration(heavyLeft = 0, lightRight = 0) { try { const { validFlag0 } = this.currentOutputState; const outputStruct = new DS5OutputStruct({ ...this.currentOutputState, - bcVibrationLeft: Math.max(0, Math.min(255, leftMotor)), - bcVibrationRight: Math.max(0, Math.min(255, rightMotor)), + bcVibrationLeft: Math.max(0, Math.min(255, heavyLeft)), + bcVibrationRight: Math.max(0, Math.min(255, lightRight)), validFlag0: validFlag0 | DS5_VALID_FLAG0.LEFT_VIBRATION | DS5_VALID_FLAG0.RIGHT_VIBRATION, // Update both vibration motors }); await this.sendOutputReport(outputStruct.pack(), 'set vibration'); diff --git a/js/core.js b/js/core.js index abf0823..063f114 100644 --- a/js/core.js +++ b/js/core.js @@ -9,6 +9,12 @@ import { draw_stick_position, CIRCULARITY_DATA_SIZE } from './stick-renderer.js' import { ds5_finetune, isFinetuneVisible, finetune_handle_controller_input } from './modals/finetune-modal.js'; import { calibrate_stick_centers, auto_calibrate_stick_centers } from './modals/calib-center-modal.js'; import { calibrate_range } from './modals/calib-range-modal.js'; +import { + updateQuickTestButtonVisibility, + show_quick_test_modal, + isQuickTestVisible, + quicktest_handle_controller_input +} from './modals/quick-test-modal.js'; // Application State - manages app-wide state and UI const app = { @@ -248,10 +254,7 @@ async function continue_connection({data, device}) { $("#mainmenu").show(); $("#resetBtn").show(); - updateAdaptiveTriggerButtonVisibility(); - updateHapticFeedbackButtonVisibility(); - updateSpeakerToneButtonVisibility(); - updateMicrophoneTestButtonVisibility(); + updateQuickTestButtonVisibility(controller); $("#d-nvstatus").text = l("Unknown"); $("#d-bdaddr").text = l("Unknown"); @@ -320,10 +323,7 @@ async function disconnect() { $("#onlinebar").hide(); $("#mainmenu").hide(); - updateAdaptiveTriggerButtonVisibility(); - updateHapticFeedbackButtonVisibility(); - updateSpeakerToneButtonVisibility(); - updateMicrophoneTestButtonVisibility(); + updateQuickTestButtonVisibility(controller); } // Wrapper function for HTML onclick handlers @@ -665,6 +665,11 @@ function get_current_test_tab() { function handleControllerInput({ changes, inputConfig, touchPoints, batteryStatus }) { const { buttonMap } = inputConfig; + // Handle Quick Test Modal input (can be open from any tab) + if (isQuickTestVisible()) { + quicktest_handle_controller_input(changes); + } + const current_active_tab = get_current_main_tab(); switch (current_active_tab) { case 'controller-tab': // Main controller tab @@ -696,7 +701,7 @@ function handle_test_input(/* changes */) { const l2 = controller.button_states.l2_analog || 0; const r2 = controller.button_states.r2_analog || 0; if (l2 || r2) { - trigger_haptic_motors(l2, r2); + // trigger_haptic_motors(l2, r2); } break; @@ -882,59 +887,6 @@ function board_model_info() { show_popup(l3 + "

" + l1 + " " + l2, true); } - -const trigger_haptic_motors = (() => { - let haptic_timeout = undefined; - let haptic_last_trigger = 0; - - return async function(strong_motor /*left*/, weak_motor /*right*/) { - // The DS4 contoller has a strong (left) and a weak (right) motor. - // The DS5 emulates the same behavior, but the left and right motors are the same. - - const now = Date.now(); - if (now - haptic_last_trigger < 200) { - return; // Rate limited - ignore calls within 200ms - } - - haptic_last_trigger = now; - - try { - if (!controller.isConnected()) return; - - const model = controller.getModel(); - const device = controller.getDevice(); - if (model == "DS4") { - const data = new Uint8Array([0x05, 0x00, 0, weak_motor, strong_motor]); - await device.sendReport(0x05, data); - } else if (model.startsWith("DS5")) { - const data = new Uint8Array([0x02, 0x00, weak_motor, strong_motor]); - await device.sendReport(0x02, data); - } - - // Stop rumble after duration - clearTimeout(haptic_timeout); - haptic_timeout = setTimeout(stop_haptic_motors, 250); - } catch(error) { - throw new Error(l("Error triggering rumble"), { cause: error }); - } - }; -})(); - -async function stop_haptic_motors() { - if (!controller.isConnected()) return; - - const model = controller.getModel(); - const device = controller.getDevice(); - if (model == "DS4") { - const data = new Uint8Array([0x05, 0x00, 0, 0, 0]); - await device.sendReport(0x05, data); - } else if (model.startsWith("DS5")) { - const data = new Uint8Array([0x02, 0x00, 0, 0]); - await device.sendReport(0x02, data); - } -} - - // Alert Management Functions let alertCounter = 0; @@ -1009,287 +961,9 @@ function infoAlert(message, duration = 5_000) { return pushAlert(message, 'info', duration, false); } -// Adaptive trigger state management -let adaptiveTriggerEnabled = false; -/** - * Toggle the left adaptive trigger on/off - */ -async function toggle_adaptive_trigger() { - const button = document.getElementById('adaptive-trigger-btn'); - const icon = document.getElementById('adaptive-trigger-icon'); - const text = document.getElementById('adaptive-trigger-text'); - // Disable button during operation - button.disabled = true; - try { - if (adaptiveTriggerEnabled) { - await controller.setAdaptiveTriggerPreset({ left: 'off', right: 'off'}); - adaptiveTriggerEnabled = false; - - button.className = 'btn btn-info ds-btn ds-i18n'; - icon.className = 'fas fa-hand-pointer'; - text.textContent = l("Enable Adaptive Trigger"); - - successAlert(l("Adaptive trigger disabled")); - } else { - await controller.setAdaptiveTriggerPreset({ left: 'heavy', right: 'heavy' }); - adaptiveTriggerEnabled = true; - - button.className = 'btn btn-warning ds-btn ds-i18n'; - icon.className = 'fas fa-hand-paper'; - text.textContent = l("Disable Adaptive Trigger"); - - successAlert(l("Adaptive trigger enabled")); - } - } - finally { - button.disabled = false; - } -} - -/** - * Update adaptive trigger button visibility based on controller type - */ -function updateAdaptiveTriggerButtonVisibility() { - const button = document.getElementById('adaptive-trigger-btn'); - if (controller?.isConnected() && controller.getModel() === "DS5") { - button.style.display = 'block'; - } else { - button.style.display = 'none'; - adaptiveTriggerEnabled = false; - } -} - -/** - * Test haptic feedback vibration for 3 seconds - */ -async function test_haptic_feedback() { - const button = document.getElementById('haptic-feedback-btn'); - const icon = document.getElementById('haptic-feedback-icon'); - const text = document.getElementById('haptic-feedback-text'); - - button.disabled = true; - - // Update UI to show vibration is active - button.className = 'btn btn-warning ds-btn ds-i18n'; - icon.className = 'fas fa-mobile-alt fa-shake'; - text.textContent = l("Vibrating..."); - - // Set vibration for 3 seconds (medium intensity on both motors) - await controller.setVibration(255, 255, 3000, ({success}) => { - button.className = 'btn btn-secondary ds-btn ds-i18n'; - icon.className = 'fas fa-mobile-alt'; - text.textContent = l("Test Haptic Feedback"); - button.disabled = false; - }); -} - -/** - * Test speaker tone by playing a 1-second tone through the controller's speaker - */ -async function test_speaker_tone() { - const button = document.getElementById('speaker-tone-btn'); - const icon = document.getElementById('speaker-tone-icon'); - const text = document.getElementById('speaker-tone-text'); - - button.disabled = true; - - // Update UI to show tone is playing - button.className = 'btn btn-warning ds-btn ds-i18n'; - icon.className = 'fas fa-volume-up fa-bounce'; - text.textContent = l("Playing tone..."); - - // Set speaker tone for 1 second - await controller.setSpeakerTone(100, ({success}) => { - button.className = 'btn btn-secondary ds-btn ds-i18n'; - icon.className = 'fas fa-volume-up'; - text.textContent = l("Test Speaker Tone"); - button.disabled = false; - }); -} - -/** - * Test microphone by monitoring audio input levels and asking user to blow into it - */ -async function test_microphone() { - const button = document.getElementById('microphone-test-btn'); - const icon = document.getElementById('microphone-test-icon'); - const text = document.getElementById('microphone-test-text'); - - button.disabled = true; - - try { - // Update UI to show microphone test is starting - button.className = 'btn btn-info ds-btn ds-i18n'; - icon.className = 'fas fa-microphone fa-pulse'; - text.textContent = l("Starting microphone test..."); - - // Request microphone access - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: false, - noiseSuppression: false, - autoGainControl: false - } - }); - - // Create audio context and analyzer - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); - const source = audioContext.createMediaStreamSource(stream); - const analyzer = audioContext.createAnalyser(); - - analyzer.fftSize = 256; - analyzer.smoothingTimeConstant = 0.8; - source.connect(analyzer); - - const bufferLength = analyzer.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - - // Update UI to show listening state - button.className = 'btn btn-success ds-btn ds-i18n'; - icon.className = 'fas fa-microphone fa-pulse'; - text.textContent = l("Blow into the microphone..."); - - let detectionCount = 0; - let isDetecting = false; - const testDuration = 10000; // 10 seconds - const startTime = Date.now(); - - const checkAudioLevel = () => { - if (Date.now() - startTime > testDuration) { - // Test timeout - cleanup(); - button.className = 'btn btn-outline-secondary ds-btn ds-i18n'; - icon.className = 'fas fa-microphone'; - text.textContent = l("Test completed - Try again"); - button.disabled = false; - return; - } - - analyzer.getByteFrequencyData(dataArray); - - // Calculate average volume level - let sum = 0; - for (let i = 0; i < bufferLength; i++) { - sum += dataArray[i]; - } - const average = sum / bufferLength; - - // Detect significant audio input (blowing into mic) - const threshold = 30; // Adjust this value as needed - if (average > threshold) { - if (!isDetecting) { - isDetecting = true; - detectionCount++; - - // Update UI to show detection - button.className = 'btn btn-warning ds-btn ds-i18n'; - icon.className = 'fas fa-microphone fa-bounce'; - text.textContent = l("Microphone input detected!") + ` (${detectionCount})`; - - // Provide haptic feedback if available - if (controller?.isConnected() && controller.getModel() === "DS5") { - controller.setVibration(100, 100, 200); - } - } - } else { - if (isDetecting) { - isDetecting = false; - // Return to listening state - button.className = 'btn btn-success ds-btn ds-i18n'; - icon.className = 'fas fa-microphone fa-pulse'; - text.textContent = l("Blow into the microphone..."); - } - } - - // Continue monitoring - requestAnimationFrame(checkAudioLevel); - }; - - const cleanup = () => { - // Stop all tracks - stream.getTracks().forEach(track => track.stop()); - // Close audio context - if (audioContext.state !== 'closed') { - audioContext.close(); - } - }; - - // Start monitoring - checkAudioLevel(); - - // Auto-stop after test duration - setTimeout(() => { - cleanup(); - if (detectionCount > 0) { - button.className = 'btn btn-success ds-btn ds-i18n'; - icon.className = 'fas fa-microphone fa-check'; - text.textContent = l("Microphone test passed!") + ` (${detectionCount} detections)`; - } else { - button.className = 'btn btn-outline-secondary ds-btn ds-i18n'; - icon.className = 'fas fa-microphone'; - text.textContent = l("No input detected - Try again"); - } - button.disabled = false; - }, testDuration); - - } catch (error) { - console.error('Microphone test error:', error); - - // Update UI to show error - button.className = 'btn btn-danger ds-btn ds-i18n'; - icon.className = 'fas fa-microphone-slash'; - - if (error.name === 'NotAllowedError') { - text.textContent = l("Microphone access denied"); - } else if (error.name === 'NotFoundError') { - text.textContent = l("No microphone found"); - } else { - text.textContent = l("Microphone test failed"); - } - - button.disabled = false; - - // Reset button after 3 seconds - setTimeout(() => { - button.className = 'btn btn-secondary ds-btn ds-i18n'; - icon.className = 'fas fa-microphone'; - text.textContent = l("Test Microphone"); - }, 3000); - } -} - -/** - * Update haptic feedback button visibility based on controller type - */ -function updateHapticFeedbackButtonVisibility() { - const button = document.getElementById('haptic-feedback-btn'); - const model = controller?.getModel(); - const supported = (controller?.isConnected() && model === "DS5"); - button.style.display = supported ? 'block' : 'none'; -} - -/** - * Update speaker tone button visibility based on controller type - */ -function updateSpeakerToneButtonVisibility() { - const button = document.getElementById('speaker-tone-btn'); - const model = controller?.getModel(); - const supported = (controller?.isConnected() && model === "DS5"); - button.style.display = supported ? 'block' : 'none'; -} - -/** - * Update microphone test button visibility based on controller type - */ -function updateMicrophoneTestButtonVisibility() { - const button = document.getElementById('microphone-test-btn'); - const model = controller?.getModel(); - const supported = (controller?.isConnected() && model === "DS5"); - button.style.display = supported ? 'block' : 'none'; -} // Export functions to global scope for HTML onclick handlers @@ -1311,10 +985,7 @@ window.welcome_accepted = welcome_accepted; window.show_donate_modal = show_donate_modal; window.board_model_info = board_model_info; window.edge_color_info = edge_color_info; -window.toggle_adaptive_trigger = toggle_adaptive_trigger; -window.test_haptic_feedback = test_haptic_feedback; -window.test_speaker_tone = test_speaker_tone; -window.test_microphone = test_microphone; +window.show_quick_test_modal = () => show_quick_test_modal(controller, { l }); // Auto-initialize the application when the module loads gboot(); \ No newline at end of file diff --git a/js/modals/quick-test-modal.js b/js/modals/quick-test-modal.js new file mode 100644 index 0000000..147184c --- /dev/null +++ b/js/modals/quick-test-modal.js @@ -0,0 +1,614 @@ +'use strict'; + +const ACCORDION_ELEMENTS = [ + 'usb-test-collapse', + 'haptic-test-collapse', + 'adaptive-test-collapse', + 'speaker-test-collapse', + 'microphone-test-collapse' +]; + +const TEST_SEQUENCE = ['usb', 'haptic', 'adaptive', 'speaker', 'microphone']; + +/** + * Quick Test Modal Class + * Handles controller feature testing including haptic feedback, adaptive triggers, speaker, and microphone functionality + */ +export class QuickTestModal { + constructor(controllerInstance, { l }) { + this.controller = controllerInstance; + this.l = l; + + // Test state + this.state = { + haptic: null, + adaptive: null, + speaker: null, + microphone: null, + microphoneStream: null, + microphoneContext: null, + microphoneMonitoring: false + }; + + // Bind event handlers to maintain proper context + this._boundAccordionShown = (event) => this._handleAccordionShown(event); + this._boundAccordionHidden = (event) => this._handleAccordionHidden(event); + this._boundModalHidden = () => { + console.log("Quick Test modal hidden event triggered"); + this.resetAllTests(); + destroyCurrentInstance(); + }; + + this._initEventListeners(); + } + + /** + * Start icon animation for a specific test type + */ + _startIconAnimation(testType) { + const $accordionItem = $(`#${testType}-test-item`); + const $icon = $accordionItem.find('.accordion-button i'); + $icon.addClass(`test-icon-${testType}`); + } + + /** + * Stop icon animation for a specific test type + */ + _stopIconAnimation(testType) { + const $accordionItem = $(`#${testType}-test-item`); + const $icon = $accordionItem.find('.accordion-button i'); + $icon.removeClass(`test-icon-${testType}`); + } + + /** + * Update the instruction text based on current test state + */ + _updateInstructions() { + const $instructionsText = $('#quick-test-instructions-text'); + const activeTest = this._getCurrentActiveTest(); + const allTestsCompleted = this._areAllTestsCompleted(); + + if (activeTest) { + $instructionsText.html(this.l('Press Square to Pass or Cross to Fail')); + } else if (allTestsCompleted) { + $instructionsText.html(this.l('Press Circle to close, or Square to start over')); + } else { + $instructionsText.html(this.l('Press Square to begin')); + } + } + + /** + * Check if all tests have been completed + */ + _areAllTestsCompleted() { + return TEST_SEQUENCE.every(test => this.state[test] !== null); + } + + /** + * Initialize event listeners for the quick test modal + */ + // Set up event listeners for accordion collapse events to auto-start tests + _initEventListeners() { + ACCORDION_ELEMENTS.forEach(elementId => { + const $element = $(`#${elementId}`); + if ($element.length) { + $element.on('shown.bs.collapse', this._boundAccordionShown); + $element.on('hidden.bs.collapse', this._boundAccordionHidden); + } + }); + + $('#quickTestModal').on('hidden.bs.modal', this._boundModalHidden); + $('#quickTestModal').on('shown.bs.modal', () => { + this._updateInstructions(); + }); + } + + /** + * Remove event listeners + */ + removeEventListeners() { + console.log("Removing event listeners"); + ACCORDION_ELEMENTS.forEach(elementId => { + const $element = $(`#${elementId}`); + if ($element.length) { + $element.off('shown.bs.collapse', this._boundAccordionShown); + $element.off('hidden.bs.collapse', this._boundAccordionHidden); + } + }); + + $('#quickTestModal').off('hidden.bs.modal', this._boundModalHidden); + } + + /** + * Open the Quick Test modal + */ + open() { + bootstrap.Modal.getOrCreateInstance('#quickTestModal').show(); + } + + /** + * Handle accordion section being shown (expanded) + */ + _handleAccordionShown(event) { + const collapseId = event.target.id; + const testType = collapseId.replace('-test-collapse', ''); + + // Update instructions when a test becomes active + this._updateInstructions(); + + // Always auto-start test when section is expanded + // Small delay to ensure UI is fully expanded + setTimeout(() => { + switch (testType) { + case 'haptic': + this._startHapticTest(); + break; + case 'adaptive': + this._startAdaptiveTest(); + break; + case 'speaker': + this._startSpeakerTest(); + break; + case 'microphone': + this._startMicrophoneTest(); + break; + } + }, 100); + } + + /** + * Handle accordion section being hidden (collapsed) + */ + _handleAccordionHidden(event) { + const collapseId = event.target.id; + const testType = collapseId.replace('-test-collapse', ''); + + // Stop ongoing tests when section is collapsed + switch (testType) { + case 'adaptive': + this._stopAdaptiveTest(); + break; + case 'microphone': + this._stopMicrophoneTest(); + break; + } + + // Update instructions when a test is collapsed + setTimeout(() => { + this._updateInstructions(); + }, 300); + } + + /** + * Start haptic vibration test + */ + async _startHapticTest() { + this._startIconAnimation('haptic'); + await this.controller.setVibration({ heavyLeft: 255, lightRight: 255, duration: 1000 }); + setTimeout(() => { this._stopIconAnimation('haptic'); }, 1000); } + + /** + * Start adaptive trigger test + */ + async _startAdaptiveTest() { + this._startIconAnimation('adaptive'); + await this.controller.setAdaptiveTriggerPreset({ left: 'heavy', right: 'heavy' }); + } + + /** + * Stop adaptive trigger test + */ + async _stopAdaptiveTest() { + this._stopIconAnimation('adaptive'); + console.log("Stopping Adaptive Trigger Test", this.controller); + await this.controller.setAdaptiveTriggerPreset({ left: 'off', right: 'off' }); + } + + /** + * Start speaker tone test + */ + async _startSpeakerTest() { + this._startIconAnimation('speaker'); + await this.controller.setSpeakerTone(100); + setTimeout(() => { this._stopIconAnimation('speaker'); }, 1000); + } + + /** + * Start microphone test + */ + async _startMicrophoneTest() { + const $levelContainer = $('#mic-level-container'); + const $levelBar = $('#mic-level-bar'); + + if (this.state.microphoneMonitoring) { + // Stop monitoring + this._stopMicrophoneTest(); + return; + } + + try { + // Request microphone access + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false + } + }); + + // Create audio context and analyzer + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const source = audioContext.createMediaStreamSource(stream); + const analyzer = audioContext.createAnalyser(); + + analyzer.fftSize = 256; + const bufferLength = analyzer.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + source.connect(analyzer); + + this.state.microphoneStream = stream; + this.state.microphoneContext = audioContext; + this.state.microphoneMonitoring = true; + + this._startIconAnimation('microphone'); + + $levelContainer.show(); + + // Monitor audio levels + let isVibrating = false; + const vibrationThreshold = 30; // Audio level threshold to trigger vibration + let count = 0; + + const updateLevel = () => { + if (!this.state.microphoneMonitoring) return; + + analyzer.getByteFrequencyData(dataArray); + + // Calculate average level + const sum = dataArray.reduce((acc, value) => acc + value, 0); + const average = sum / bufferLength; + const percentage = Math.min(100, (average / 255) * 100); + + $levelBar.css('width', percentage + '%'); + $levelBar.attr('aria-valuenow', percentage); + + // Trigger vibration when audio level exceeds threshold + if (percentage > vibrationThreshold && !isVibrating) { + this.controller.setVibration({ heavyLeft: 50, duration: 50 }, () => { isVibrating = false; }); + isVibrating = true; + count++; + } + + if(count > 5){ + const activeTest = this._getCurrentActiveTest(); + this.markTestResult(activeTest, true); + } + + requestAnimationFrame(updateLevel); + }; + + updateLevel(); + + } catch (error) { + console.error('Microphone test failed:', error); + } + } + + /** + * Stop microphone test + */ + _stopMicrophoneTest() { + const $levelContainer = $('#mic-level-container'); + + this.state.microphoneMonitoring = false; + + this._stopIconAnimation('microphone'); + + if (this.state.microphoneStream) { + this.state.microphoneStream.getTracks().forEach(track => track.stop()); + this.state.microphoneStream = null; + } + + if (this.state.microphoneContext) { + this.state.microphoneContext.close(); + this.state.microphoneContext = null; + } + + $levelContainer.hide(); + } + + /** + * Mark test result and update UI + */ + markTestResult(testType, passed) { + this.state[testType] = passed; + + this._stopIconAnimation(testType); + + const $statusBadge = $(`#${testType}-test-status`); + const $accordionItem = $(`#${testType}-test-item`); + const $accordionButton = $accordionItem.find('.accordion-button'); + + $accordionItem.removeClass('border-success border-danger'); + + if (passed) { + $statusBadge.attr('class', 'badge bg-success me-2'); + $statusBadge.text(this.l('Passed')); + $accordionItem.addClass('border-success'); + $accordionButton.css('backgroundColor', 'rgba(25, 135, 84, 0.1)'); // Light green background + } else { + $statusBadge.attr('class', 'badge bg-danger me-2'); + $statusBadge.text(this.l('Failed')); + $accordionItem.addClass('border-danger'); + $accordionButton.css('backgroundColor', 'rgba(220, 53, 69, 0.1)'); // Light red background + } + + // Clean up any active tests + if (testType === 'adaptive') { + this._stopAdaptiveTest(); + } else if (testType === 'microphone') { + this._stopMicrophoneTest(); + } + + this._updateTestSummary(); + + // Auto-expand next test + this._expandNextTest(testType); + } + + /** + * Update test summary display + */ + _updateTestSummary() { + const $summary = $('#test-summary'); + + let completed = 0; + let passed = 0; + + TEST_SEQUENCE.forEach(test => { + if (this.state[test] !== null) { + completed++; + if (this.state[test]) passed++; + } + }); + + if (completed === 0) { + $summary.text(this.l('No tests completed yet.')); + $summary.attr('class', 'text-muted ds-i18n'); + } else { + $summary.text(this.l(`${completed}/4 tests completed. ${passed} passed, ${completed - passed} failed.`)); + $summary.attr('class', completed === 4 ? 'text-success' : 'text-info'); + } + } + + /** + * Expand the next untested item + */ + _expandNextTest(currentTest) { + const currentIndex = TEST_SEQUENCE.indexOf(currentTest); + + // Always collapse the current test first + const $currentCollapse = $(`#${currentTest}-test-collapse`); + bootstrap.Collapse.getInstance($currentCollapse[0])?.hide(); + + // Find next untested item + for (let i = currentIndex + 1; i < TEST_SEQUENCE.length; i++) { + const nextTest = TEST_SEQUENCE[i]; + if (this.state[nextTest] === null) { + const $nextCollapse = $(`#${nextTest}-test-collapse`); + + // Expand next + setTimeout(() => { + bootstrap.Collapse.getOrCreateInstance($nextCollapse[0]).show(); + }, 300); + + break; + } + } + } + + /** + * Get the currently active (expanded) test type + */ + _getCurrentActiveTest() { + for (const test of TEST_SEQUENCE) { + const $collapse = $(`#${test}-test-collapse`); + if ($collapse.hasClass('show')) { + return test; + } + } + return null; + } + + /** + * Handle controller input for test navigation and control + */ + handleControllerInput(changes) { + const activeTest = this._getCurrentActiveTest(); + + // Handle Cross button (Start test sequence OR mark test as passed) + if (changes.square === true) { + if (!activeTest) { + this._startTestSequence(); + } else { + this.markTestResult(activeTest, true); + } + } + + // Handle Square button (Pass) + if (activeTest && changes.cross === true) { + this.markTestResult(activeTest, false); + } + + // Handle Triangle button (Move to previous test) + if (changes.triangle === true) { + this._moveToPreviousTest(); + } + + // Handle Circle button (Close the modal) + if (changes.circle === true) { + bootstrap.Modal.getOrCreateInstance('#quickTestModal').hide(); + } + } + + /** + * Start the test sequence from the beginning + */ + _startTestSequence() { + // First, reset all tests to ensure clean state + this.resetAllTests(); + + // After a short delay, start with the first test + setTimeout(() => { + const [firstTest] = TEST_SEQUENCE; + const $firstCollapse = $(`#${firstTest}-test-collapse`); + bootstrap.Collapse.getOrCreateInstance($firstCollapse[0]).show(); + }, 300); + } + + /** + * Move to the previous test in the sequence + */ + _moveToPreviousTest() { + const activeTest = this._getCurrentActiveTest(); + if (!activeTest) return; + + const currentIndex = TEST_SEQUENCE.indexOf(activeTest); + const previousIndex = currentIndex > 0 ? currentIndex - 1 : 0; + if(previousIndex == currentIndex) return; + + const previousTest = TEST_SEQUENCE[previousIndex]; + + // Collapse current test + const $currentCollapse = $(`#${activeTest}-test-collapse`); + bootstrap.Collapse.getInstance($currentCollapse[0])?.hide(); + + + // Expand previous test after a short delay + setTimeout(() => { + const $previousCollapse = $(`#${previousTest}-test-collapse`); + bootstrap.Collapse.getOrCreateInstance($previousCollapse[0]).show(); + }, 300); + } + + /** + * Reset all tests to initial state + */ + resetAllTests() { + // Reset state + this.state = { + haptic: null, + adaptive: null, + speaker: null, + microphone: null, + microphoneStream: null, + microphoneContext: null, + microphoneMonitoring: false + }; + + // Clean up any active tests + this._stopAdaptiveTest(); + this._stopMicrophoneTest(); + + // Reset UI + TEST_SEQUENCE.forEach(test => { + this._stopIconAnimation(test); + + const $statusBadge = $(`#${test}-test-status`); + const $accordionItem = $(`#${test}-test-item`); + const $accordionButton = $accordionItem.find('.accordion-button'); + + $statusBadge.attr('class', 'badge bg-secondary me-2'); + $statusBadge.text(this.l('Not tested')); + $accordionItem.removeClass('border-success border-danger'); + $accordionButton.css('backgroundColor', ''); // Clear background color + + if (test === 'microphone') { + const $levelContainer = $('#mic-level-container'); + $levelContainer.hide(); + } + }); + + this._updateTestSummary(); + + // Update instructions after reset + this._updateInstructions(); + + // Collapse all accordions + const $accordions = $('#quickTestAccordion .accordion-collapse'); + $accordions.each((index, accordion) => { + bootstrap.Collapse.getInstance(accordion)?.hide(); + }); + } +} + +// Global reference to the current quick test instance +let currentQuickTestInstance = null; + +/** + * Helper function to safely clear the current quick test instance + */ +function destroyCurrentInstance() { + if (currentQuickTestInstance) { + console.log("Destroying current quick test instance"); + currentQuickTestInstance.removeEventListeners(); + currentQuickTestInstance = null; + } +} + +/** + * Update quick test button visibility based on controller type + */ +export function updateQuickTestButtonVisibility(controller) { + const $button = $('#quick-test-btn'); + const model = controller?.getModel(); + const supported = (controller?.isConnected() && (model === "DS5" || model === "DS5_Edge")); + $button.css('display', supported ? 'block' : 'none'); +} + +/** + * Check if the Quick Test Modal is currently visible + */ +export function isQuickTestVisible() { + const $modal = $('#quickTestModal'); + return $modal.hasClass('show'); +} + +/** + * Handle controller input for the Quick Test Modal + */ +export function quicktest_handle_controller_input(changes) { + if (currentQuickTestInstance && isQuickTestVisible()) { + currentQuickTestInstance.handleControllerInput(changes); + } +} + +/** + * Show the Quick Test modal (legacy function for backward compatibility) + */ +export function show_quick_test_modal(controller, { l } = {}) { + // Destroy any existing instance + destroyCurrentInstance(); + + // Create new instance + currentQuickTestInstance = new QuickTestModal(controller, { l }); + currentQuickTestInstance.open(); +} + +// Legacy function exports for backward compatibility (used by HTML onclick handlers) +function markTestResult(testType, passed) { + if (currentQuickTestInstance) { + currentQuickTestInstance.markTestResult(testType, passed); + } +} + +function resetAllTests() { + if (currentQuickTestInstance) { + currentQuickTestInstance.resetAllTests(); + } +} + +// Legacy compatibility - expose functions to window for HTML onclick handlers +window.markTestResult = markTestResult; +window.resetAllTests = resetAllTests; \ No newline at end of file diff --git a/js/template-loader.js b/js/template-loader.js index 880e043..d3d5cbf 100644 --- a/js/template-loader.js +++ b/js/template-loader.js @@ -82,10 +82,11 @@ export async function loadAllTemplates() { const edgeProgressModalHtml = await loadTemplate('edge-progress-modal'); const edgeModalHtml = await loadTemplate('edge-modal'); const donateModalHtml = await loadTemplate('donate-modal'); + const quickTestModalHtml = await loadTemplate('quick-test-modal'); // Create modals container const modalsContainer = document.createElement('div'); modalsContainer.id = 'modals-container'; - modalsContainer.innerHTML = faqModalHtml + popupModalHtml + finetuneModalHtml + calibCenterModalHtml + welcomeModalHtml + calibrateModalHtml + rangeModalHtml + edgeProgressModalHtml + edgeModalHtml + donateModalHtml; + modalsContainer.innerHTML = faqModalHtml + popupModalHtml + finetuneModalHtml + calibCenterModalHtml + welcomeModalHtml + calibrateModalHtml + rangeModalHtml + edgeProgressModalHtml + edgeModalHtml + donateModalHtml + quickTestModalHtml; document.body.appendChild(modalsContainer); } diff --git a/templates/quick-test-modal.html b/templates/quick-test-modal.html new file mode 100644 index 0000000..713c7b2 --- /dev/null +++ b/templates/quick-test-modal.html @@ -0,0 +1,173 @@ + + \ No newline at end of file