testimonials 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de1f909878b18970be1bef107ddcc34e863b1e4118ba404b80bb894a36c39eb2
4
- data.tar.gz: ea00c89fee76e431d36cd7034ca19987851aee12a9c326e50cdd851fd8c60861
3
+ metadata.gz: f32069743e1abbbe0f5ce913e32e0c37f1e23230c0f5d59bb903dd9189761153
4
+ data.tar.gz: fbae328897d5aea39f1896e55324df8182f08bae09e7907a30510dfd3026c82d
5
5
  SHA512:
6
- metadata.gz: 322db6f66cc308f1aa827fa2d56fa171fd8bd14471208f20d2a4a8f6c548ff66cc3d59df5bb3e1b2fa167229bd23dec608deb21b2040ebf6daea984c8d8b45c5
7
- data.tar.gz: 1ad3ecfa42433b655b074c99b1fe3d65b7e7047f1dab4e023eb6439d685e82b0dceef875919e6a5857c8e0b3f0839623701bf4ba11f4c810952108843656a1fe
6
+ metadata.gz: c9345c6257864c1531782f4a7cdd1fd136118aab23898e57ae28723b13a27107f657d865b77947c0ac26de7ed06b143ce8cb2ffd0463a6cfc57c08f28cf90085
7
+ data.tar.gz: 27d79110f0b6e043373e0e2989f7f1947810d2c52a1c6e21b5f71e759739c9762f668e6e05f0bbb8a9e9879e38dcb4b61de8d3d276d626a89b1440e4c3af6e35
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2
4
+
5
+ - Uploaded videos now get a poster frame too (grabbed from the file
6
+ client-side), so Safari shows a preview instead of a black frame before
7
+ play — previously only recorded videos captured a poster. Best-effort;
8
+ legacy videos with no stored poster are unaffected (re-upload to add one).
9
+
10
+ ## 0.4.1
11
+
12
+ - Video poster frames: a still is captured from the camera at record time and
13
+ shown via `<video poster>`, so recorded videos display a real thumbnail
14
+ instead of a black box in every browser (Safari won't paint a
15
+ fragmented-MP4 frame from the file itself). Served at `/:id/poster`,
16
+ exposed as `poster_url` in the API.
17
+
3
18
  ## 0.4.0
4
19
 
5
20
  - Consent is now an explicit public/private choice instead of a single
@@ -33,7 +33,8 @@ module Testimonials
33
33
  featured: testimonial.featured?,
34
34
  created_at: testimonial.created_at.iso8601,
35
35
  avatar_url: (testimonial_avatar_url(testimonial) if testimonial.avatar_attached?),
36
- video_url: (testimonial_video_url(testimonial) if testimonial.video_attached?)
36
+ video_url: (testimonial_video_url(testimonial) if testimonial.video_attached?),
37
+ poster_url: (testimonial_poster_url(testimonial) if testimonial.poster_attached?)
37
38
  }
38
39
  end
39
40
  end
@@ -17,6 +17,12 @@ module Testimonials
17
17
  redirect_to main_app.rails_blob_path(@testimonial.video_file, disposition: disposition)
18
18
  end
19
19
 
20
+ def poster
21
+ head :not_found and return unless @testimonial.poster_attached?
22
+
23
+ redirect_to main_app.rails_blob_path(@testimonial.poster, disposition: disposition)
24
+ end
25
+
20
26
  def avatar
21
27
  head :not_found and return unless @testimonial.avatar_attached?
22
28
 
@@ -55,7 +55,10 @@ module Testimonials
55
55
  if testimonial.save
56
56
  # Purge only after a valid save, so a failed update can't strand a
57
57
  # review with its video already gone.
58
- testimonial.video_file.purge if remove_video? && testimonial.video_attached?
58
+ if remove_video? && testimonial.video_attached?
59
+ testimonial.video_file.purge
60
+ testimonial.poster.purge if testimonial.poster_attached?
61
+ end
59
62
  record_submission
60
63
  notify_host(testimonial)
61
64
  head :created
@@ -154,9 +157,27 @@ module Testimonials
154
157
  return error_label(:error_save) unless file.content_type.to_s.start_with?('video/', 'audio/')
