From e5913cc3eae71c9eb52c6bf02235e7cdb9f85362 Mon Sep 17 00:00:00 2001 From: Brian Sztamfater Date: Mon, 13 Mar 2023 14:08:03 -0300 Subject: [PATCH] feat: audio component Signed-off-by: Brian Sztamfater --- .../images/icons2/20x20/pause-audio@2x.png | Bin 0 -> 271 bytes .../images/icons2/20x20/pause-audio@3x.png | Bin 0 -> 367 bytes .../images/icons2/20x20/play-audio@2x.png | Bin 0 -> 363 bytes .../images/icons2/20x20/play-audio@3x.png | Bin 0 -> 524 bytes .../record_audio/record_audio/view.cljs | 2 +- .../__tests__/soundtrack_component_spec.cljs | 8 +- .../record_audio/soundtrack/view.cljs | 10 +- src/quo2/core.cljs | 2 + .../content/audio/component_spec.cljs | 52 +++++ .../chat/messages/content/audio/style.cljs | 41 ++++ .../chat/messages/content/audio/view.cljs | 204 ++++++++++++++++++ .../contexts/chat/messages/content/view.cljs | 3 +- .../chat/messages/pin/banner/view.cljs | 21 +- src/status_im2/core_spec.cljs | 3 +- src/test_helpers/component.cljs | 5 + test/jest/jestSetup.js | 8 + translations/en.json | 3 +- 17 files changed, 344 insertions(+), 18 deletions(-) create mode 100644 resources/images/icons2/20x20/pause-audio@2x.png create mode 100644 resources/images/icons2/20x20/pause-audio@3x.png create mode 100644 resources/images/icons2/20x20/play-audio@2x.png create mode 100644 resources/images/icons2/20x20/play-audio@3x.png create mode 100644 src/status_im2/contexts/chat/messages/content/audio/component_spec.cljs create mode 100644 src/status_im2/contexts/chat/messages/content/audio/style.cljs create mode 100644 src/status_im2/contexts/chat/messages/content/audio/view.cljs diff --git a/resources/images/icons2/20x20/pause-audio@2x.png b/resources/images/icons2/20x20/pause-audio@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b73e28e5a80afad35cbd28b46da6823eae70ad14 GIT binary patch literal 271 zcmeAS@N?(olHy`uVBq!ia0vp^8X(NU1|)m_?Z^dEoCO|{#S9E$svykh8Km+7D9BhG z^dmJnR%*!W_K;h+0Y( z$!2IyOJGl8J;-6pc{g6%}t*V&O6jHe}Xg9jG2+)E^j`c`S@VLO&LW*105oX6Jp!0q9`@67G@j(%f%%)!TD{M$Kz#xyvucxAfIJ*K(t`tj0k?T)~=|E?Ww z*>P`{{?)kXm+?YB)|G7!`=vg2sfF#uAH`y`SH9lm?U(rW-$~8w0n>jc+4>Y+>)9Q) z`@fcTfyk1W8!OX3$9X@Pw0u98r0Le*=li(sIQ^fgx`F#n;(LxavWpO5y z@P;*0wR^&Qokn*}^LHVeEA{^us7}5z_3r-_;a1Nb7Ej66`>-SU?6bW*m;Y6C-n|?U zy?cMJ-sQ^I2duVS+TWoU{L5s=yLy?F3r{z*elFQpRuA+FL#kaykxuCMHyuVGo~Nsy J%Q~loCIER9pCteQ literal 0 HcmV?d00001 diff --git a/resources/images/icons2/20x20/play-audio@2x.png b/resources/images/icons2/20x20/play-audio@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..38f9803d4a57ecb6d1fb92d337db54e60e619198 GIT binary patch literal 363 zcmV-x0hIoUP)>%3F{*}6@MS}888@Mx5p`euTe*W3C zNxF&d0|^ngo2Cm0*TSQl6Y`T&xSXrukaL&8z5&OQbD2ecZ5t>R@NN&w;x6%upVdPb zr8{Wl8}Lfw+YVin4!F&->^L-FN;1rYu597FjkHiVi1ph;_^+;_*znzE;fz$9S#;Zc zSLR_#N(q0kVcl4QHIO`;;PatLHrOK$vg<>c`B<9Jq?A%BiW`e64nXeW5=;O9002ov JPDHLkV1gv*mOuai literal 0 HcmV?d00001 diff --git a/resources/images/icons2/20x20/play-audio@3x.png b/resources/images/icons2/20x20/play-audio@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..417b576aad3d76a1c680a765bb7371e449bba55d GIT binary patch literal 524 zcmV+n0`vWeP)Z zgFp-hU{iH~j&LKi8)%JCyDqyQ9oWsjFe8r!`mQbJrUbmg#eJ7`?R0pTUKjS== z%HXu<9mXlC9zY9P>syxdw4+8E$ zhJ$n*RYN;<2Lhx5r=G6-`+Ew#$H+;pCGdc|+{6qtZGoAiswwm%%petbX%1$F)-bfj z@V_cJwAKy}IP@`OkZBLh991!O<1GYc-%~f-LSRo$cw+vmU&y8iji8L!SHNvp`L61&uMr7-Nhv7ySUa)`r&{e)-4% O0000 soundtrack/f-soundtrack {:audio-current-time-ms audio-current-time-ms - :player-ref player-ref + :player-ref @player-ref :seeking-audio? seeking-audio?}]]) (when (or @recording? @reviewing-audio?) [:f> f-time-counter @recording? @recording-length-ms @ready-to-delete? @reviewing-audio? diff --git a/src/quo2/components/record_audio/soundtrack/__tests__/soundtrack_component_spec.cljs b/src/quo2/components/record_audio/soundtrack/__tests__/soundtrack_component_spec.cljs index d5c2c42caa..b3054d6355 100644 --- a/src/quo2/components/record_audio/soundtrack/__tests__/soundtrack_component_spec.cljs +++ b/src/quo2/components/record_audio/soundtrack/__tests__/soundtrack_component_spec.cljs @@ -19,7 +19,7 @@ (let [player-ref (reagent/atom {}) audio-current-time-ms (reagent/atom 0)] (h/render [:f> soundtrack/f-soundtrack - {:player-ref player-ref + {:player-ref @player-ref :audio-current-time-ms audio-current-time-ms}]) (-> (h/expect (h/get-by-test-id "soundtrack")) (.toBeTruthy))))) @@ -31,7 +31,7 @@ audio-current-time-ms (reagent/atom 0)] (h/render [:f> soundtrack/f-soundtrack {:seeking-audio? seeking-audio? - :player-ref player-ref + :player-ref @player-ref :audio-current-time-ms audio-current-time-ms}]) (h/fire-event :on-sliding-start @@ -47,7 +47,7 @@ audio-current-time-ms (reagent/atom 0)] (h/render [:f> soundtrack/f-soundtrack {:seeking-audio? seeking-audio? - :player-ref player-ref + :player-ref @player-ref :audio-current-time-ms audio-current-time-ms}]) (h/fire-event :on-sliding-start @@ -69,7 +69,7 @@ audio-current-time-ms (reagent/atom 0)] (h/render [:f> soundtrack/f-soundtrack {:seeking-audio? seeking-audio? - :player-ref player-ref + :player-ref @player-ref :audio-current-time-ms audio-current-time-ms}]) (h/fire-event :on-sliding-start diff --git a/src/quo2/components/record_audio/soundtrack/view.cljs b/src/quo2/components/record_audio/soundtrack/view.cljs index 62889c63ce..6e6c7279f3 100644 --- a/src/quo2/components/record_audio/soundtrack/view.cljs +++ b/src/quo2/components/record_audio/soundtrack/view.cljs @@ -10,12 +10,14 @@ (def ^:private thumb-dark (js/require "../resources/images/icons2/12x12/thumb-dark.png")) (defn f-soundtrack - [{:keys [audio-current-time-ms player-ref seeking-audio?]}] - (let [audio-duration-ms (audio/get-player-duration @player-ref)] + [{:keys [audio-current-time-ms player-ref style seeking-audio?]}] + (let [audio-duration-ms (audio/get-player-duration player-ref)] [:<> [slider/slider {:test-ID "soundtrack" - :style (style/player-slider-container) + :style (merge + (style/player-slider-container) + (or style {})) :minimum-value 0 :maximum-value audio-duration-ms :value @audio-current-time-ms @@ -23,7 +25,7 @@ :on-sliding-complete (fn [seek-time] (reset! seeking-audio? false) (audio/seek-player - @player-ref + player-ref seek-time #(log/debug "[record-audio] on seek - seek time: " seek-time) #(log/error "[record-audio] on seek - error: " %))) diff --git a/src/quo2/core.cljs b/src/quo2/core.cljs index 3308369b91..10eb110612 100644 --- a/src/quo2/core.cljs +++ b/src/quo2/core.cljs @@ -63,6 +63,7 @@ quo2.components.profile.select-profile.view quo2.components.reactions.reaction quo2.components.record-audio.record-audio.view + quo2.components.record-audio.soundtrack.view quo2.components.selectors.disclaimer.view quo2.components.selectors.filter.view quo2.components.selectors.selectors.view @@ -186,6 +187,7 @@ ;;;; RECORD AUDIO (def record-audio quo2.components.record-audio.record-audio.view/record-audio) +(def soundtrack quo2.components.record-audio.soundtrack.view/f-soundtrack) ;;;; SETTINGS (def privacy-option quo2.components.settings.privacy-option/card) diff --git a/src/status_im2/contexts/chat/messages/content/audio/component_spec.cljs b/src/status_im2/contexts/chat/messages/content/audio/component_spec.cljs new file mode 100644 index 0000000000..7822c50ec2 --- /dev/null +++ b/src/status_im2/contexts/chat/messages/content/audio/component_spec.cljs @@ -0,0 +1,52 @@ +(ns status-im2.contexts.chat.messages.content.audio.component-spec + (:require [status-im2.contexts.chat.messages.content.audio.view :as audio-message] + [test-helpers.component :as h] + [react-native.audio-toolkit :as audio] + [re-frame.core :as re-frame])) + +(def message + {:audio-duration-ms 5000 + :message-id "message-id"}) + +(def context + {:in-pinned-view? false}) + +(defn setup-subs + [subs] + (doseq [keyval subs] + (re-frame/reg-sub + (key keyval) + (fn [_] (val keyval))))) + +(h/describe "audio message" + (h/before-each + #(setup-subs {:mediaserver/port 1000})) + + (h/test "renders correctly" + (h/render [audio-message/audio-message message context]) + (h/is-truthy (h/get-by-label-text :audio-message-container))) + + (h/test "press play calls audio/toggle-playpause-player" + (with-redefs [audio/toggle-playpause-player (js/jest.fn) + audio/new-player (fn [_ _ _] {}) + audio/destroy-player #() + audio/prepare-player (fn [_ on-success _] (on-success)) + audio-message/download-audio-http (fn [_ on-success] (on-success "audio-uri"))] + (h/render [audio-message/audio-message message context]) + (h/fire-event + :on-press + (h/get-by-label-text :play-pause-audio-message-button)) + (-> (h/expect audio/toggle-playpause-player) + (.toHaveBeenCalledTimes 1)))) + + (h/test "press play renders error" + (h/render [audio-message/audio-message message context]) + (with-redefs [audio/toggle-playpause-player (fn [_ _ _ on-error] (on-error)) + audio/new-player (fn [_ _ _] {}) + audio/destroy-player #() + audio/prepare-player (fn [_ on-success _] (on-success)) + audio-message/download-audio-http (fn [_ on-success] (on-success "audio-uri"))] + (h/fire-event + :on-press + (h/get-by-label-text :play-pause-audio-message-button)) + (h/wait-for #(h/is-truthy (h/get-by-label-text :audio-error-label)))))) diff --git a/src/status_im2/contexts/chat/messages/content/audio/style.cljs b/src/status_im2/contexts/chat/messages/content/audio/style.cljs new file mode 100644 index 0000000000..a7d3fd66e3 --- /dev/null +++ b/src/status_im2/contexts/chat/messages/content/audio/style.cljs @@ -0,0 +1,41 @@ +(ns status-im2.contexts.chat.messages.content.audio.style + (:require [quo2.foundations.colors :as colors] + [quo2.theme :as theme])) + +(defn container + [] + {:width 295 + :height 56 + :border-radius 12 + :border-width 1 + :padding 12 + :flex-direction :row + :align-items :center + :justify-content :space-between + :border-color (colors/theme-colors colors/neutral-20 colors/neutral-80) + :background-color (colors/theme-colors colors/neutral-5 colors/neutral-80-opa-40)}) + +(def play-pause-slider-container + {:flex-direction :row + :align-items :center}) + +(def slider-container + {:position :absolute + :left 60 + :right 71 + :bottom nil}) + +(defn play-pause-container + [] + {:background-color (get-in colors/customization [:blue (if (theme/dark?) 60 50)]) + :width 32 + :height 32 + :border-radius 16 + :align-items :center + :justify-content :center}) + +(def timestamp + {:margin-left 4}) + +(def error-label + {:margin-bottom 16}) diff --git a/src/status_im2/contexts/chat/messages/content/audio/view.cljs b/src/status_im2/contexts/chat/messages/content/audio/view.cljs new file mode 100644 index 0000000000..5196400e33 --- /dev/null +++ b/src/status_im2/contexts/chat/messages/content/audio/view.cljs @@ -0,0 +1,204 @@ +(ns status-im2.contexts.chat.messages.content.audio.view + (:require ["react-native-blob-util" :default ReactNativeBlobUtil] + [goog.string :as gstring] + [reagent.core :as reagent] + [react-native.audio-toolkit :as audio] + [status-im2.contexts.chat.messages.content.audio.style :as style] + [react-native.platform :as platform] + [taoensso.timbre :as log] + [quo2.foundations.colors :as colors] + [quo2.core :as quo] + [react-native.core :as rn] + [utils.re-frame :as rf] + [utils.i18n :as i18n])) + +(def ^:const media-server-uri-prefix "https://localhost:") +(def ^:const audio-path "/messages/audio") +(def ^:const uri-param "?messageId=") + +(defonce active-players (atom {})) +(defonce audio-uris (atom {})) +(defonce progress-timer (atom nil)) +(defonce current-player-key (reagent/atom nil)) + +(defn get-player-key + [message-id in-pinned-view?] + (str in-pinned-view? message-id)) + +(defn destroy-player + [player-key] + (when-let [player (@active-players player-key)] + (audio/destroy-player player) + (swap! active-players dissoc player-key))) + +(defn update-state + [state new-state] + (when-not (= @state new-state) + (reset! state new-state))) + +(defn seek-player + [player-key player-state value on-success] + (when-let [player (@active-players player-key)] + (audio/seek-player + player + value + #(when on-success (on-success)) + #(update-state player-state :error)) + (update-state player-state :seeking))) + +(defn download-audio-http + [base64-uri on-success] + (-> (.config ReactNativeBlobUtil (clj->js {:trusty platform/ios?})) + (.fetch "GET" (str base64-uri)) + (.then #(on-success (.base64 ^js %))) + (.catch #(log/error "could not fetch audio " base64-uri)))) + +(defn create-player + [{:keys [progress player-state player-key]} audio-url on-success] + (download-audio-http + audio-url + (fn [base64-data] + (let [player (audio/new-player + (str "data:audio/acc;base64," base64-data) + {:autoDestroy false + :continuesToPlayInBackground false} + (fn [] + (update-state player-state :ready-to-play) + (reset! progress 0) + (when (and @progress-timer (= @current-player-key player-key)) + (js/clearInterval @progress-timer) + (reset! progress-timer nil))))] + (swap! active-players assoc player-key player) + (audio/prepare-player + player + #(when on-success (on-success)) + #(update-state player-state :error))))) + (update-state player-state :preparing)) + +(defn play-pause-player + [{:keys [player-key player-state progress message-id audio-duration-ms seeking-audio? + user-interaction?] + :as params}] + (let [mediaserver-port (rf/sub [:mediaserver/port]) + audio-uri (str media-server-uri-prefix + mediaserver-port + audio-path + uri-param + message-id) + player (@active-players player-key) + playing? (= @player-state :playing)] + (when-not playing? + (reset! current-player-key player-key)) + (if (and player + (= (@audio-uris player-key) audio-uri)) + (audio/toggle-playpause-player + player + (fn [] + (update-state player-state :playing) + (when @progress-timer + (js/clearInterval @progress-timer)) + (reset! progress-timer + (js/setInterval + (fn [] + (let [player (@active-players player-key) + current-time (audio/get-player-current-time player) + playing? (= @player-state :playing)] + (when (and playing? (not @seeking-audio?) (> current-time 0)) + (reset! progress current-time)))) + 100))) + (fn [] + (update-state player-state :ready-to-play) + (when (and @progress-timer user-interaction?) + (js/clearInterval @progress-timer) + (reset! progress-timer nil))) + #(update-state player-state :error)) + (do + (swap! audio-uris assoc player-key audio-uri) + (destroy-player player-key) + (create-player params + audio-uri + (fn [] + (reset! seeking-audio? false) + (if (> @progress 0) + (let [seek-time (* audio-duration-ms @progress) + checked-seek-time (min audio-duration-ms seek-time)] + (seek-player + player-key + player-state + checked-seek-time + #(play-pause-player params))) + (play-pause-player params)))))))) + +(defn f-audio-message + [player-state progress seeking-audio? {:keys [audio-duration-ms message-id]} + {:keys [in-pinned-view?]}] + (let [player-key (get-player-key message-id in-pinned-view?) + player (@active-players player-key) + duration (if (and player (not (#{:preparing :not-loaded :error} @player-state))) + (audio/get-player-duration player) + audio-duration-ms) + time-secs (quot + (if (or @seeking-audio? (#{:playing :seeking} @player-state)) + (if (<= @progress 1) (* duration @progress) @progress) + duration) + 1000)] + (rn/use-effect (fn [] #(destroy-player player-key))) + (rn/use-effect + (fn [] + (when (and (some? @current-player-key) + (not= @current-player-key player-key) + (= @player-state :playing)) + (play-pause-player {:player-key player-key + :player-state player-state + :progress progress + :message-id message-id + :audio-duration-ms duration + :seeking-audio? seeking-audio? + :user-interaction? false}))) + [@current-player-key]) + (if (= @player-state :error) + [quo/text + {:style style/error-label + :accessibility-label :audio-error-label + :weight :medium + :size :paragraph-2} + (i18n/label :error-loading-audio)] + [rn/view + {:accessibility-label :audio-message-container + :style (style/container)} + [rn/touchable-opacity + {:accessibility-label :play-pause-audio-message-button + :on-press #(play-pause-player {:player-key player-key + :player-state player-state + :progress progress + :message-id message-id + :audio-duration-ms duration + :seeking-audio? seeking-audio? + :user-interaction? true}) + :style (style/play-pause-container)} + [quo/icon + (case @player-state + :preparing :i/loading + :playing :i/pause-audio + :i/play-audio) + {:size 20 + :color colors/white}]] + [:f> quo/soundtrack + {:style style/slider-container + :audio-current-time-ms progress + :player-ref (@active-players player-key) + :seeking-audio? seeking-audio?}] + [quo/text + {:style style/timestamp + :accessibility-label :audio-duration-label + :weight :medium + :size :paragraph-2} + (gstring/format "%02d:%02d" (quot time-secs 60) (mod time-secs 60))]]))) + +(defn audio-message + [message context] + (let [player-state (reagent/atom :not-loaded) + progress (reagent/atom 0) + seeking-audio? (reagent/atom false)] + (fn [] + [:f> f-audio-message player-state progress seeking-audio? message context]))) diff --git a/src/status_im2/contexts/chat/messages/content/view.cljs b/src/status_im2/contexts/chat/messages/content/view.cljs index ff1ab9720a..2b91f135b6 100644 --- a/src/status_im2/contexts/chat/messages/content/view.cljs +++ b/src/status_im2/contexts/chat/messages/content/view.cljs @@ -14,6 +14,7 @@ [status-im2.contexts.chat.messages.content.album.view :as album] [status-im2.contexts.chat.messages.avatar.view :as avatar] [status-im2.contexts.chat.messages.content.image.view :as image] + [status-im2.contexts.chat.messages.content.audio.view :as audio] [quo2.core :as quo] [utils.re-frame :as rf] [status-im.ui2.screens.chat.messages.message :as old-message] @@ -128,7 +129,7 @@ [not-implemented/not-implemented [old-message/sticker message-data]] constants/content-type-audio - [not-implemented/not-implemented [old-message/audio message-data]] + [audio/audio-message message-data context] constants/content-type-image [image/image-message 0 message-data context on-long-press] diff --git a/src/status_im2/contexts/chat/messages/pin/banner/view.cljs b/src/status_im2/contexts/chat/messages/pin/banner/view.cljs index 4520ed2e67..834246e03b 100644 --- a/src/status_im2/contexts/chat/messages/pin/banner/view.cljs +++ b/src/status_im2/contexts/chat/messages/pin/banner/view.cljs @@ -2,19 +2,28 @@ (:require [quo2.core :as quo] [utils.i18n :as i18n] [utils.re-frame :as rf] - [status-im2.contexts.chat.messages.resolver.message-resolver :as resolver])) + [status-im2.contexts.chat.messages.resolver.message-resolver :as resolver] + [status-im2.constants :as constants])) + +(defn message-text + [{:keys [content-type] :as message}] + (cond (= content-type constants/content-type-audio) + (i18n/label :audio-message) + :else + (get-in message [:content :parsed-text]))) (defn banner [chat-id] (let [pinned-message (rf/sub [:chats/last-pinned-message chat-id]) - latest-pin-text (get-in pinned-message [:content :parsed-text]) + latest-pin-text (message-text pinned-message) {:keys [deleted? deleted-for-me?]} pinned-message pins-count (rf/sub [:chats/pin-messages-count chat-id]) - + content-type-text? (= (:content-type pinned-message) constants/content-type-text) latest-pin-text - (cond deleted? (i18n/label :t/message-deleted-for-everyone) - deleted-for-me? (i18n/label :t/message-deleted-for-you) - :else (resolver/resolve-message latest-pin-text))] + (cond deleted? (i18n/label :t/message-deleted-for-everyone) + deleted-for-me? (i18n/label :t/message-deleted-for-you) + content-type-text? (resolver/resolve-message latest-pin-text) + :else latest-pin-text)] [quo/banner {:latest-pin-text latest-pin-text :pins-count pins-count diff --git a/src/status_im2/core_spec.cljs b/src/status_im2/core_spec.cljs index 1a64237e14..2c76440e3d 100644 --- a/src/status_im2/core_spec.cljs +++ b/src/status_im2/core_spec.cljs @@ -1,3 +1,4 @@ (ns status-im2.core-spec (:require - [status-im2.contexts.communities.actions.community-options.component-spec])) + [status-im2.contexts.communities.actions.community-options.component-spec] + [status-im2.contexts.chat.messages.content.audio.component-spec])) diff --git a/src/test_helpers/component.cljs b/src/test_helpers/component.cljs index a48d3d2392..e7b777dc82 100644 --- a/src/test_helpers/component.cljs +++ b/src/test_helpers/component.cljs @@ -63,6 +63,11 @@ (def within rtl/within) +(defn wait-for + ([condition] (wait-for condition {})) + ([condition options] + (rtl/waitFor condition (clj->js options)))) + (defn fire-event ([event-name node] (fire-event event-name node nil)) diff --git a/test/jest/jestSetup.js b/test/jest/jestSetup.js index 8d4488fe46..67aae1382a 100644 --- a/test/jest/jestSetup.js +++ b/test/jest/jestSetup.js @@ -70,6 +70,14 @@ jest.mock("i18n-js", () => ({ t: (label) => `tx:${label}` })); +jest.mock("react-native-blob-util", () => ({ + default: { + config: jest.fn().mockReturnValue({ + fetch: jest.fn() + }) + } +})); + NativeModules.ReactLocalization = { language: 'en', locale: 'en', diff --git a/translations/en.json b/translations/en.json index e7e23273a3..a9b634f543 100644 --- a/translations/en.json +++ b/translations/en.json @@ -2087,5 +2087,6 @@ "community-request-pending": "Request pending", "community-request-pending-body-text": "You requested to join", "community-kicked-heading": "Kicked from community", - "community-kicked-body": "You were kicked from" + "community-kicked-body": "You were kicked from", + "error-loading-audio": "Error while loading audio" }