From bfa5ffe8a1eaed1f0e503bb2db65e2882b1ed9c4 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 21 Mar 2019 15:22:09 -0700 Subject: [PATCH] Add a "Play Sound" workboard trigger rule Summary: Ref T5474. Allow columns to play a sound when tasks are dropped. This is a little tricky because Safari has changed somewhat recently to require some gymnastics to play sounds when the user didn't explicitly click something. Preloading the sound on the first mouse interaction, then playing and immediately pausing it seems to work, though. Test Plan: Added a trigger with 5 sounds. In Safari, Chrome, and Firefox, dropped a card into the column. In all browsers, heard a nice sequence of 5 sounds played one after the other. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20306 --- resources/celerity/map.php | 64 ++++----- src/__phutil_library_map__.php | 2 + .../PhabricatorProjectBoardViewController.php | 6 + .../PhabricatorProjectController.php | 6 +- .../PhabricatorProjectMoveController.php | 11 +- .../engine/PhabricatorBoardResponseEngine.php | 11 ++ .../storage/PhabricatorProjectTrigger.php | 12 ++ ...PhabricatorProjectTriggerPlaySoundRule.php | 122 ++++++++++++++++++ .../trigger/PhabricatorProjectTriggerRule.php | 4 + webroot/rsrc/externals/javelin/lib/Sound.js | 50 ++++++- .../js/application/projects/WorkboardBoard.js | 5 + .../projects/behavior-project-boards.js | 12 ++ 12 files changed, 267 insertions(+), 38 deletions(-) create mode 100644 src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 62cdf2f1e3..5dd506784a 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -10,7 +10,7 @@ return array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', 'core.pkg.css' => 'b797945d', - 'core.pkg.js' => 'eaca003c', + 'core.pkg.js' => 'eb53fc5b', 'differential.pkg.css' => '8d8360fb', 'differential.pkg.js' => '67e02996', 'diffusion.pkg.css' => '42c75c37', @@ -249,7 +249,7 @@ return array( 'rsrc/externals/javelin/lib/Routable.js' => '6a18c42e', 'rsrc/externals/javelin/lib/Router.js' => '32755edb', 'rsrc/externals/javelin/lib/Scrollbar.js' => 'a43ae2ae', - 'rsrc/externals/javelin/lib/Sound.js' => 'e562708c', + 'rsrc/externals/javelin/lib/Sound.js' => 'd4cc2d2a', 'rsrc/externals/javelin/lib/URI.js' => '2e255291', 'rsrc/externals/javelin/lib/Vector.js' => 'e9c80beb', 'rsrc/externals/javelin/lib/WebSocket.js' => 'fdc13e4e', @@ -409,7 +409,7 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '65afb173', + 'rsrc/js/application/projects/WorkboardBoard.js' => '3ba8e6ad', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', @@ -418,7 +418,7 @@ return array( 'rsrc/js/application/projects/WorkboardHeader.js' => '111bfd2d', 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'ebe83a6b', 'rsrc/js/application/projects/WorkboardOrderTemplate.js' => '03e8891f', - 'rsrc/js/application/projects/behavior-project-boards.js' => '8512e4ea', + 'rsrc/js/application/projects/behavior-project-boards.js' => 'aad45445', 'rsrc/js/application/projects/behavior-project-create.js' => '34c53422', 'rsrc/js/application/projects/behavior-reorder-columns.js' => '8ac32fd9', 'rsrc/js/application/releeph/releeph-preview-branch.js' => '75184d68', @@ -664,7 +664,7 @@ return array( 'javelin-behavior-phuix-example' => 'c2c500a7', 'javelin-behavior-policy-control' => '0eaa33a9', 'javelin-behavior-policy-rule-editor' => '9347f172', - 'javelin-behavior-project-boards' => '8512e4ea', + 'javelin-behavior-project-boards' => 'aad45445', 'javelin-behavior-project-create' => '34c53422', 'javelin-behavior-quicksand-blacklist' => '5a6f6a06', 'javelin-behavior-read-only-warning' => 'b9109f8f', @@ -718,7 +718,7 @@ return array( 'javelin-routable' => '6a18c42e', 'javelin-router' => '32755edb', 'javelin-scrollbar' => 'a43ae2ae', - 'javelin-sound' => 'e562708c', + 'javelin-sound' => 'd4cc2d2a', 'javelin-stratcom' => '0889b835', 'javelin-tokenizer' => '89a1ae3a', 'javelin-typeahead' => 'a4356cde', @@ -737,7 +737,7 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '65afb173', + 'javelin-workboard-board' => '3ba8e6ad', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', 'javelin-workboard-column' => 'c3d24e63', @@ -1227,6 +1227,18 @@ return array( 'javelin-behavior', 'phabricator-prefab', ), + '3ba8e6ad' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', + ), '3dc5ad43' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1456,18 +1468,6 @@ return array( '60cd9241' => array( 'javelin-behavior', ), - '65afb173' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - 'javelin-workboard-header-template', - 'javelin-workboard-card-template', - 'javelin-workboard-order-template', - ), '65bb0011' => array( 'javelin-behavior', 'javelin-dom', @@ -1594,16 +1594,6 @@ return array( 'javelin-dom', 'javelin-vector', ), - '8512e4ea' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-util', - 'javelin-vector', - 'javelin-stratcom', - 'javelin-workflow', - 'javelin-workboard-controller', - 'javelin-workboard-drop-effect', - ), '87428eb2' => array( 'javelin-behavior', 'javelin-diffusion-locate-file-source', @@ -1848,6 +1838,16 @@ return array( 'javelin-dom', 'javelin-util', ), + 'aad45445' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-util', + 'javelin-vector', + 'javelin-stratcom', + 'javelin-workflow', + 'javelin-workboard-controller', + 'javelin-workboard-drop-effect', + ), 'ab85e184' => array( 'javelin-install', 'javelin-dom', @@ -2041,6 +2041,9 @@ return array( 'd3799cb4' => array( 'javelin-install', ), + 'd4cc2d2a' => array( + 'javelin-install', + ), 'd8a86cfb' => array( 'javelin-behavior', 'javelin-dom', @@ -2075,9 +2078,6 @@ return array( 'javelin-dom', 'javelin-history', ), - 'e562708c' => array( - 'javelin-install', - ), 'e5bdb730' => array( 'javelin-behavior', 'javelin-stratcom', diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index a4edf3d246..f56c616545 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4183,6 +4183,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerManiphestStatusRule' => 'applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php', 'PhabricatorProjectTriggerNameTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php', 'PhabricatorProjectTriggerPHIDType' => 'applications/project/phid/PhabricatorProjectTriggerPHIDType.php', + 'PhabricatorProjectTriggerPlaySoundRule' => 'applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php', 'PhabricatorProjectTriggerQuery' => 'applications/project/query/PhabricatorProjectTriggerQuery.php', 'PhabricatorProjectTriggerRule' => 'applications/project/trigger/PhabricatorProjectTriggerRule.php', 'PhabricatorProjectTriggerRuleRecord' => 'applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php', @@ -10317,6 +10318,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerManiphestStatusRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerNameTransaction' => 'PhabricatorProjectTriggerTransactionType', 'PhabricatorProjectTriggerPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorProjectTriggerPlaySoundRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorProjectTriggerRule' => 'Phobject', 'PhabricatorProjectTriggerRuleRecord' => 'Phobject', diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 4e4ff81b4e..e8a47d362a 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -542,6 +542,7 @@ final class PhabricatorProjectBoardViewController $templates = array(); $all_tasks = array(); $column_templates = array(); + $sounds = array(); foreach ($visible_columns as $column_phid => $column) { $column_tasks = $column_phids[$column_phid]; @@ -629,6 +630,10 @@ final class PhabricatorProjectBoardViewController if ($trigger) { $preview_effect = $trigger->getPreviewEffect() ->toDictionary(); + + foreach ($trigger->getSoundEffects() as $sound) { + $sounds[] = $sound; + } } } @@ -685,6 +690,7 @@ final class PhabricatorProjectBoardViewController 'boardID' => $board_id, 'projectPHID' => $project->getPHID(), + 'preloadSounds' => $sounds, ); $this->initBehavior('project-boards', $behavior_config); diff --git a/src/applications/project/controller/PhabricatorProjectController.php b/src/applications/project/controller/PhabricatorProjectController.php index 850dfa2268..c28ace305c 100644 --- a/src/applications/project/controller/PhabricatorProjectController.php +++ b/src/applications/project/controller/PhabricatorProjectController.php @@ -152,7 +152,8 @@ abstract class PhabricatorProjectController extends PhabricatorController { protected function newCardResponse( $board_phid, $object_phid, - PhabricatorProjectColumnOrder $ordering = null) { + PhabricatorProjectColumnOrder $ordering = null, + $sounds = array()) { $viewer = $this->getViewer(); @@ -166,7 +167,8 @@ abstract class PhabricatorProjectController extends PhabricatorController { ->setViewer($viewer) ->setBoardPHID($board_phid) ->setObjectPHID($object_phid) - ->setVisiblePHIDs($visible_phids); + ->setVisiblePHIDs($visible_phids) + ->setSounds($sounds); if ($ordering) { $engine->setOrdering($ordering); diff --git a/src/applications/project/controller/PhabricatorProjectMoveController.php b/src/applications/project/controller/PhabricatorProjectMoveController.php index 71588754c0..950b1e90cd 100644 --- a/src/applications/project/controller/PhabricatorProjectMoveController.php +++ b/src/applications/project/controller/PhabricatorProjectMoveController.php @@ -111,6 +111,7 @@ final class PhabricatorProjectMoveController $xactions[] = $header_xaction; } + $sounds = array(); if ($column->canHaveTrigger()) { $trigger = $column->getTrigger(); if ($trigger) { @@ -121,6 +122,10 @@ final class PhabricatorProjectMoveController foreach ($trigger_xactions as $trigger_xaction) { $xactions[] = $trigger_xaction; } + + foreach ($trigger->getSoundEffects() as $effect) { + $sounds[] = $effect; + } } } @@ -133,7 +138,11 @@ final class PhabricatorProjectMoveController $editor->applyTransactions($object, $xactions); - return $this->newCardResponse($board_phid, $object_phid, $ordering); + return $this->newCardResponse( + $board_phid, + $object_phid, + $ordering, + $sounds); } } diff --git a/src/applications/project/engine/PhabricatorBoardResponseEngine.php b/src/applications/project/engine/PhabricatorBoardResponseEngine.php index fb5299a857..f22254e43a 100644 --- a/src/applications/project/engine/PhabricatorBoardResponseEngine.php +++ b/src/applications/project/engine/PhabricatorBoardResponseEngine.php @@ -7,6 +7,7 @@ final class PhabricatorBoardResponseEngine extends Phobject { private $objectPHID; private $visiblePHIDs; private $ordering; + private $sounds; public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; @@ -53,6 +54,15 @@ final class PhabricatorBoardResponseEngine extends Phobject { return $this->ordering; } + public function setSounds(array $sounds) { + $this->sounds = $sounds; + return $this; + } + + public function getSounds() { + return $this->sounds; + } + public function buildResponse() { $viewer = $this->getViewer(); $object_phid = $this->getObjectPHID(); @@ -150,6 +160,7 @@ final class PhabricatorBoardResponseEngine extends Phobject { 'columnMaps' => $natural, 'cards' => $cards, 'headers' => $headers, + 'sounds' => $this->getSounds(), ); return id(new AphrontAjaxResponse()) diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php index a499f0bcf8..bac3927b74 100644 --- a/src/applications/project/storage/PhabricatorProjectTrigger.php +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -245,6 +245,18 @@ final class PhabricatorProjectTrigger ->setContent($header); } + public function getSoundEffects() { + $sounds = array(); + + foreach ($this->getTriggerRules() as $rule) { + foreach ($rule->getSoundEffects() as $effect) { + $sounds[] = $effect; + } + } + + return $sounds; + } + /* -( PhabricatorApplicationTransactionInterface )------------------------- */ diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php new file mode 100644 index 0000000000..ef19b504ef --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php @@ -0,0 +1,122 @@ +newEffect() + ->setIcon($sound_icon) + ->setColor($sound_color) + ->setContent($content), + ); + } + + protected function getDefaultValue() { + return head_key(self::getSoundMap()); + } + + protected function getPHUIXControlType() { + return 'select'; + } + + protected function getPHUIXControlSpecification() { + $map = self::getSoundMap(); + $map = ipull($map, 'name'); + + return array( + 'options' => $map, + 'order' => array_keys($map), + ); + } + + public function getRuleViewLabel() { + return pht('Play Sound'); + } + + public function getRuleViewDescription($value) { + $sound_name = self::getSoundName($value); + + return pht( + 'Play sound %s.', + phutil_tag('strong', array(), $sound_name)); + } + + public function getRuleViewIcon($value) { + $sound_icon = 'fa-volume-up'; + $sound_color = 'blue'; + + return id(new PHUIIconView()) + ->setIcon($sound_icon, $sound_color); + } + + private static function getSoundName($value) { + $map = self::getSoundMap(); + $spec = idx($map, $value, array()); + return idx($spec, 'name', $value); + } + + private static function getSoundMap() { + return array( + 'bing' => array( + 'name' => pht('Bing'), + 'uri' => celerity_get_resource_uri('/rsrc/audio/basic/bing.mp3'), + ), + 'glass' => array( + 'name' => pht('Glass'), + 'uri' => celerity_get_resource_uri('/rsrc/audio/basic/ting.mp3'), + ), + ); + } + + public function getSoundEffects() { + $value = $this->getValue(); + + $map = self::getSoundMap(); + $spec = idx($map, $value, array()); + + $uris = array(); + if (isset($spec['uri'])) { + $uris[] = $spec['uri']; + } + + return $uris; + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php index c75c15a1ab..ae2b3ee092 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php @@ -60,6 +60,10 @@ abstract class PhabricatorProjectTriggerRule return null; } + public function getSoundEffects() { + return array(); + } + final public function getDropTransactions($object, $value) { return $this->newDropTransactions($object, $value); } diff --git a/webroot/rsrc/externals/javelin/lib/Sound.js b/webroot/rsrc/externals/javelin/lib/Sound.js index accbe3d29b..68181560ff 100644 --- a/webroot/rsrc/externals/javelin/lib/Sound.js +++ b/webroot/rsrc/externals/javelin/lib/Sound.js @@ -8,31 +8,75 @@ JX.install('Sound', { statics: { _sounds: {}, + _queue: [], + _playingQueue: false, load: function(uri) { var self = JX.Sound; if (!(uri in self._sounds)) { - self._sounds[uri] = JX.$N( + var audio = JX.$N( 'audio', { src: uri, preload: 'auto' }); + + // In Safari, it isn't good enough to just load a sound in response + // to a click: we must also play it. Once we've played it once, we + // can continue to play it freely. + + // Play the sound, then immediately pause it. This rejects the "play()" + // promise but marks the audio as playable, so our "play()" method will + // work correctly later. + if (window.webkitAudioContext) { + audio.play().then(JX.bag, JX.bag); + audio.pause(); + } + + self._sounds[uri] = audio; } }, - play: function(uri) { + play: function(uri, callback) { var self = JX.Sound; self.load(uri); var sound = self._sounds[uri]; try { - sound.play(); + sound.onended = callback || JX.bag; + sound.play().then(JX.bag, callback || JX.bag); } catch (ex) { JX.log(ex); } + }, + + queue: function(uri) { + var self = JX.Sound; + self._queue.push(uri); + self._playQueue(); + }, + + _playQueue: function() { + var self = JX.Sound; + if (self._playingQueue) { + return; + } + self._playingQueue = true; + self._nextQueue(); + }, + + _nextQueue: function() { + var self = JX.Sound; + if (self._queue.length) { + var next = self._queue[0]; + self._queue.splice(0, 1); + self.play(next, self._nextQueue); + } else { + self._playingQueue = false; + } } + } }); diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index a7786a86f4..6add658259 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -529,6 +529,11 @@ JX.install('WorkboardBoard', { this.updateCard(response); + var sounds = response.sounds || []; + for (var ii = 0; ii < sounds.length; ii++) { + JX.Sound.queue(sounds[ii]); + } + list.unlock(); }, diff --git a/webroot/rsrc/js/application/projects/behavior-project-boards.js b/webroot/rsrc/js/application/projects/behavior-project-boards.js index daec59155f..bba6db7a49 100644 --- a/webroot/rsrc/js/application/projects/behavior-project-boards.js +++ b/webroot/rsrc/js/application/projects/behavior-project-boards.js @@ -166,4 +166,16 @@ JX.behavior('project-boards', function(config, statics) { board.start(); + // In Safari, we can only play sounds that we've already loaded, and we can + // only load them in response to an explicit user interaction like a click. + var sounds = config.preloadSounds; + var listener = JX.Stratcom.listen('mousedown', null, function() { + for (var ii = 0; ii < sounds.length; ii++) { + JX.Sound.load(sounds[ii]); + } + + // Remove this callback once it has run once. + listener.remove(); + }); + });