155
158
 
156
159
  testimonial.video_file.attach(file)
160
+ attach_poster(testimonial)
157
161
  nil
158
162
  end
159
163
 
164
+ # The poster rides with a new video upload. It's best-effort — a missing
165
+ # or malformed poster never blocks the testimonial. Attaching replaces any
166
+ # previous poster; a new video with no usable poster (e.g. an uploaded
167
+ # file, which we can't frame-grab) drops the stale one so it can't show a
168
+ # thumbnail from the wrong video.
169
+ def attach_poster(testimonial)
170
+ file = params.dig(:testimonial, :poster)
171
+ usable = file.present? && file.content_type.to_s.start_with?('image/') &&
172
+ file.size <= Testimonials.config.max_avatar_size
173
+
174
+ if usable
175
+ testimonial.poster.attach(file)
176
+ elsif testimonial.poster_attached?
177
+ testimonial.poster.purge
178
+ end
179
+ end
180
+
160
181
  def attach_avatar(testimonial)
161
182
  file = params.dig(:testimonial, :avatar)
162
183
  return nil if file.blank?
@@ -11,6 +11,7 @@ module Testimonials
11
11
 
12
12
  if defined?(::ActiveStorage)
13
13
  has_one_attached :video_file
14
+ has_one_attached :poster # a still frame for the video, captured at record time
14
15
  has_one_attached :avatar
15
16
  end
16
17
 
@@ -37,6 +38,10 @@ module Testimonials
37
38
  respond_to?(:video_file) && video_file.attached?
38
39
  end
39
40
 
41
+ def poster_attached?
42
+ respond_to?(:poster) && poster.attached?
43
+ end
44
+
40
45
  def avatar_attached?
41
46
  respond_to?(:avatar) && avatar.attached?
42
47
  end
@@ -17,10 +17,11 @@
17
17
 
18
18
  <div class="card pad" style="margin-bottom: 16px;">
19
19
  <% if @testimonial.video_attached? %>
20
- <%# No #t=0.1 fragment: MediaRecorder webm has no seek index, and
21
- Firefox honors media fragments literally the pending seek stalls
22
- playback right after play. preload="metadata" paints the frame. %>
20
+ <%# The stored poster shows a real frame with no decoding Safari won't
21
+ paint a fragmented-MP4 frame on its own, so preload/fragment tricks
22
+ leave it black. %>
23
23
  <video class="playback" controls preload="metadata"
24
+ poster="<%= testimonial_poster_path(@testimonial) if @testimonial.poster_attached? %>"
24
25
  src="<%= testimonial_video_path(@testimonial) %>"></video>
25
26
  <p class="muted">
26
27
  <%= link_to "⬇ #{t('testimonials.dashboard.download_video', default: 'Download video')}",
data/config/routes.rb CHANGED
@@ -21,6 +21,7 @@ Testimonials::Engine.routes.draw do
21
21
 
22
22
  # Media by testimonial id, gated (admin, author, or public_api + publishable).
23
23
  get ':id/video', to: 'media#video', as: :testimonial_video, constraints: { id: /\d+/ }
24
+ get ':id/poster', to: 'media#poster', as: :testimonial_poster, constraints: { id: /\d+/ }
24
25
  get ':id/avatar', to: 'media#avatar', as: :testimonial_avatar, constraints: { id: /\d+/ }
25
26
 
26
27
  # Flat, human URLs: the mount path IS the resource. /testimonials is the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testimonials
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.2'
5
5
  end
@@ -163,6 +163,7 @@
163
163
  if (!overlay) return;
164
164
  stopMedia();
165
165
  if (state && state.playbackUrl) URL.revokeObjectURL(state.playbackUrl);
166
+ if (state && state.posterUrl) URL.revokeObjectURL(state.posterUrl);
166
167
  if (state && state.auto && !state.submitted) postEvent(state.kind, "dismissed");
167
168
  overlay.remove();
168
169
  overlay = null;
@@ -276,6 +277,7 @@
276
277
  state.body = config.existing.body || "";
277
278
  state.consent = !!config.existing.consent;
