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/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..79460700ca 100644 --- a/app/helpers/youtube_helper.rb +++ b/app/helpers/youtube_helper.rb @@ -1,19 +1,34 @@ module YoutubeHelper - def youtube_embed_url(url) + BACKGROUND_PLAYER_PARAMS = { autoplay: 1, mute: 1, loop: 1, controls: 0, playsinline: 1, disablekb: 1, rel: 0 }.freeze + + # 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 + + 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. + 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/assets/_display_assets_carousel.html.erb b/app/views/assets/_display_assets_carousel.html.erb index 166170d95f..7ff8e30e75 100644 --- a/app/views/assets/_display_assets_carousel.html.erb +++ b/app/views/assets/_display_assets_carousel.html.erb @@ -1,15 +1,28 @@ <%# 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 with js_api, 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 +38,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, js_api: true), video_title: "#{@story.title} video" %>
<%= render "assets/primary_image_picker", owner: @story %>
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/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 # ==========================================================