diff --git a/app/views/story_shares/show.html.erb b/app/views/story_shares/show.html.erb
index d0d5fdecb3..814487f7ba 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, js_api: true), 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/dev/story_share_content.rb b/db/seeds/dev/story_share_content.rb
index 6d63a38a69..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" ] },
@@ -133,6 +135,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 +158,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,27 +171,29 @@
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|
+ 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
- asset = story.primary_asset
- next if asset&.file&.attached?
+ attach_primary_image.call(story, story_images[i])
+ next unless seed[:gallery] && story.gallery_assets.none?
- 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!
+ 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/
@@ -216,8 +232,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 +249,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 +258,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)
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..a1750f19c1
--- /dev/null
+++ b/db/seeds/features/2026-10-22-story-videos-play-in-story-share-carousels.yml
@@ -0,0 +1,13 @@
+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. On the story itself, the
+ video leads the carousel at the top, followed by the story's images.
+pro_tips:
+ - "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/helpers/youtube_helper_spec.rb b/spec/helpers/youtube_helper_spec.rb
new file mode 100644
index 0000000000..a4ac8b2663
--- /dev/null
+++ b/spec/helpers/youtube_helper_spec.rb
@@ -0,0 +1,49 @@
+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_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")
+ 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/stories_spec.rb b/spec/requests/stories_spec.rb
index 53d35ca07a..93eb93eafc 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?enablejsapi=1']")
+ end
end
describe "GET /edit" do
diff --git a/spec/requests/story_share_spec.rb b/spec/requests/story_share_spec.rb
index ddd8ce504d..f12df74947 100644
--- a/spec/requests/story_share_spec.rb
+++ b/spec/requests/story_share_spec.rb
@@ -756,6 +756,91 @@ 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
+ 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(category_names_all: "Teens")
+
+ 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, is_primary: true) }
+
+ 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
+
+ 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?enablejsapi=1'][data-carousel-target='video']")
+ 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?enablejsapi=1']")
+ expect(page).to have_no_css(".swiper-button-next-custom")
+ end
+ end
+
# ==========================================================
# "What others are reading" — most-read by Ahoy view count
# ==========================================================