278
279
  state.existingVideoUrl = config.existing.videoUrl || null;
280
+ state.existingPosterUrl = config.existing.posterUrl || null;
279
281
  }
280
282
 
281
283
  // --- stage 2: the testimonial form ------------------------------------------
@@ -423,12 +425,9 @@
423
425
  playback.controls = true;
424
426
  playback.playsInline = true;
425
427
  playback.preload = "metadata";
426
- // No #t=0.1 fragment here either (see the review-stage comment):
427
- // MediaRecorder webm has no seek index, and Firefox honors the
428
- // fragment literally — the pending seek stalls playback a moment
429
- // after play. preload="metadata" already paints the first frame in
430
- // Chrome and Firefox; iOS shows the controls on a blank frame, which
431
- // beats a player that pauses itself.
428
+ // The stored poster shows a real frame with no decoding — the reliable
429
+ // fix for Safari, which won't paint a fragmented-MP4 frame on its own.
430
+ if (state.existingPosterUrl) playback.poster = state.existingPosterUrl;
432
431
  playback.src = state.existingVideoUrl;
433
432
  wrap.appendChild(playback);
434
433
  wrap.appendChild(attachedVideoChip(form, wrap, playback, function () {
@@ -551,7 +550,7 @@
551
550
  runCountdown(countdown, 3, function () {
552
551
  start.hidden = true;
553
552
  stop.hidden = false;
554
- beginRecording(timer, stop);
553
+ beginRecording(preview, timer, stop);
555
554
  });
556
555
  });
557
556
 
@@ -582,7 +581,7 @@
582
581
  }, 800);
583
582
  }
584
583
 
585
- function beginRecording(timer, stop) {
584
+ function beginRecording(preview, timer, stop) {
586
585
  media.chunks = [];
587
586
  var recorder;
588
587
  try {
@@ -598,6 +597,12 @@
598
597
  // A teardown (Cancel, close) also stops the recorder — only a real
599
598
  // Finish, where this recorder is still the active one, goes to review.
600
599
  if (media.recorder !== recorder) return;
600
+ // Grab a poster frame from the still-live preview stream *before*
601
+ // showStage tears the camera down. Captured from the camera, never
602
+ // from the recorded blob — seeking Safari's own fragmented MP4 to
603
+ // paint a frame is exactly what fails (black or stall), so we store a
604
+ // real image and hand it to <video poster>, which needs no decoding.
605
+ capturePoster(preview);
601
606
  var type = recorder.mimeType || "video/webm";
602
607
  var blob = new Blob(media.chunks, { type: type.split(";")[0] });
603
608
  showStage(reviewStage(blob));
@@ -668,9 +673,13 @@
668
673
  state.playbackUrl = URL.createObjectURL(blob);
669
674
  // No #t=0.1 here, on purpose: seeking a blob of Safari's own fragmented
670
675
  // MP4 recording can stall the video track while audio keeps playing.
671
- // The fragment-thumbnail trick is only for server-served files; a
672
- // just-recorded clip gets played immediately anyway.
676
+ // The captured poster (below) shows a frame without any decoding.
673
677
  playback.src = state.playbackUrl;
678
+ if (state.posterBlob) {
679
+ if (state.posterUrl) URL.revokeObjectURL(state.posterUrl);
680
+ state.posterUrl = URL.createObjectURL(state.posterBlob);
681
+ playback.poster = state.posterUrl;
682
+ }
674
683
  stage.appendChild(playback);
675
684
 
676
685
  var error = el("p", "tml-error");
@@ -706,11 +715,35 @@
706
715
  var file = input.files && input.files[0];
707
716
  if (!file) return;
708
717
  state.videoBlob = file;
718
+ // Recorded videos grab their poster from the live preview; uploaded
719
+ // files have no live stream, so grab one from the file itself — else
720
+ // Safari shows a black frame (it won't paint a posterless video).
721
+ capturePosterFromFile(file);
709
722
  showStage(formStage(state.formTitle || config.labels.shareTitle));
710
723
  });
711
724
  input.click();
712
725
  }
713
726
 
