testimonials 0.4.0 → 0.4.1
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 +8 -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 +40 -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: eec2476402ec7e3a4d2c414cad340aae35a0456db87d65c950d527e983ab7053
|
|
4
|
+
data.tar.gz: a910019755bf56f04cdfc180c6706be9a1b6fe2246c481822a647610a63a53f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84e12d4988eddabcba1ba473b7ddb92158674f34051fdf7005aae11171962b686621c81aade5121c33a700363422f1843183d6bb4d81c32123e2a84c757c51ee
|
|
7
|
+
data.tar.gz: 97731aaeffebf8bad9e02e44005a69d1507c99b46badda66735d3c4d5b756dbcb06eda88e10a3716d36ef13a82d9c3f962ac9993f0c2968e8d2bd39f8443b576
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
- Video poster frames: a still is captured from the camera at record time and
|
|
6
|
+
shown via `<video poster>`, so recorded videos display a real thumbnail
|
|
7
|
+
instead of a black box in every browser (Safari won't paint a
|
|
8
|
+
fragmented-MP4 frame from the file itself). Served at `/:id/poster`,
|
|
9
|
+
exposed as `poster_url` in the API.
|
|
10
|
+
|
|
3
11
|
## 0.4.0
|
|
4
12
|
|
|
5
13
|
- 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");
|
|
@@ -722,6 +731,24 @@
|
|
|
722
731
|
media = { stream: null, recorder: null, chunks: [], timer: null };
|
|
723
732
|
}
|
|
724
733
|
|
|
734
|
+
// Draw the current preview frame to a canvas *now* (synchronous, while the
|
|
735
|
+
// stream is live) and encode it as a JPEG poster. The canvas keeps the
|
|
736
|
+
// pixels, so the async toBlob is safe even after the camera stops.
|
|
737
|
+
function capturePoster(preview) {
|
|
738
|
+
try {
|
|
739
|
+
var w = preview.videoWidth;
|
|
740
|
+
var h = preview.videoHeight;
|
|
741
|
+
if (!w || !h) return;
|
|
742
|
+
var canvas = document.createElement("canvas");
|
|
743
|
+
canvas.width = w;
|
|
744
|
+
canvas.height = h;
|
|
745
|
+
canvas.getContext("2d").drawImage(preview, 0, 0, w, h);
|
|
746
|
+
canvas.toBlob(function (blob) {
|
|
747
|
+
if (state && blob) state.posterBlob = blob;
|
|
748
|
+
}, "image/jpeg", 0.8);
|
|
749
|
+
} catch (e) { /* poster is best-effort; a black frame is not fatal */ }
|
|
750
|
+
}
|
|
751
|
+
|
|
725
752
|
// --- NPS ----------------------------------------------------------------------
|
|
726
753
|
|
|
727
754
|
function npsStage() {
|
|
@@ -858,6 +885,9 @@
|
|
|
858
885
|
if (state.videoBlob) {
|
|
859
886
|
var extension = (state.videoBlob.type || "video/webm").indexOf("mp4") > -1 ? "mp4" : "webm";
|
|
860
887
|
data.append("testimonial[video_file]", state.videoBlob, "testimonial." + extension);
|
|
888
|
+
// The poster frame captured at record time, so the stored video shows
|
|
889
|
+
// a real thumbnail instead of a black box in every browser.
|
|
890
|
+
if (state.posterBlob) data.append("testimonial[poster]", state.posterBlob, "poster.jpg");
|
|
861
891
|
} else if (state.removeExistingVideo) {
|
|
862
892
|
data.append("testimonial[remove_video]", "1");
|
|
863
893
|
}
|
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
|