From 3286de37ee9bea98f69da0d163775bdd90b1261d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Thu, 22 Oct 2026 22:39:07 -0400 Subject: [PATCH 1/8] Start: play story YouTube videos in the story share carousels Co-Authored-By: Claude Opus 5.5 (1M context) From 1815ea20ea6d63912c2fabbba8be84c790b441d5 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Thu, 22 Oct 2026 22:44:16 -0400 Subject: [PATCH 2/8] Play story videos in the Story Share carousels A story's video says more than its photo, so the home and browse carousels now loop it muted in the photo's place. The player is decorative and click-through, so the slide still links to the story and swiping keeps working. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/story_shares_helper.rb | 2 + app/helpers/youtube_helper.rb | 29 +++++++--- app/views/story_shares/_banner_video.html.erb | 9 +++ app/views/story_shares/_browse.html.erb | 2 +- app/views/story_shares/_home.html.erb | 1 + app/views/story_shares/_story_image.html.erb | 7 ++- ...y-videos-play-in-story-share-carousels.yml | 12 ++++ spec/helpers/youtube_helper_spec.rb | 39 +++++++++++++ spec/requests/story_share_spec.rb | 56 +++++++++++++++++++ 9 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 app/views/story_shares/_banner_video.html.erb create mode 100644 db/seeds/features/2026-10-22-story-videos-play-in-story-share-carousels.yml create mode 100644 spec/helpers/youtube_helper_spec.rb diff --git a/app/helpers/story_shares_helper.rb b/app/helpers/story_shares_helper.rb index 9d35bea0a3..a81f10f567 100644 --- a/app/helpers/story_shares_helper.rb +++ b/app/helpers/story_shares_helper.rb @@ -18,6 +18,8 @@ module StorySharesHelper STORY_IMAGE_ASPECT = "aspect-[3/2]".freeze STORY_BANNER_ASPECT = "aspect-[4/3] sm:aspect-[2/1]".freeze + # Zooms a 16:9 player until its letterboxed video covers the banner box at each breakpoint. + STORY_BANNER_VIDEO_SCALE = "scale-[1.34] sm:scale-[1.125]".freeze STORY_IMAGE_GUIDANCE = "Images look best as landscape photos in a 3:2 shape, at least 1800 × 1200 pixels. " \ "A recent phone photo taken sideways works well. Tall images show in full on a blurred background. " \ "Some spots trim the edges, so keep the main subject near the center.".freeze diff --git a/app/helpers/youtube_helper.rb b/app/helpers/youtube_helper.rb index d09c2a31e8..24956cf1f0 100644 --- a/app/helpers/youtube_helper.rb +++ b/app/helpers/youtube_helper.rb @@ -1,19 +1,32 @@ module YoutubeHelper + BACKGROUND_PLAYER_PARAMS = { autoplay: 1, mute: 1, loop: 1, controls: 0, playsinline: 1, disablekb: 1, rel: 0 }.freeze + def youtube_embed_url(url) + video_id = youtube_video_id(url) + return unless video_id + + "https://www.youtube.com/embed/#{video_id}" + end + + # Muted autoplay is the only autoplay browsers allow; loop needs the video listed as its own playlist. + def youtube_background_embed_url(url) + video_id = youtube_video_id(url) + return unless video_id + + "https://www.youtube.com/embed/#{ERB::Util.url_encode(video_id)}?#{BACKGROUND_PLAYER_PARAMS.merge(playlist: video_id).to_query}" + end + + def youtube_video_id(url) return if url.blank? uri = URI.parse(url) rescue nil return unless uri - if uri.host&.include?("youtu.be") - video_id = uri.path.delete_prefix("/") + video_id = if uri.host&.include?("youtu.be") + uri.path.delete_prefix("/") elsif uri.query - params = Rack::Utils.parse_query(uri.query) - video_id = params["v"] + Rack::Utils.parse_query(uri.query)["v"] end - - return unless video_id - - "https://www.youtube.com/embed/#{video_id}" + video_id.presence end end diff --git a/app/views/story_shares/_banner_video.html.erb b/app/views/story_shares/_banner_video.html.erb new file mode 100644 index 0000000000..d83319483a --- /dev/null +++ b/app/views/story_shares/_banner_video.html.erb @@ -0,0 +1,9 @@ +<%# Muted, looping YouTube player covering a banner box, decorative so the slide link and swipe stay live. + Locals: story. %> +<% if (embed_url = youtube_background_embed_url(story.youtube_url)) %> + +<% end %> diff --git a/app/views/story_shares/_browse.html.erb b/app/views/story_shares/_browse.html.erb index 58542bd380..0c58cfa2d2 100644 --- a/app/views/story_shares/_browse.html.erb +++ b/app/views/story_shares/_browse.html.erb @@ -42,7 +42,7 @@ <% @lead_stories.each do |story| %>
<%= link_to story_share_path(story), class: "block relative" do %> - <%= render "story_image", story: story, aspect: StorySharesHelper::STORY_BANNER_ASPECT %> + <%= render "story_image", story: story, aspect: StorySharesHelper::STORY_BANNER_ASPECT, video: true %>

