ladybird/Libraries/LibWeb/Gamepad/GamepadHapticActuator.cpp
2025-09-01 21:10:47 +02:00

378 lines
18 KiB
C++

/*
* Copyright (c) 2025, Luke Wilde <luke@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/Bindings/Intrinsics.h>
#include <LibWeb/DOM/DocumentObserver.h>
#include <LibWeb/Gamepad/Gamepad.h>
#include <LibWeb/Gamepad/GamepadHapticActuator.h>
#include <LibWeb/HTML/Scripting/Environments.h>
#include <LibWeb/HTML/Scripting/TemporaryExecutionContext.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/Platform/EventLoopPlugin.h>
#include <LibWeb/Platform/Timer.h>
#include <SDL3/SDL_gamepad.h>
namespace Web::Gamepad {
GC_DEFINE_ALLOCATOR(GamepadHapticActuator);
// FIXME: What is a valid duration and startDelay? The spec doesn't define that.
// Safari: clamps any duration above 5000ms to 5000ms and doesn't seem to clamp or reject any startDelay.
// Chrome: rejects if duration + startDelay > 5000ms.
// Firefox doesn't support vibration at the time of writing.
static constexpr u64 MAX_VIBRATION_DURATION = 5000;
// https://w3c.github.io/gamepad/#dfn-constructing-a-gamepadhapticactuator
GC::Ref<GamepadHapticActuator> GamepadHapticActuator::create(JS::Realm& realm, GC::Ref<Gamepad> gamepad)
{
auto& window = as<HTML::Window>(realm.global_object());
auto document_became_hidden_observer = realm.create<DOM::DocumentObserver>(realm, window.associated_document());
// 1. Let gamepadHapticActuator be a newly created GamepadHapticActuator instance.
auto gamepad_haptic_actuator = realm.create<GamepadHapticActuator>(realm, gamepad, document_became_hidden_observer);
// 2. Let supportedEffectsList be an empty list.
Vector<Bindings::GamepadHapticEffectType> supported_effects_list;
// 3. For each enum value type of GamepadHapticEffectType, if the user agent can send a command to initiate effects
// of that type on that actuator, append type to supportedEffectsList.
SDL_PropertiesID sdl_gamepad_properties = SDL_GetGamepadProperties(gamepad->sdl_gamepad());
if (SDL_GetBooleanProperty(sdl_gamepad_properties, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, /* default_value= */ false))
supported_effects_list.append(Bindings::GamepadHapticEffectType::DualRumble);
if (SDL_GetBooleanProperty(sdl_gamepad_properties, SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN, /* default_value= */ false))
supported_effects_list.append(Bindings::GamepadHapticEffectType::TriggerRumble);
// 4. Set gamepadHapticActuator.[[effects]] to supportedEffectsList.
gamepad_haptic_actuator->m_effects = move(supported_effects_list);
return gamepad_haptic_actuator;
}
GamepadHapticActuator::GamepadHapticActuator(JS::Realm& realm, GC::Ref<Gamepad> gamepad, GC::Ref<DOM::DocumentObserver> document_became_hidden_observer)
: Bindings::PlatformObject(realm)
, m_gamepad(gamepad)
, m_document_became_hidden_observer(document_became_hidden_observer)
{
m_document_became_hidden_observer->set_document_visibility_state_observer([this](HTML::VisibilityState visibility_state) {
if (visibility_state == HTML::VisibilityState::Hidden)
document_became_hidden();
});
}
GamepadHapticActuator::~GamepadHapticActuator() = default;
void GamepadHapticActuator::initialize(JS::Realm& realm)
{
WEB_SET_PROTOTYPE_FOR_INTERFACE(GamepadHapticActuator);
Base::initialize(realm);
}
void GamepadHapticActuator::visit_edges(Cell::Visitor& visitor)
{
Base::visit_edges(visitor);
visitor.visit(m_gamepad);
visitor.visit(m_document_became_hidden_observer);
visitor.visit(m_playing_effect_promise);
visitor.visit(m_playing_effect_timer);
}
// https://w3c.github.io/gamepad/#dfn-valid-effect
static bool is_valid_effect(Bindings::GamepadHapticEffectType type, GamepadEffectParameters const& params)
{
// 1. Given the value of GamepadHapticEffectType type, switch on:
// "dual-rumble"
// If params does not describe a valid dual-rumble effect, return false.
// "trigger-rumble"
// If params does not describe a valid trigger-rumble effect, return false.
// 2. Return true
switch (type) {
case Bindings::GamepadHapticEffectType::DualRumble:
// https://w3c.github.io/gamepad/#dfn-valid-dual-rumble-effect
// Given GamepadEffectParameters params, a valid dual-rumble effect must have a valid duration, a valid
// startDelay, and both the strongMagnitude and the weakMagnitude must be in the range [0 .. 1].
if (Checked<u64>::addition_would_overflow(params.duration, params.start_delay))
return false;
if (params.duration + params.start_delay > MAX_VIBRATION_DURATION)
return false;
if (params.strong_magnitude < 0.0 || params.strong_magnitude > 1.0)
return false;
if (params.weak_magnitude < 0.0 || params.weak_magnitude > 1.0)
return false;
return true;
case Bindings::GamepadHapticEffectType::TriggerRumble:
// https://w3c.github.io/gamepad/#dfn-valid-trigger-rumble-effect
// Given GamepadEffectParameters params, a valid trigger-rumble effect must have a valid duration, a valid
// startDelay, and the strongMagnitude, weakMagnitude, leftTrigger, and rightTrigger must be in the range
// [0 .. 1].
if (Checked<u64>::addition_would_overflow(params.duration, params.start_delay))
return false;
if (params.duration + params.start_delay > MAX_VIBRATION_DURATION)
return false;
if (params.strong_magnitude < 0.0 || params.strong_magnitude > 1.0)
return false;
if (params.weak_magnitude < 0.0 || params.weak_magnitude > 1.0)
return false;
if (params.left_trigger < 0.0 || params.left_trigger > 1.0)
return false;
if (params.right_trigger < 0.0 || params.right_trigger > 1.0)
return false;
return true;
}
VERIFY_NOT_REACHED();
}
// https://w3c.github.io/gamepad/#dom-gamepadhapticactuator-playeffect
GC::Ref<WebIDL::Promise> GamepadHapticActuator::play_effect(Bindings::GamepadHapticEffectType type, GamepadEffectParameters const& params)
{
auto& realm = this->realm();
// 1. If params does not describe a valid effect of type type, return a promise rejected with a TypeError.
if (!is_valid_effect(type, params))
return WebIDL::create_rejected_promise_from_exception(realm, WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, "Invalid effect"_string });
// 2. Let document be the current settings object's relevant global object's associated Document.
auto& window = as<HTML::Window>(HTML::current_principal_settings_object().global_object());
auto& document = window.associated_document();
// 3. If document is null or document is not fully active or document's visibility state is "hidden", return a
// promise rejected with an "InvalidStateError" DOMException.
if (!document.is_fully_active() || document.visibility_state_value() == HTML::VisibilityState::Hidden)
return WebIDL::create_rejected_promise_from_exception(realm, WebIDL::InvalidStateError::create(realm, "Haptics are not allowed in a hidden document"_utf16));
// 4. If this.[[playingEffectPromise]] is not null:
if (m_playing_effect_promise) {
// 1. Let effectPromise be this.[[playingEffectPromise]].
auto effect_promise = GC::Ref { *m_playing_effect_promise };
// 2. Set this.[[playingEffectPromise]] to null.
m_playing_effect_promise = nullptr;
clear_playing_effect_timers();
// 3. Queue a global task on the gamepad task source with the relevant global object of this to resolve
// effectPromise with "preempted".
HTML::queue_global_task(HTML::Task::Source::Gamepad, HTML::relevant_global_object(*this), GC::create_function(realm.heap(), [effect_promise, &realm] {
HTML::TemporaryExecutionContext execution_context { realm, HTML::TemporaryExecutionContext::CallbacksEnabled::Yes };
auto preempted_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Preempted));
WebIDL::resolve_promise(realm, effect_promise, preempted_string);
}));
}
// 5. If this GamepadHapticActuator cannot play effects with type type, return a promise rejected with reason
// NotSupportedError.
// https://w3c.github.io/gamepad/#ref-for-dfn-play-effects-with-type-1
// A GamepadHapticActuator can play effects with type type if type can be found in the [[effects]] list.
if (!m_effects.contains_slow(type))
return WebIDL::create_rejected_promise_from_exception(realm, WebIDL::NotSupportedError::create(realm, "Gamepad does not support this effect"_utf16));
// 6. Let [[playingEffectPromise]] be a new promise.
m_playing_effect_promise = WebIDL::create_promise(realm);
// 7. Let playEffectTimestamp be the current high resolution time given the document's relevant global object.
// NOTE: Unused.
// 8. Do the following steps in parallel:
Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [this, &realm, type, params] {
// 1. Issue a haptic effect to the actuator with type, params, and the playEffectTimestamp.
issue_haptic_effect(type, params, GC::create_function(realm.heap(), [this, &realm] {
// 2. When the effect completes, if this.[[playingEffectPromise]] is not null, queue a global task on the
// gamepad task source with the relevant global object of this to run the following steps:
if (m_playing_effect_promise) {
HTML::queue_global_task(HTML::Task::Source::Gamepad, HTML::relevant_global_object(*this), GC::create_function(realm.heap(), [this, &realm] {
// 1. If this.[[playingEffectPromise]] is null, abort these steps.
if (!m_playing_effect_promise)
return;
HTML::TemporaryExecutionContext execution_context { realm, HTML::TemporaryExecutionContext::CallbacksEnabled::Yes };
// 2. Resolve this.[[playingEffectPromise]] with "complete".
auto complete_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Complete));
WebIDL::resolve_promise(realm, *m_playing_effect_promise, complete_string);
// 3. Set this.[[playingEffectPromise]] to null.
m_playing_effect_promise = nullptr;
clear_playing_effect_timers();
}));
}
}));
}));
// 9. Return [[playingEffectPromise]].
VERIFY(m_playing_effect_promise);
return *m_playing_effect_promise;
}
// https://w3c.github.io/gamepad/#dom-gamepadhapticactuator-reset
GC::Ref<WebIDL::Promise> GamepadHapticActuator::reset()
{
auto& realm = this->realm();
// 1. Let document be the current settings object's relevant global object's associated Document.
auto& window = as<HTML::Window>(HTML::current_principal_settings_object().global_object());
auto& document = window.associated_document();
// 2. If document is null or document is not fully active or document's visibility state is "hidden", return a
// promise rejected with an "InvalidStateError" DOMException.
if (!document.is_fully_active() || document.visibility_state_value() == HTML::VisibilityState::Hidden)
return WebIDL::create_rejected_promise_from_exception(realm, WebIDL::InvalidStateError::create(realm, "Haptics are not allowed in a hidden document"_utf16));
// 3. Let resetResultPromise be a new promise.
auto reset_result_promise = WebIDL::create_promise(realm);
// 4. If this.[[playingEffectPromise]] is not null, do the following steps in parallel:
if (m_playing_effect_promise) {
Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [this, &realm, reset_result_promise] {
// 1. Let effectPromise be this.[[playingEffectPromise]].
auto effect_promise = m_playing_effect_promise;
// 2. Stop haptic effects on this's gamepad's actuator.
bool stopped_all = stop_haptic_effects();
// 3. If the effect has been successfully stopped, do:
if (stopped_all) {
// 1. If effectPromise and this.[[playingEffectPromise]] are still the same,
// set this.[[playingEffectPromise]] to null.
if (effect_promise == m_playing_effect_promise)
m_playing_effect_promise = nullptr;
// 2. Queue a global task on the gamepad task source with the relevant global object of this to resolve
// effectPromise with "preempted".
// AD-HOC: With doing this in parallel, there is a chance effect_promise is null. Don't try to resolve it
// if so.
if (effect_promise) {
HTML::queue_global_task(HTML::Task::Source::Gamepad, HTML::relevant_global_object(*this), GC::create_function(realm.heap(), [&realm, effect_promise = GC::Ref { *effect_promise }] {
HTML::TemporaryExecutionContext execution_context { realm, HTML::TemporaryExecutionContext::CallbacksEnabled::Yes };
auto preempted_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Preempted));
WebIDL::resolve_promise(realm, effect_promise, preempted_string);
}));
}
}
// 4. Resolve resetResultPromise with "complete"
auto complete_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Complete));
WebIDL::resolve_promise(realm, reset_result_promise, complete_string);
}));
}
// 5. Return resetResultPromise.
return reset_result_promise;
}
// https://w3c.github.io/gamepad/#handling-visibility-change
void GamepadHapticActuator::document_became_hidden()
{
auto& realm = this->realm();
// When the document's visibility state becomes "hidden", run these steps for each GamepadHapticActuator actuator:
// 1. If actuator.[[playingEffectPromise]] is null, abort these steps.
if (!m_playing_effect_promise)
return;
// 2. Queue a global task on the gamepad task source with the relevant global object of actuator to run the
// following steps:
auto& global = HTML::relevant_global_object(*this);
HTML::queue_global_task(HTML::Task::Source::Gamepad, global, GC::create_function(global.heap(), [this, &realm] {
// 1. If actuator.[[playingEffectPromise]] is null, abort these steps.
if (!m_playing_effect_promise)
return;
HTML::TemporaryExecutionContext execution_context { realm, HTML::TemporaryExecutionContext::CallbacksEnabled::Yes };
// 2. Resolve actuator.[[playingEffectPromise]] with "preempted".
auto preempted_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Preempted));
WebIDL::resolve_promise(realm, *m_playing_effect_promise, preempted_string);
// 3. Set actuator.[[playingEffectPromise]] to null.
m_playing_effect_promise = nullptr;
clear_playing_effect_timers();
}));
// 3. Stop haptic effects on actuator.
stop_haptic_effects();
}
// https://w3c.github.io/gamepad/#dfn-issue-a-haptic-effect
void GamepadHapticActuator::issue_haptic_effect(Bindings::GamepadHapticEffectType type, GamepadEffectParameters const& params, GC::Ref<GC::Function<void()>> on_complete)
{
auto& heap = this->heap();
// To issue a haptic effect on an actuator, the user agent MUST send a command to the device to render an effect
// of type and try to make it use the provided params. The user agent SHOULD use the provided playEffectTimestamp
// for more precise playback timing when params.startDelay is not 0.0. The user agent MAY modify the effect to
// increase compatibility. For example, an effect intended for a rumble motor may be transformed into a
// waveform-based effect for a device that supports waveform haptics but lacks rumble motors.
m_playing_effect_timer = Platform::Timer::create_single_shot(heap, static_cast<int>(params.start_delay), GC::create_function(heap, [this, type, params, on_complete, &heap] {
switch (type) {
case Bindings::GamepadHapticEffectType::DualRumble:
SDL_RumbleGamepad(m_gamepad->sdl_gamepad(), params.strong_magnitude * NumericLimits<u16>::max(), params.weak_magnitude * NumericLimits<u16>::max(), params.duration);
break;
case Bindings::GamepadHapticEffectType::TriggerRumble:
SDL_RumbleGamepadTriggers(m_gamepad->sdl_gamepad(), params.left_trigger * NumericLimits<u16>::max(), params.right_trigger * NumericLimits<u16>::max(), params.duration);
break;
}
m_playing_effect_timer = Platform::Timer::create_single_shot(heap, params.duration, GC::create_function(heap, [on_complete] {
on_complete->function()();
}));
m_playing_effect_timer->start();
}));
m_playing_effect_timer->start();
}
// https://w3c.github.io/gamepad/#dfn-stop-haptic-effects
bool GamepadHapticActuator::stop_haptic_effects()
{
// To stop haptic effects on an actuator, the user agent MUST send a command to the device to abort any effects
// currently being played. If a haptic effect was interrupted, the actuator SHOULD return to a motionless state
// as quickly as possible.
bool stopped_all = true;
// https://wiki.libsdl.org/SDL3/SDL_RumbleGamepad
// "Each call to this function cancels any previous rumble effect, and calling it with 0 intensity stops any
// rumbling."
if (m_effects.contains_slow(Bindings::GamepadHapticEffectType::DualRumble)) {
bool success = SDL_RumbleGamepad(m_gamepad->sdl_gamepad(), 0, 0, 0);
if (!success)
stopped_all = false;
}
// https://wiki.libsdl.org/SDL3/SDL_RumbleGamepadTriggers
// "Each call to this function cancels any previous trigger rumble effect, and calling it with 0 intensity stops
// any rumbling."
if (m_effects.contains_slow(Bindings::GamepadHapticEffectType::TriggerRumble)) {
bool success = SDL_RumbleGamepadTriggers(m_gamepad->sdl_gamepad(), 0, 0, 0);
if (!success)
stopped_all = false;
}
return stopped_all;
}
void GamepadHapticActuator::clear_playing_effect_timers()
{
if (m_playing_effect_timer) {
m_playing_effect_timer->stop();
m_playing_effect_timer = nullptr;
}
}
}