727
+ // Load an uploaded video offscreen, seek a hair past the start (frame 0 is
728
+ // often black), and reuse capturePoster's canvas grab. Best-effort and
729
+ // async — if the customer sends before it resolves, no poster, no harm.
730
+ function capturePosterFromFile(file) {
731
+ try {
732
+ var url = URL.createObjectURL(file);
733
+ var probe = document.createElement("video");
734
+ probe.preload = "metadata";
735
+ probe.muted = true;
736
+ probe.playsInline = true;
737
+ var cleanup = function () { try { URL.revokeObjectURL(url); } catch (e) { /* noop */ } };
738
+ probe.addEventListener("loadeddata", function () {
739
+ try { probe.currentTime = Math.min(0.1, (probe.duration || 1) / 2); } catch (e) { cleanup(); }
740
+ });
741
+ probe.addEventListener("seeked", function () { capturePoster(probe); cleanup(); });
742
+ probe.addEventListener("error", cleanup);
743
+ probe.src = url;
744
+ } catch (e) { /* poster is best-effort */ }
745
+ }
746
+
714
747
  function stopMedia() {
715
748
  if (media.timer) clearInterval(media.timer);
716
749
  if (media.recorder && media.recorder.state !== "inactive") {
@@ -722,6 +755,24 @@
722
755
  media = { stream: null, recorder: null, chunks: [], timer: null };
723
756
  }
724
757
 
758
+ // Draw the current preview frame to a canvas *now* (synchronous, while the
759
+ // stream is live) and encode it as a JPEG poster. The canvas keeps the
760
+ // pixels, so the async toBlob is safe even after the camera stops.
761
+ function capturePoster(preview) {
762
+ try {
763
+ var w = preview.videoWidth;
764
+ var h = preview.videoHeight;
765
+ if (!w || !h) return;
766
+ var canvas = document.createElement("canvas");
767
+ canvas.width = w;
768
+ canvas.height = h;
769
+ canvas.getContext("2d").drawImage(preview, 0, 0, w, h);
770
+ canvas.toBlob(function (blob) {
771
+ if (state && blob) state.posterBlob = blob;
772
+ }, "image/jpeg", 0.8);
773
+ } catch (e) { /* poster is best-effort; a black frame is not fatal */ }
774
+ }
775
+
725
776
  // --- NPS ----------------------------------------------------------------------
726
777
 
727
778
  function npsStage() {
@@ -858,6 +909,9 @@
858
909
  if (state.videoBlob) {
859
910
  var extension = (state.videoBlob.type || "video/webm").indexOf("mp4") > -1 ? "mp4" : "webm";
860
911
  data.append("testimonial[video_file]", state.videoBlob, "testimonial." + extension);
912
+ // The poster frame captured at record time, so the stored video shows
913
+ // a real thumbnail instead of a black box in every browser.
914
+ if (state.posterBlob) data.append("testimonial[poster]", state.posterBlob, "poster.jpg");
861
915
  } else if (state.removeExistingVideo) {
862
916
  data.append("testimonial[remove_video]", "1");
863
917
  }
@@ -86,7 +86,8 @@ module Testimonials
86
86
  rating: existing.rating,
87
87
  body: existing.body,
88
88
  consent: existing.consent_given ? true : false,
89
- videoUrl: existing_video_url(existing)
89
+ videoUrl: existing_video_url(existing),
90
+ posterUrl: existing_poster_url(existing)
90
91
  },
91
92
  video: {
92
93
  enabled: config.video_enabled?,
@@ -112,6 +113,12 @@ module Testimonials
112
113
  "#{Testimonials.config.mount_path.chomp('/')}/#{existing.id}/video"
113
114
  end
114
115
 
116
+ def existing_poster_url(existing)
117
+ return unless existing.poster_attached?
118
+
119
+ "#{Testimonials.config.mount_path.chomp('/')}/#{existing.id}/poster"
120
+ end
121
+
115
122
  # Every user-facing string in the widget, resolved through Rails I18n so
116
123
  # the form follows the app's current locale. Each lookup carries an
117
124
  # English default, so the widget stays fully worded even when a key is
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: testimonials
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov