Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

Hotwire or React? ~Reactの録画機能をHotwireに置き換えて得られた...

Hotwire or React? ~Reactの録画機能をHotwireに置き換えて得られた知見~ / hotwire_or_react

haruna tsujita

October 25, 2024
Tweet

More Decks by haruna tsujita

Other Decks in Programming

Transcript

  1. 3FDPSE $MFBS 4UPQ $MFBS )J 4UPQ $MFBS (PPEMVDL 3FDPSE $MFBS

    ಈը࠶ੜ EJTBCMFEղআ EJTBCMFEUSVF 1045
  2. ݱঢ়ͷ࣮૷ 3FBDU ఴ࡟ϖʔδ 3FDPSE $MFBS // app/views/summaries/feedbacks/edit.html.haml -# লུ #summary-video-recorder{data:

    {'summary-id': @summary.id}} = javascript_include_tag ‘SummaryVideoRecorder' -# লུ w ఴ࡟ϖʔδͷWJFX͔Β3FBDUΛݺͼग़͢
  3. w ಈըͷදࣔɾ3FDPSEϘλϯɾ$MFBSϘλϯ͕ίϯϙʔωϯτԽ w ϘλϯͷΫϦοΫΠϕϯτΛى఺ʹॲཧ͕࣮ߦ͞ΕΔ return ( <> <Video theme='large' videoUrl={videoUrl}

    mimeType='video/webm' /> <p> <RecordButton startRecording={startRecording} stopRecording={stopRecording} id={summaryId} videoUrl={videoUrl} /> <ClearButton onClick={handleClear} isDisabled={isPosting || !videoUrl} /> </p> </> ) } ݱঢ়ͷ࣮૷ 3FDPSE $MFBS
  4. )PUXJSF4UJNVMVTͱ͸ʁ <!--HTML from anywhere--> <div data-controller="hello"> <input data-hello-target="name" type="text"> <button

    data-action="click->hello#greet"> Greet </button> <span data-hello-target="output"> </span> </div> // hello_controller.js import { Controller } from "stimulus" export default class extends Controller { static targets = [ "name", "output" ] greet() { this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!` } } Ҿ༻ɿ)PUXJSF)BOECPPL 5VSCPͱ૬ੑͷΑ͍ +BWB4DSJQUϑϨʔϜϫʔΫ
  5. )PUXJSF4UJNVMVTͱ͸ʁ <!--HTML from anywhere--> <div data-controller="hello"> <input data-hello-target="name" type="text"> <button

    data-action="click->hello#greet"> Greet </button> <span data-hello-target="output"> </span> </div> // hello_controller.js import { Controller } from "stimulus" export default class extends Controller { static targets = [ "name", "output" ] greet() { this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!` } } Ҿ༻ɿ)PUXJSF)BOECPPL
  6. )PUXJSF4UJNVMVTͱ͸ʁ <!--HTML from anywhere--> <div data-controller="hello"> <input data-hello-target="name" type="text"> <button

    data-action="click->hello#greet"> Greet </button> <span data-hello-target="output"> </span> </div> // hello_controller.js import { Controller } from "stimulus" export default class extends Controller { static targets = [ "name", "output" ] greet() { this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!` } } Ҿ༻ɿ)PUXJSF)BOECPPL ΠϕϯτͰൃੜ͢ΔΞΫγϣϯΛࢦఆ
  7. )PUXJSF4UJNVMVTͱ͸ʁ <!--HTML from anywhere--> <div data-controller="hello"> <input data-hello-target="name" type="text"> <button

    data-action="click->hello#greet"> Greet </button> <span data-hello-target="output"> </span> </div> // hello_controller.js import { Controller } from "stimulus" export default class extends Controller { static targets = [ "name", "output" ] greet() { this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!` } } Ҿ༻ɿ)PUXJSF)BOECPPL )BSVOBͱೖྗͯ͠(SFFUϘλϯΛԡ͢ͱz)FMMP )BSVOBz͕ग़ྗ
  8. )PUXJSF΁ஔ͖׵͑ // app/javascript/controllers/video_recorder_controller.js export default class extends Controller { static

    targets = ["video"] static values = { summaryId: Number } } -# app/views/summaries/feedbacks/edit.html.haml %div{data: {controller: "video-recorder", video_recorder_summary_id_value: @summary.id}} %video{data: {video_recorder_target: "video"}, controls: true, autoplay: true, muted: true} %button{data: {action: "click->video-recorder#startRecording"}} Record %button{data: {action: "click->video-recorder#stopRecording"} Stop %button{data: {action: "click->video-recorder#clearVideo"} Clear
  9. )PUXJSF΁ஔ͖׵͑ ࣮૷్தͳͷʹɺͳΜ͔+BWB4DSJQU ͍ͬͺ͍ॻ͍ͯΔͧɾɾʁ // app/javascript/controllers/video_recorder_controller.js import { Controller } from

    "@hotwired/stimulus" // Connects to data-controller="feedback-video" export default class extends Controller { static targets = ["video", "recordButton", "stopButton", "clearButton"] static values = { summaryId: Number } async connect() { console.log(this.summaryIdValue) this.chunks = [] const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }) this.videoTarget.srcObject = stream this.videoTarget.play() this.mediaRecorder = new MediaRecorder(stream) this.mediaRecorder.ondataavailable = (event) => this.chunks.push(event.data) } startRecording() { try { console.log('start recording') this.mediaRecorder.start() } catch(error) { console.error('Error accessing media devices.', error) } } stopRecording() { console.log('stop recording') this.mediaRecorder.stop() this.mediaRecorder.onstop = this.handleStop.bind(this) } handleStop() { const blob = new Blob(this.chunks, { type: 'video/webm' }) const url = URL.createObjectURL(blob) this.videoTarget.src = url this.uploadVideo(blob) } async uploadVideo(blob) { const formData = new FormData() formData.append( 'summary_video[video]', blob, `summary_video_id${this.summaryIdValue}.webm` ) const response = await fetch(`/api/summaries/${this.summaryIdValue}/summary_feedback_videos`, { method: 'POST', body: formData, headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content } }) if (response.ok) { await response.json() console.log('Video uploaded') } else { console.error('Failed to upload video', await response.text()) } } clearVideo() { try { fetch(`/api/summaries/${this.summaryIdValue}/summary_feedback_videos`, { method: 'DELETE', headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content } }) } catch(error) { console.error('Error deleting video', error) } } } ˞த਎͸ಡ·ͳͯ͘େৎ෉ͳͷͰɺ ࠞಱͱͨ͠ײ͡Λ἞Έऔ͍͚ͬͯͨͩΔͱخ͍͠Ͱ͢ɻ
  10. 3FDPSE $MFBS 4UPQ $MFBS )J 4UPQ $MFBS (PPEMVDL 3FDPSE $MFBS

    1045 %&-&5& ը໘ͷྲྀΕʹԊ࣮ͬͯ૷͢Δͷ͸μϝͩͬͨɾɾ
  11. 3FDPSE $MFBS 4UPQ $MFBS (PPEMVDL 4UPQ $MFBS )J 3FDPSE $MFBS

    1045 %&-&5& ʢ࠶ܝʣϘλϯͷΫϦοΫͰঢ়ଶ͕ભҠ͢Δ
  12. 3FDPSE $MFBS 4UPQ $MFBS (PPEMVDL 4UPQ $MFBS )J 3FDPSE $MFBS

    1045 %&-&5& %#ʹಈը σʔλͳ͠ %#ʹಈը σʔλ͋Γ ࿥ը։࢝  ऴྃ
  13. 3FDPSE $MFBS 4UPQ $MFBS (PPEMVDL 4UPQ $MFBS )J 3FDPSE $MFBS

    1045 %&-&5& ᶃಈըσʔλͳ͠ ᶄಈըσʔλ͋Γ
  14. 3FDPSE $MFBS 4UPQ $MFBS (PPEMVDL 4UPQ $MFBS )J 3FDPSE $MFBS

    1045 %&-&5& ᶃಈըσʔλͳ͠ ᶄಈըσʔλ͋Γ 5VSCPΛར༻Ͱ͖ͦ͏
  15. ࢴࣳډͱͯ͠ߟ͑ͯΈΔ 3FDPSE ᶃಈըσʔλͳ͠ $MFBS ᶄಈըσʔλ͋Γ 1045 %&-&5& $MFBS 3FDPSE w

    $36%ૢ࡞͕͖͔͚ͬͰൃੜ͢Δը໘ͷ੾Γସ͑͸5VSCPʹ೚ͤΔ ˞ʰ8FCࢴࣳډʱͷτʔΫ͸ը໘શମΛຕͷࢴࣳډͱͯ͠ଊ͑Δ͓࿩ͳͷͰɺ τʔΫ͔ΒώϯτΛಘͨ͏͑ͰҟͳΔΞϓϩʔνΛ͍ͯ͠·͢
  16. 3FDPSE $MFBS 4UPQ $MFBS (PPEMVDL 4UPQ $MFBS )J 3FDPSE $MFBS

    1045 %&-&5& ᶃಈըσʔλͳ͠ ᶄಈըσʔλ͋Γ ʁʁʁ ࿥ը։࢝ऴྃ Ϙλϯͷ%0.ૢ࡞ͳͲ
  17. 3FDPSE $MFBS 4UPQ $MFBS (PPEMVDL 4UPQ $MFBS )J 3FDPSE $MFBS

    1045 %&-&5& ᶃಈըσʔλͳ͠ ᶄಈըσʔλ͋Γ 5VSCPͰ࣮૷
  18. )PUXJSF΁ஔ͖׵͑d5VSCPΛ࢖͏d // app/views/summaries/feedbacks/_summary_feedback_video.html.haml = turbo_frame_tag 'summary-feedback-video' do %div{data: {controller: "video-recorder",

    {//stimulusίϯτϩʔϥ΁σʔλ౉͠}}} .is-flex.is-flex-direction-column.is-align-items-start - if summary_feedback_video.present? // ϏσΦσʔλ͕͋Δ࣌ͷॲཧ - else // ϏσΦσʔλ͕ͳ͍࣌ͷॲཧ // ఴ࡟ϖʔδͷview = render partial: 'summaries/feedbacks/summary_feedback_video', locals: { // লུ } ˣbTVNNBSZGFFECBDLWJEFP` ఴ࡟ϖʔδͷWJFX
  19. // app/views/summaries/feedbacks/_summary_feedback_video.html.haml = turbo_frame_tag 'summary-feedback-video' do %div{data: {controller: "video-recorder", {//

    ίϯτϩʔϥʔ΁σʔλड͚౉͠}}} .is-flex.is-flex-direction-column.is-align-items-start - if summary_feedback_video.present? %video %source{src: summary_feedback_video.video_url, type: "video/webm"} = link_to 'Clear', summary_summary_feedback_videos_path(summary), data: { turbo_confirm: "ຊ౰ʹ࡟আ͠·͔͢ʁ", turbo_method: :delete } - else // ϏσΦσʔλ͕ͳ͍࣌ͷॲཧ ˞εϖʔεͷؔ܎ͰࢿྉͰ͸Ұ෦লུ͍ͯ͠ΔՕॴ͕͋Γ·͢ # app/controllers/summaries/summary_feedback_videos_controller.rb def destroy @summary = Summary.find(params[:summary_id]) @summary_feedback_video = @summary.summary_feedback_video @summary_feedback_video.destroy! end ˢ҉໧తʹUVSCP@TUSFBNΛSFOEFS ˠ5VSCPϦΫΤετˠ3BJMTίϯτϩʔϥʔ
  20. // app/views/summaries/summary_feedback_videos/destroy.turbo_stream.haml = turbo_stream.replace 'summary-feedback-video', partial: 'summaries/feedbacks/summary_feedback_video', locals: { summary:

    @summary, summary_feedback_video: nil } ˠ5VSCPϦΫΤετˠ3BJMTίϯτϩʔϥʔˠUVSCP@TUSFBN ˣbTVNNBSZGFFECBDLWJEFP` ఴ࡟ϖʔδͷWJFX ˞εϖʔεͷؔ܎ͰࢿྉͰ͸վߦΛೖΕ͍ͯ·͢
  21. // app/views/summaries/summary_feedback_videos/destroy.turbo_stream.haml = turbo_stream.replace 'summary-feedback-video', partial: 'summaries/feedbacks/summary_feedback_video', locals: { summary:

    @summary, summary_feedback_video: nil } ˠ5VSCPϦΫΤετˠ3BJMTίϯτϩʔϥʔˠUVSCP@TUSFBN // app/views/summaries/feedbacks/_summary_feedback_video.html.haml = turbo_frame_tag 'summary-feedback-video' do %div{data: {controller: "video-recorder", {// ίϯτϩʔϥʔ΁σʔλड͚౉͠}}} .is-flex.is-flex-direction-column.is-align-items-start - if summary_feedback_video.present? // ϏσΦσʔλ͕͋Δ࣌ͷॲཧ - else // ϏσΦσʔλ͕ͳ͍࣌ͷॲཧ ઌ΄Ͳ·Ͱ͸σʔλ͕ ͋ͬͨͷͰͪ͜Β
  22. // app/views/summaries/summary_feedback_videos/destroy.turbo_stream.haml = turbo_stream.replace 'summary-feedback-video', partial: 'summaries/feedbacks/summary_feedback_video', locals: { summary:

    @summary, summary_feedback_video: nil } ˠ5VSCPϦΫΤετˠ3BJMTίϯτϩʔϥʔˠUVSCP@TUSFBNˠ // app/views/summaries/feedbacks/_summary_feedback_video.html.haml = turbo_frame_tag 'summary-feedback-video' do %div{data: {controller: "video-recorder", {// ίϯτϩʔϥʔ΁σʔλड͚౉͠}}} .is-flex.is-flex-direction-column.is-align-items-start - if summary_feedback_video.present? // ϏσΦσʔλ͕͋Δ࣌ͷॲཧ - else // ϏσΦσʔλ͕ͳ͍࣌ͷॲཧ σʔλ͕࡟আ͞Εͨ ͷͰͪ͜Β͕࣮ߦ͞ΕΔ ˣbTVNNBSZGFFECBDLWJEFP` ఴ࡟ϖʔδͷWJFX
  23. 3FDPSE $MFBS 4UPQ $MFBS (PPEMVDL 4UPQ $MFBS )J 3FDPSE $MFBS

    1045 %&-&5& ᶃಈըσʔλͳ͠ ᶄಈըσʔλ͋Γ 4UJNVMVTͰ࣮૷
  24. // app/javascript/controllers/video_recorder_controller.js import { Controller } from "@hotwired/stimulus" import {

    // ར༻͢Δؔ਺ } from “./video_recorder_utils" export default class extends Controller { static targets = ["video", "recordButton"] static values = { writingId: Number, writingType: String, feedbackVideo: String, uploadUrl: String } async connect() { // ઀ଓ࣌ͷॲཧ } async startRecording() { // ࿥ը։࢝ͷॲཧ } stopRecording() { // ࿥ըऴྃͷॲཧ } }
  25. // app/javascript/controllers/video_recorder_controller.js import { Controller } from "@hotwired/stimulus" import {

    initializeMediaStream, setupMediaRecorder, setStopButton, uploadData } from “./video_recorder_utils" // Connects to data-controller="video-recorder" export default class extends Controller { static targets = ["video", "recordButton"] static values = { writingId: Number, writingType: String, feedbackVideo: String, uploadUrl: String } async connect() { if (!this.feedbackVideoValue) { this.mediaStream = await initializeMediaStream() } } async startRecording() { if (!this.mediaStream) { this.mediaStream = await initializeMediaStream() if (!this.mediaStream) return } this.mediaRecorder = setupMediaRecorder(this.mediaStream) this.mediaRecorder.start() setStopButton(this.recordButtonTarget) this.mediaRecorder.ondataavailable = (event) => uploadData(event, this.writingTypeValue, this.writingIdValue, this.uploadUrlValue) } stopRecording() { this.mediaRecorder?.stop() this.mediaStream.getTracks().forEach(track => track.stop()) this.recordButtonTarget.disabled = true }} 5PUBMߦ
  26. $36%ͷΈ $36% +4 $36%ͳ͠ +4 )PUXJSFPS3FBDUʁ w $36%ૢ࡞͕ओମ ิॿతͳ+BWB4DSJQU͕ඞཁͳը໘ ‣ࢴࣳډ

    ͪΐͬͱͨ͠࢓ֻ͚͕͋Δ৔߹ w )PUXJSFͰे෼ରԠͰ͖Δɺੵۃతʹ࢖͍͖͍ͬͯͨ