<%= story.title %>

diff --git a/app/views/story_shares/_home.html.erb b/app/views/story_shares/_home.html.erb index e1a9912b8a..af9bcbdc2d 100644 --- a/app/views/story_shares/_home.html.erb +++ b/app/views/story_shares/_home.html.erb @@ -41,6 +41,7 @@ <%= link_to story_share_path(story), class: "block relative" do %>

<%= render "assets/fitted_image", file: story.display_image, alt: story.title %> + <%= render "story_shares/banner_video", story: story %>
<%= render "story_shares/primary_tag_pills", story: story, pad: "px-4 py-1.5", wrapper_class: "absolute top-5 left-5 right-5 z-10" %> diff --git a/app/views/story_shares/_story_image.html.erb b/app/views/story_shares/_story_image.html.erb index 0b8a6109eb..e208103c38 100644 --- a/app/views/story_shares/_story_image.html.erb +++ b/app/views/story_shares/_story_image.html.erb @@ -1,12 +1,15 @@ <%# Full-bleed story image with the primary-tag pills and a play badge for video stories. - Locals: story; aspect (default STORY_IMAGE_ASPECT); pills: false to omit the tag pills. %> + Locals: story; aspect (default STORY_IMAGE_ASPECT); pills: false to omit the tag pills; + video (plays the story's video over the image; banner aspect only). %> <% aspect = local_assigns.fetch(:aspect, StorySharesHelper::STORY_IMAGE_ASPECT) %> +<% video = local_assigns[:video] && youtube_video_id(story.youtube_url) %>
<%= render "assets/fitted_image", file: story.display_image, alt: story.title, loading: "lazy" %> + <%= render "story_shares/banner_video", story: story if video %> <% if local_assigns.fetch(:pills, true) %> <%= render "story_shares/primary_tag_pills", story: story, wrapper_class: "absolute top-3 left-3 right-3" %> <% end %> - <% if story.youtube_url.present? %> + <% if story.youtube_url.present? && !video %>
diff --git a/db/seeds/features/2026-10-22-story-videos-play-in-story-share-carousels.yml b/db/seeds/features/2026-10-22-story-videos-play-in-story-share-carousels.yml new file mode 100644 index 0000000000..eefa45caf4 --- /dev/null +++ b/db/seeds/features/2026-10-22-story-videos-play-in-story-share-carousels.yml @@ -0,0 +1,12 @@ +name: "Story videos play in the Story Share carousels" +area: stories +display_status: public_facing +released_on: 2026-10-22 +action_path: "/story_share" +pr_number: 2789 +summary: >- + A story with a YouTube link now plays its video, muted and looping, in place of + its photo in the Story Share home and browse carousels. +pro_tips: + - "Clicking the video opens the full story, where it plays with sound." + - "Paste a regular video link (youtube.com/watch?v=… or youtu.be/…); a channel link shows the photo instead." diff --git a/spec/helpers/youtube_helper_spec.rb b/spec/helpers/youtube_helper_spec.rb new file mode 100644 index 0000000000..eb6b873c08 --- /dev/null +++ b/spec/helpers/youtube_helper_spec.rb @@ -0,0 +1,39 @@ +require "rails_helper" + +RSpec.describe YoutubeHelper, type: :helper do + describe "#youtube_video_id" do + it "reads the id from watch and short links" do + expect(helper.youtube_video_id("https://www.youtube.com/watch?v=abc123")).to eq("abc123") + expect(helper.youtube_video_id("https://youtu.be/abc123")).to eq("abc123") + end + + it "is nil for a blank, unparseable, or id-less URL" do + expect(helper.youtube_video_id(nil)).to be_nil + expect(helper.youtube_video_id("not a url")).to be_nil + expect(helper.youtube_video_id("https://www.youtube.com/@awbworg")).to be_nil + expect(helper.youtube_video_id("https://youtu.be/")).to be_nil + end + end + + describe "#youtube_background_embed_url" do + it "builds a muted, looping, controls-free autoplay embed" do + url = helper.youtube_background_embed_url("https://www.youtube.com/watch?v=abc123") + uri = URI.parse(url) + + expect("#{uri.host}#{uri.path}").to eq("www.youtube.com/embed/abc123") + expect(Rack::Utils.parse_query(uri.query)).to include( + "autoplay" => "1", "mute" => "1", "loop" => "1", "playlist" => "abc123", "controls" => "0", "playsinline" => "1" + ) + end + + it "keeps a crafted id from adding its own player params" do + url = helper.youtube_background_embed_url("https://www.youtube.com/watch?v=abc%26mute%3D0") + + expect(Rack::Utils.parse_query(URI.parse(url).query)["mute"]).to eq("1") + end + + it "is nil without a video id" do + expect(helper.youtube_background_embed_url("https://www.youtube.com/@awbworg")).to be_nil + end + end +end diff --git a/spec/requests/story_share_spec.rb b/spec/requests/story_share_spec.rb index ddd8ce504d..71842c4c30 100644 --- a/spec/requests/story_share_spec.rb +++ b/spec/requests/story_share_spec.rb @@ -756,6 +756,62 @@ def carousel_slide(story) end end + # ========================================================== + # VIDEO STORIES IN CAROUSELS + # ========================================================== + describe "video stories in carousels" do + let(:video_url) { "https://www.youtube.com/watch?v=abc123" } + let(:player) { "iframe[src^='https://www.youtube.com/embed/abc123?'][src*='mute=1'][aria-hidden='true']" } + + def carousel_slide(story) + Capybara.string(response.body).find(".swiper-slide", text: story.title) + end + + it "plays a story's video muted in place of its image on the home carousel" do + video_story = create(:story, :published, :publicly_visible, title: "Video story", youtube_url: video_url, story_share_carousel_position: 1) + photo_story = create(:story, :published, :publicly_visible, title: "Photo story", story_share_carousel_position: 2) + + get story_shares_path + + expect(carousel_slide(video_story)).to have_css(player) + expect(carousel_slide(video_story)).to have_css("a[href='#{story_share_path(video_story)}'] #{player}") + expect(carousel_slide(photo_story)).to have_no_css("iframe") + end + + it "plays a story's video in place of its play badge on the browse carousel" do + create(:story, :published, :publicly_visible, title: "Video story", youtube_url: video_url) + create(:story, :published, :publicly_visible, title: "Photo story", created_at: 1.day.ago) + + get story_shares_path(query: "story") + + slide = carousel_slide(Story.find_by(title: "Video story")) + expect(slide).to have_css(player) + expect(slide).to have_no_css(".fa-play") + end + + it "keeps the play badge, not a player, on browse cards" do + sector = create(:sector, :published, name: "Homelessness", story_share_position: 1) + card_story = create(:story, :published, :publicly_visible, title: "Video card story", youtube_url: video_url, created_at: 2.days.ago) + lead = create(:story, :published, :publicly_visible, title: "Newer lead story") + [ card_story, lead ].each { |s| s.sectorable_items.create!(sector: sector) } + + get story_shares_path(sector_names_all: "Homelessness") + + card = Capybara.string(response.body).find("article", text: "Video card story") + expect(card).to have_css(".fa-play") + expect(card).to have_no_css("iframe") + end + + it "falls back to the image when the YouTube link has no video id" do + story = create(:story, :published, :publicly_visible, title: "Channel link story", + youtube_url: "https://www.youtube.com/@awbworg", story_share_carousel_position: 1) + + get story_shares_path + + expect(carousel_slide(story)).to have_no_css("iframe") + end + end + # ========================================================== # "What others are reading" — most-read by Ahoy view count # ========================================================== From 8cf47d27999394ea8b1b5582b6a04f7d43825ef6 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 07:28:53 -0400 Subject: [PATCH 3/8] Star the browsed sector in the video browse-card spec Main now fills the browse grid only with stories starring the browsed tag as primary, so the spec has to star it to land a card there. Co-Authored-By: Claude Opus 5.5 (1M context) --- spec/requests/story_share_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/story_share_spec.rb b/spec/requests/story_share_spec.rb index 71842c4c30..b83dd511f5 100644 --- a/spec/requests/story_share_spec.rb +++ b/spec/requests/story_share_spec.rb @@ -793,7 +793,7 @@ def carousel_slide(story) sector = create(:sector, :published, name: "Homelessness", story_share_position: 1) card_story = create(:story, :published, :publicly_visible, title: "Video card story", youtube_url: video_url, created_at: 2.days.ago) lead = create(:story, :published, :publicly_visible, title: "Newer lead story") - [ card_story, lead ].each { |s| s.sectorable_items.create!(sector: sector) } + [ card_story, lead ].each { |s| s.sectorable_items.create!(sector: sector, is_primary: true) } get story_shares_path(sector_names_all: "Homelessness") From 89bf2305de546f744ec4b947c2bc1afdfb7e76fb Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 07:30:39 -0400 Subject: [PATCH 4/8] Seed home-carousel stories with a photo and a video The hand-picked home carousel stories had neither, so the new video slides couldn't be seen in dev. Odd slides get both; even slides stay photo-only so the carousel mixes the two. Co-Authored-By: Claude Opus 5.5 (1M context) --- db/seeds/dev/story_share_content.rb | 35 ++++++++++++++++++----------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/db/seeds/dev/story_share_content.rb b/db/seeds/dev/story_share_content.rb index 6d63a38a69..d0ceee078c 100644 --- a/db/seeds/dev/story_share_content.rb +++ b/db/seeds/dev/story_share_content.rb @@ -133,6 +133,18 @@ { title: "When Art Opens a Door", sectors: [ "Grief/Loss" ], audiences: [ "Self" ], spotlight: true } ] story_images = %w[who-we-are-grid.jpg what-we-do.jpg ways-to-help.jpg our-next-chapter.jpg workshop_default.jpg] +demo_video_url = "https://youtube.com/watch?v=dQw4w9WgXcQ" + +attach_primary_image = lambda do |story, filename| + asset = story.primary_asset + next if asset&.file&.attached? + + image_path = Rails.root.join("app/assets/images", filename) + next unless File.exist?(image_path) + asset ||= story.assets.build(type: "PrimaryAsset") + asset.file.attach(io: File.open(image_path), filename: filename, content_type: "image/jpeg") + asset.save! +end story_seeds.each_with_index do |seed, i| body_content = Faker::Lorem.paragraph(sentence_count: 10) @@ -144,7 +156,7 @@ story_workshops_attributes: [ { workshop_id: [ nil, Workshop.all.sample&.id ].sample } ], windows_type_id: WindowsType.all.sample&.id, spotlighted_facilitator_id: (Person.all.sample&.id if seed[:spotlight]), - youtube_url: (seed[:youtube] ? "https://youtube.com/watch?v=dQw4w9WgXcQ" : nil), + youtube_url: (demo_video_url if seed[:youtube]), created_by_id: User.first&.id, updated_by_id: User.first&.id, created_at: Time.current - (i * 7).days, @@ -157,7 +169,7 @@ story.update!( published: true, publicly_visible: true, publicly_featured: i < 5, featured: i < 5, spotlighted_facilitator_id: story.spotlighted_facilitator_id || (Person.all.sample&.id if seed[:spotlight]), - youtube_url: seed[:youtube] ? "https://youtube.com/watch?v=dQw4w9WgXcQ" : story.youtube_url + youtube_url: seed[:youtube] ? demo_video_url : story.youtube_url ) seed[:sectors].each do |name| @@ -170,14 +182,7 @@ end next if i >= story_images.length # last two stay image-less on purpose - asset = story.primary_asset - next if asset&.file&.attached? - - image_path = Rails.root.join("app/assets/images", story_images[i]) - next unless File.exist?(image_path) - asset ||= story.assets.build(type: "PrimaryAsset") - asset.file.attach(io: File.open(image_path), filename: story_images[i], content_type: "image/jpeg") - asset.save! + attach_primary_image.call(story, story_images[i]) end # Bulk stories so the Story Share portal, admin index, pagination, and the sector/ @@ -216,8 +221,10 @@ body_content = Faker::Lorem.paragraph(sentence_count: rand(6..14)) has_spotlight = n % 9 == 0 - has_youtube = n % 7 == 0 is_public = n % 4 != 0 + in_carousel = is_public && n < 6 + # Odd carousel slides carry a video over their photo so the home carousel mixes both. + has_youtube = n % 7 == 0 || (in_carousel && n.odd?) is_featured = n % 11 == 0 has_author = bulk_people.any? && n % 3 != 0 created = Time.current - rand(1..900).days @@ -231,7 +238,7 @@ windows_type_id: bulk_windows_types.sample&.id, spotlighted_facilitator_id: (bulk_people.sample&.id if has_spotlight), author_id: (bulk_people.sample&.id if has_author), - youtube_url: (has_youtube ? "https://youtube.com/watch?v=dQw4w9WgXcQ" : nil), + youtube_url: (demo_video_url if has_youtube), created_by_id: seed_user&.id, updated_by_id: seed_user&.id, created_at: created, @@ -240,8 +247,10 @@ publicly_visible: is_public, publicly_featured: is_public && is_featured, featured: is_featured, - story_share_carousel_position: (n + 1 if is_public && n < 6) + story_share_carousel_position: (n + 1 if in_carousel) ) + story.update!(youtube_url: demo_video_url) if has_youtube && story.youtube_url.blank? + attach_primary_image.call(story, story_images[n % story_images.length]) if in_carousel bulk_sectors.sample(rand(1..2)).each do |sector| story.sectorable_items.find_or_create_by!(sector: sector) From e0fb50b3846ce2ff9144276ecf468e79d585c651 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 07:36:25 -0400 Subject: [PATCH 5/8] Lead the story page carousel with the story's video The video sat below the story text, easy to miss. It now opens the image carousel at the top of both the public and admin story pages. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../assets/_display_assets_carousel.html.erb | 16 ++++++++++-- app/views/stories/show.html.erb | 3 ++- app/views/story_shares/show.html.erb | 13 +++------- ...y-videos-play-in-story-share-carousels.yml | 5 ++-- spec/requests/stories_spec.rb | 11 ++++++++ spec/requests/story_share_spec.rb | 26 +++++++++++++++++++ 6 files changed, 59 insertions(+), 15 deletions(-) diff --git a/app/views/assets/_display_assets_carousel.html.erb b/app/views/assets/_display_assets_carousel.html.erb index 166170d95f..38c439b49f 100644 --- a/app/views/assets/_display_assets_carousel.html.erb +++ b/app/views/assets/_display_assets_carousel.html.erb @@ -1,15 +1,27 @@ <%# Renders a resource's primary + gallery images as one carousel of uniform-size slides. %> <%# Every image is forced into the same fixed aspect box, so there is no hero/thumbnail size split. %> +<%# Optional locals: video_url (YouTube embed URL, shown as the first slide); video_title. %> <% + video_url = local_assigns[:video_url] assets = [] assets << resource.primary_asset if resource.respond_to?(:primary_asset) && resource.primary_asset&.file&.attached? assets += resource.gallery_assets.select { |asset| asset.file.attached? } if resource.respond_to?(:gallery_assets) %> -<% if assets.any? %> +<% slide_count = assets.size + (video_url ? 1 : 0) %> +<% if slide_count.positive? %>
+ <% if video_url %> +
+
+ +
+
+ <% end %> <% assets.each_with_index do |asset, idx| %>
<% portrait = portrait_image?(asset.file) %> @@ -25,7 +37,7 @@ <% end %>
- <% if assets.size > 1 %> + <% if slide_count > 1 %>
- <%= render "assets/display_assets_carousel", resource: @story %> + <%= render "assets/display_assets_carousel", resource: @story, + video_url: youtube_embed_url(@story.youtube_url), video_title: "#{@story.title} video" %>
<%= render "assets/primary_image_picker", owner: @story %>
diff --git a/app/views/story_shares/show.html.erb b/app/views/story_shares/show.html.erb index d0d5fdecb3..631007afbe 100644 --- a/app/views/story_shares/show.html.erb +++ b/app/views/story_shares/show.html.erb @@ -57,22 +57,15 @@ <%= render "tag_chips", story: @story %> - - <%= render "assets/display_assets_carousel", resource: @story %> + + <%= render "assets/display_assets_carousel", resource: @story, + video_url: youtube_embed_url(@story.youtube_url), video_title: "#{@story.title} video" %>
<%= @story.rhino_body %>
- - <% if (embed_url = youtube_embed_url(@story.youtube_url)) %> -
- -
- <% end %> -
<%= render "share_buttons", story: @story %> diff --git a/db/seeds/features/2026-10-22-story-videos-play-in-story-share-carousels.yml b/db/seeds/features/2026-10-22-story-videos-play-in-story-share-carousels.yml index eefa45caf4..a1750f19c1 100644 --- a/db/seeds/features/2026-10-22-story-videos-play-in-story-share-carousels.yml +++ b/db/seeds/features/2026-10-22-story-videos-play-in-story-share-carousels.yml @@ -6,7 +6,8 @@ action_path: "/story_share" pr_number: 2789 summary: >- A story with a YouTube link now plays its video, muted and looping, in place of - its photo in the Story Share home and browse carousels. + its photo in the Story Share home and browse carousels. On the story itself, the + video leads the carousel at the top, followed by the story's images. pro_tips: - - "Clicking the video opens the full story, where it plays with sound." + - "Clicking a carousel video opens the full story, where it plays with sound." - "Paste a regular video link (youtube.com/watch?v=… or youtu.be/…); a channel link shows the photo instead." diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb index 53d35ca07a..33f2460413 100644 --- a/spec/requests/stories_spec.rb +++ b/spec/requests/stories_spec.rb @@ -349,6 +349,17 @@ def tag_primary(story, sector_name:, audience_name:) get story_url(private_story) expect(response).to have_http_status(:ok) end + + it "leads the image carousel with the story's video" do + private_story.update!(youtube_url: "https://youtu.be/abc123") + create(:primary_asset, owner: private_story).file.attach(io: StringIO.new("image"), filename: "art.png", content_type: "image/png") + + get story_url(private_story) + + slides = Capybara.string(response.body).all("#hero-image .swiper-slide") + expect(slides.size).to eq(2) + expect(slides.first).to have_css("iframe[src='https://www.youtube.com/embed/abc123']") + end end describe "GET /edit" do diff --git a/spec/requests/story_share_spec.rb b/spec/requests/story_share_spec.rb index b83dd511f5..2ae2ad91e1 100644 --- a/spec/requests/story_share_spec.rb +++ b/spec/requests/story_share_spec.rb @@ -810,6 +810,32 @@ def carousel_slide(story) expect(carousel_slide(story)).to have_no_css("iframe") end + + it "leads the story page carousel with the video, then the story's images" do + story = create(:story, :published, :publicly_visible, youtube_url: video_url) + [ :primary_asset, :gallery_asset ].each do |factory| + create(factory, owner: story).file.attach(io: StringIO.new("image"), filename: "art.png", content_type: "image/png") + end + + get story_share_path(story) + + page = Capybara.string(response.body) + slides = page.all(".swiper-slide") + expect(slides.size).to eq(3) + expect(slides.first).to have_css("iframe[src='https://www.youtube.com/embed/abc123'][allowfullscreen]") + expect(slides.drop(1)).to all(have_css("img")) + expect(page).to have_css("iframe", count: 1) + end + + it "shows a video-only story as a single slide without arrows" do + story = create(:story, :published, :publicly_visible, youtube_url: video_url) + + get story_share_path(story) + + page = Capybara.string(response.body) + expect(page).to have_css(".swiper-slide iframe[src='https://www.youtube.com/embed/abc123']") + expect(page).to have_no_css(".swiper-button-next-custom") + end end # ========================================================== From ba40bbac9a82fa1cc87012befdf01129b84b1a87 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 07:39:11 -0400 Subject: [PATCH 6/8] Pause the story video when the carousel moves on Playing the video and then flipping to an image left the sound running off-screen. The carousel now tells YouTube to pause on every slide change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../javascript/controllers/carousel_controller.js | 7 +++++++ app/helpers/youtube_helper.rb | 6 ++++-- app/views/assets/_display_assets_carousel.html.erb | 3 ++- app/views/stories/show.html.erb | 2 +- app/views/story_shares/show.html.erb | 2 +- spec/helpers/youtube_helper_spec.rb | 10 ++++++++++ spec/requests/stories_spec.rb | 2 +- spec/requests/story_share_spec.rb | 4 ++-- 8 files changed, 28 insertions(+), 8 deletions(-) diff --git a/app/frontend/javascript/controllers/carousel_controller.js b/app/frontend/javascript/controllers/carousel_controller.js index 0acae43efa..4056ba6f96 100644 --- a/app/frontend/javascript/controllers/carousel_controller.js +++ b/app/frontend/javascript/controllers/carousel_controller.js @@ -9,6 +9,7 @@ import 'swiper/css/navigation' // Pass Swiper overrides via data-carousel-options-value='{ "slidesPerView": 2, ... }'. // Any keys provided there win over the defaults below (breakpoints replace wholesale). export default class extends Controller { + static targets = ["video"] static values = { options: Object } connect() { @@ -40,6 +41,12 @@ export default class extends Controller { } this.swiper = new Swiper(this.element, { ...defaults, ...this.optionsValue }) + this.swiper.on("slideChange", () => this.pauseVideos()) + } + + pauseVideos() { + const command = JSON.stringify({ event: "command", func: "pauseVideo", args: [] }) + this.videoTargets.forEach((video) => video.contentWindow?.postMessage(command, "https://www.youtube.com")) } disconnect() { diff --git a/app/helpers/youtube_helper.rb b/app/helpers/youtube_helper.rb index 24956cf1f0..79460700ca 100644 --- a/app/helpers/youtube_helper.rb +++ b/app/helpers/youtube_helper.rb @@ -1,11 +1,13 @@ module YoutubeHelper BACKGROUND_PLAYER_PARAMS = { autoplay: 1, mute: 1, loop: 1, controls: 0, playsinline: 1, disablekb: 1, rel: 0 }.freeze - def youtube_embed_url(url) + # js_api lets the page send the player commands (e.g. pause) via postMessage. + def youtube_embed_url(url, js_api: false) video_id = youtube_video_id(url) return unless video_id - "https://www.youtube.com/embed/#{video_id}" + embed_url = "https://www.youtube.com/embed/#{video_id}" + js_api ? "#{embed_url}?enablejsapi=1" : embed_url end # Muted autoplay is the only autoplay browsers allow; loop needs the video listed as its own playlist. diff --git a/app/views/assets/_display_assets_carousel.html.erb b/app/views/assets/_display_assets_carousel.html.erb index 38c439b49f..7ff8e30e75 100644 --- a/app/views/assets/_display_assets_carousel.html.erb +++ b/app/views/assets/_display_assets_carousel.html.erb @@ -1,6 +1,6 @@ <%# Renders a resource's primary + gallery images as one carousel of uniform-size slides. %> <%# Every image is forced into the same fixed aspect box, so there is no hero/thumbnail size split. %> -<%# Optional locals: video_url (YouTube embed URL, shown as the first slide); video_title. %> +<%# Optional locals: video_url (YouTube embed URL with js_api, shown as the first slide); video_title. %> <% video_url = local_assigns[:video_url] assets = [] @@ -18,6 +18,7 @@
diff --git a/app/views/stories/show.html.erb b/app/views/stories/show.html.erb index 546e9d25fe..18d4deb6a2 100644 --- a/app/views/stories/show.html.erb +++ b/app/views/stories/show.html.erb @@ -52,7 +52,7 @@
<%= render "assets/display_assets_carousel", resource: @story, - video_url: youtube_embed_url(@story.youtube_url), video_title: "#{@story.title} video" %> + video_url: youtube_embed_url(@story.youtube_url, js_api: true), video_title: "#{@story.title} video" %>
<%= render "assets/primary_image_picker", owner: @story %>
diff --git a/app/views/story_shares/show.html.erb b/app/views/story_shares/show.html.erb index 631007afbe..814487f7ba 100644 --- a/app/views/story_shares/show.html.erb +++ b/app/views/story_shares/show.html.erb @@ -59,7 +59,7 @@ <%= render "assets/display_assets_carousel", resource: @story, - video_url: youtube_embed_url(@story.youtube_url), video_title: "#{@story.title} video" %> + video_url: youtube_embed_url(@story.youtube_url, js_api: true), video_title: "#{@story.title} video" %>
diff --git a/spec/helpers/youtube_helper_spec.rb b/spec/helpers/youtube_helper_spec.rb index eb6b873c08..a4ac8b2663 100644 --- a/spec/helpers/youtube_helper_spec.rb +++ b/spec/helpers/youtube_helper_spec.rb @@ -15,6 +15,16 @@ end end + describe "#youtube_embed_url" do + it "builds a plain embed URL" do + expect(helper.youtube_embed_url("https://youtu.be/abc123")).to eq("https://www.youtube.com/embed/abc123") + end + + it "turns on the player's JS API when asked" do + expect(helper.youtube_embed_url("https://youtu.be/abc123", js_api: true)).to eq("https://www.youtube.com/embed/abc123?enablejsapi=1") + end + end + describe "#youtube_background_embed_url" do it "builds a muted, looping, controls-free autoplay embed" do url = helper.youtube_background_embed_url("https://www.youtube.com/watch?v=abc123") diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb index 33f2460413..93eb93eafc 100644 --- a/spec/requests/stories_spec.rb +++ b/spec/requests/stories_spec.rb @@ -358,7 +358,7 @@ def tag_primary(story, sector_name:, audience_name:) slides = Capybara.string(response.body).all("#hero-image .swiper-slide") expect(slides.size).to eq(2) - expect(slides.first).to have_css("iframe[src='https://www.youtube.com/embed/abc123']") + expect(slides.first).to have_css("iframe[src='https://www.youtube.com/embed/abc123?enablejsapi=1']") end end diff --git a/spec/requests/story_share_spec.rb b/spec/requests/story_share_spec.rb index 2ae2ad91e1..c049091829 100644 --- a/spec/requests/story_share_spec.rb +++ b/spec/requests/story_share_spec.rb @@ -822,7 +822,7 @@ def carousel_slide(story) page = Capybara.string(response.body) slides = page.all(".swiper-slide") expect(slides.size).to eq(3) - expect(slides.first).to have_css("iframe[src='https://www.youtube.com/embed/abc123'][allowfullscreen]") + expect(slides.first).to have_css("iframe[src='https://www.youtube.com/embed/abc123?enablejsapi=1'][data-carousel-target='video']") expect(slides.drop(1)).to all(have_css("img")) expect(page).to have_css("iframe", count: 1) end @@ -833,7 +833,7 @@ def carousel_slide(story) get story_share_path(story) page = Capybara.string(response.body) - expect(page).to have_css(".swiper-slide iframe[src='https://www.youtube.com/embed/abc123']") + expect(page).to have_css(".swiper-slide iframe[src='https://www.youtube.com/embed/abc123?enablejsapi=1']") expect(page).to have_no_css(".swiper-button-next-custom") end end From 43fb239e2b3485800b298542970cc2a358279cbd Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 11:18:56 -0400 Subject: [PATCH 7/8] Drive the browse video spec through an audience page Story Share search now lists results without a carousel, so the spec browses an audience whose stories star it as primary. Co-Authored-By: Claude Opus 5.5 (1M context) --- spec/requests/story_share_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/requests/story_share_spec.rb b/spec/requests/story_share_spec.rb index c049091829..f12df74947 100644 --- a/spec/requests/story_share_spec.rb +++ b/spec/requests/story_share_spec.rb @@ -779,10 +779,13 @@ def carousel_slide(story) end it "plays a story's video in place of its play badge on the browse carousel" do + teens = create(:category, :published, name: "Teens", category_type: create(:category_type, name: "AgeRange")) create(:story, :published, :publicly_visible, title: "Video story", youtube_url: video_url) + .categorizable_items.create!(category: teens, is_primary: true) create(:story, :published, :publicly_visible, title: "Photo story", created_at: 1.day.ago) + .categorizable_items.create!(category: teens, is_primary: true) - get story_shares_path(query: "story") + get story_shares_path(category_names_all: "Teens") slide = carousel_slide(Story.find_by(title: "Video story")) expect(slide).to have_css(player) From f6db99bb055d0e2d92782e4452d3192866734634 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sat, 24 Oct 2026 07:06:58 -0400 Subject: [PATCH 8/8] Seed a video story with gallery images and starred tags Story 1 now carries a video plus gallery images so the story page carousel can be tried on /stories/1 and /story_share/1, and curated stories star their first sector and audience so audience pages get a carousel with video stories in it. Co-Authored-By: Claude Opus 5.5 (1M context) --- db/seeds/dev/story_share_content.rb | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/db/seeds/dev/story_share_content.rb b/db/seeds/dev/story_share_content.rb index d0ceee078c..e685ab0089 100644 --- a/db/seeds/dev/story_share_content.rb +++ b/db/seeds/dev/story_share_content.rb @@ -119,9 +119,11 @@ # publicly visible, the first five are publicly featured, sectors/audiences are # tagged (with ≥3 public stories in each featured sector), a few spotlight a # facilitator (for person profiles), and most get a primary image (last two -# exercise the fallback). +# exercise the fallback). Each story stars its first sector and audience as primary, +# so the audience pages' carousels have highlights. The first story (id 1 on a fresh +# DB) carries a video plus gallery images to exercise the story page carousel. story_seeds = [ - { title: "Healing Through Art: A Survivor's Journey", sectors: [ "Domestic Violence" ], audiences: [ "Adults" ] }, + { title: "Healing Through Art: A Survivor's Journey", sectors: [ "Domestic Violence" ], audiences: [ "Adults" ], youtube: true, gallery: true }, { title: "Finding Strength in Creativity", sectors: [ "Domestic Violence", "Self-Care/Personal Growth" ], audiences: [ "Adults", "Families" ] }, { title: "A Workshop Moment That Changed Everything", sectors: [ "Racial/Social Justice" ], audiences: [ "Teens" ] }, { title: "From Silence to Expression", sectors: [ "Domestic Violence" ], audiences: [ "Adults" ] }, @@ -172,17 +174,26 @@ youtube_url: seed[:youtube] ? demo_video_url : story.youtube_url ) - seed[:sectors].each do |name| + seed[:sectors].each_with_index do |name, position| sector = Sector.find_by(name: name) or next - story.sectorable_items.find_or_create_by!(sector: sector) + story.sectorable_items.find_or_initialize_by(sector: sector).update!(is_primary: position.zero?) end - seed[:audiences].each do |name| + seed[:audiences].each_with_index do |name, position| category = Category.where("LOWER(name) = LOWER(?)", name).first or next - story.categorizable_items.find_or_create_by!(category: category) + story.categorizable_items.find_or_initialize_by(category: category).update!(is_primary: position.zero?) end next if i >= story_images.length # last two stay image-less on purpose attach_primary_image.call(story, story_images[i]) + next unless seed[:gallery] && story.gallery_assets.none? + + story_images.excluding(story_images[i]).first(2).each do |filename| + image_path = Rails.root.join("app/assets/images", filename) + next unless File.exist?(image_path) + asset = story.assets.build(type: "GalleryAsset") + asset.file.attach(io: File.open(image_path), filename: filename, content_type: "image/jpeg") + asset.save! + end end # Bulk stories so the Story Share portal, admin index, pagination, and the sector/