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 +4 -4
- data/CHANGELOG.md +15 -0
- data/app/controllers/testimonials/api/testimonials_controller.rb +2 -1
- data/app/controllers/testimonials/media_controller.rb +6 -0
- data/app/controllers/testimonials/testimonials_controller.rb +22 -1
- data/app/models/testimonials/testimonial.rb +5 -0
- data/app/views/testimonials/testimonials/show.html.erb +4 -3
- data/config/routes.rb +1 -0
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials/widget.js +64 -10
- data/lib/testimonials/widget.rb +8 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f32069743e1abbbe0f5ce913e32e0c37f1e23230c0f5d59bb903dd9189761153
|
|
4
|
+
data.tar.gz: fbae328897d5aea39f1896e55324df8182f08bae09e7907a30510dfd3026c82d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
<%#
|
|
21
|
-
|
|
22
|
-
|
|
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
|
data/lib/testimonials/version.rb
CHANGED
data/lib/testimonials/widget.js
CHANGED
|
@@ -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
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
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
|
|
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
|
}
|
data/lib/testimonials/widget.rb
CHANGED
|
@@ -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
|