feat: audio component

Signed-off-by: Brian Sztamfater <brian@status.im>
This commit is contained in:
Brian Sztamfater 2023-03-13 14:08:03 -03:00
parent 0226a92c07
commit e5913cc3ea
No known key found for this signature in database
GPG Key ID: 59EB921E0706B48F
17 changed files with 344 additions and 18 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

View File

@ -522,7 +522,7 @@
seeking-audio?] seeking-audio?]
[:f> soundtrack/f-soundtrack [:f> soundtrack/f-soundtrack
{:audio-current-time-ms audio-current-time-ms {:audio-current-time-ms audio-current-time-ms
:player-ref player-ref :player-ref @player-ref
:seeking-audio? seeking-audio?}]]) :seeking-audio? seeking-audio?}]])
(when (or @recording? @reviewing-audio?) (when (or @recording? @reviewing-audio?)
[:f> f-time-counter @recording? @recording-length-ms @ready-to-delete? @reviewing-audio? [:f> f-time-counter @recording? @recording-length-ms @ready-to-delete? @reviewing-audio?

View File

@ -19,7 +19,7 @@
(let [player-ref (reagent/atom {}) (let [player-ref (reagent/atom {})
audio-current-time-ms (reagent/atom 0)] audio-current-time-ms (reagent/atom 0)]
(h/render [:f> soundtrack/f-soundtrack (h/render [:f> soundtrack/f-soundtrack
{:player-ref player-ref {:player-ref @player-ref
:audio-current-time-ms audio-current-time-ms}]) :audio-current-time-ms audio-current-time-ms}])
(-> (h/expect (h/get-by-test-id "soundtrack")) (-> (h/expect (h/get-by-test-id "soundtrack"))
(.toBeTruthy))))) (.toBeTruthy)))))
@ -31,7 +31,7 @@
audio-current-time-ms (reagent/atom 0)] audio-current-time-ms (reagent/atom 0)]
(h/render [:f> soundtrack/f-soundtrack (h/render [:f> soundtrack/f-soundtrack
{:seeking-audio? seeking-audio? {:seeking-audio? seeking-audio?
:player-ref player-ref :player-ref @player-ref
:audio-current-time-ms audio-current-time-ms}]) :audio-current-time-ms audio-current-time-ms}])
(h/fire-event (h/fire-event
:on-sliding-start :on-sliding-start
@ -47,7 +47,7 @@
audio-current-time-ms (reagent/atom 0)] audio-current-time-ms (reagent/atom 0)]
(h/render [:f> soundtrack/f-soundtrack (h/render [:f> soundtrack/f-soundtrack
{:seeking-audio? seeking-audio? {:seeking-audio? seeking-audio?
:player-ref player-ref :player-ref @player-ref
:audio-current-time-ms audio-current-time-ms}]) :audio-current-time-ms audio-current-time-ms}])
(h/fire-event (h/fire-event
:on-sliding-start :on-sliding-start
@ -69,7 +69,7 @@
audio-current-time-ms (reagent/atom 0)] audio-current-time-ms (reagent/atom 0)]
(h/render [:f> soundtrack/f-soundtrack (h/render [:f> soundtrack/f-soundtrack
{:seeking-audio? seeking-audio? {:seeking-audio? seeking-audio?
:player-ref player-ref :player-ref @player-ref
:audio-current-time-ms audio-current-time-ms}]) :audio-current-time-ms audio-current-time-ms}])
(h/fire-event (h/fire-event
:on-sliding-start :on-sliding-start

View File

@ -10,12 +10,14 @@
(def ^:private thumb-dark (js/require "../resources/images/icons2/12x12/thumb-dark.png")) (def ^:private thumb-dark (js/require "../resources/images/icons2/12x12/thumb-dark.png"))
(defn f-soundtrack (defn f-soundtrack
[{:keys [audio-current-time-ms player-ref seeking-audio?]}] [{:keys [audio-current-time-ms player-ref style seeking-audio?]}]
(let [audio-duration-ms (audio/get-player-duration @player-ref)] (let [audio-duration-ms (audio/get-player-duration player-ref)]
[:<> [:<>
[slider/slider [slider/slider
{:test-ID "soundtrack" {:test-ID "soundtrack"
:style (style/player-slider-container) :style (merge
(style/player-slider-container)
(or style {}))
:minimum-value 0 :minimum-value 0
:maximum-value audio-duration-ms :maximum-value audio-duration-ms
:value @audio-current-time-ms :value @audio-current-time-ms
@ -23,7 +25,7 @@
:on-sliding-complete (fn [seek-time] :on-sliding-complete (fn [seek-time]
(reset! seeking-audio? false) (reset! seeking-audio? false)
(audio/seek-player (audio/seek-player
@player-ref player-ref
seek-time seek-time
#(log/debug "[record-audio] on seek - seek time: " seek-time) #(log/debug "[record-audio] on seek - seek time: " seek-time)
#(log/error "[record-audio] on seek - error: " %))) #(log/error "[record-audio] on seek - error: " %)))

View File

@ -63,6 +63,7 @@
quo2.components.profile.select-profile.view quo2.components.profile.select-profile.view
quo2.components.reactions.reaction quo2.components.reactions.reaction
quo2.components.record-audio.record-audio.view quo2.components.record-audio.record-audio.view
quo2.components.record-audio.soundtrack.view
quo2.components.selectors.disclaimer.view quo2.components.selectors.disclaimer.view
quo2.components.selectors.filter.view quo2.components.selectors.filter.view
quo2.components.selectors.selectors.view quo2.components.selectors.selectors.view
@ -186,6 +187,7 @@
;;;; RECORD AUDIO ;;;; RECORD AUDIO
(def record-audio quo2.components.record-audio.record-audio.view/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 ;;;; SETTINGS
(def privacy-option quo2.components.settings.privacy-option/card) (def privacy-option quo2.components.settings.privacy-option/card)

View File

@ -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))))))

View File

@ -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})

View File

@ -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])))

View File

@ -14,6 +14,7 @@
[status-im2.contexts.chat.messages.content.album.view :as album] [status-im2.contexts.chat.messages.content.album.view :as album]
[status-im2.contexts.chat.messages.avatar.view :as avatar] [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.image.view :as image]
[status-im2.contexts.chat.messages.content.audio.view :as audio]
[quo2.core :as quo] [quo2.core :as quo]
[utils.re-frame :as rf] [utils.re-frame :as rf]
[status-im.ui2.screens.chat.messages.message :as old-message] [status-im.ui2.screens.chat.messages.message :as old-message]
@ -128,7 +129,7 @@
[not-implemented/not-implemented [old-message/sticker message-data]] [not-implemented/not-implemented [old-message/sticker message-data]]
constants/content-type-audio constants/content-type-audio
[not-implemented/not-implemented [old-message/audio message-data]] [audio/audio-message message-data context]
constants/content-type-image constants/content-type-image
[image/image-message 0 message-data context on-long-press] [image/image-message 0 message-data context on-long-press]

View File

@ -2,19 +2,28 @@
(:require [quo2.core :as quo] (:require [quo2.core :as quo]
[utils.i18n :as i18n] [utils.i18n :as i18n]
[utils.re-frame :as rf] [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 (defn banner
[chat-id] [chat-id]
(let [pinned-message (rf/sub [:chats/last-pinned-message 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 {:keys [deleted? deleted-for-me?]} pinned-message
pins-count (rf/sub [:chats/pin-messages-count chat-id]) pins-count (rf/sub [:chats/pin-messages-count chat-id])
content-type-text? (= (:content-type pinned-message) constants/content-type-text)
latest-pin-text latest-pin-text
(cond deleted? (i18n/label :t/message-deleted-for-everyone) (cond deleted? (i18n/label :t/message-deleted-for-everyone)
deleted-for-me? (i18n/label :t/message-deleted-for-you) deleted-for-me? (i18n/label :t/message-deleted-for-you)
:else (resolver/resolve-message latest-pin-text))] content-type-text? (resolver/resolve-message latest-pin-text)
:else latest-pin-text)]
[quo/banner [quo/banner
{:latest-pin-text latest-pin-text {:latest-pin-text latest-pin-text
:pins-count pins-count :pins-count pins-count

View File

@ -1,3 +1,4 @@
(ns status-im2.core-spec (ns status-im2.core-spec
(:require (: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]))

View File

@ -63,6 +63,11 @@
(def within rtl/within) (def within rtl/within)
(defn wait-for
([condition] (wait-for condition {}))
([condition options]
(rtl/waitFor condition (clj->js options))))
(defn fire-event (defn fire-event
([event-name node] ([event-name node]
(fire-event event-name node nil)) (fire-event event-name node nil))

View File

@ -70,6 +70,14 @@ jest.mock("i18n-js", () => ({
t: (label) => `tx:${label}` t: (label) => `tx:${label}`
})); }));
jest.mock("react-native-blob-util", () => ({
default: {
config: jest.fn().mockReturnValue({
fetch: jest.fn()
})
}
}));
NativeModules.ReactLocalization = { NativeModules.ReactLocalization = {
language: 'en', language: 'en',
locale: 'en', locale: 'en',

View File

@ -2087,5 +2087,6 @@
"community-request-pending": "Request pending", "community-request-pending": "Request pending",
"community-request-pending-body-text": "You requested to join", "community-request-pending-body-text": "You requested to join",
"community-kicked-heading": "Kicked from community", "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"
} }