Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/frontend/javascript/controllers/carousel_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions app/helpers/story_shares_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 24 additions & 9 deletions app/helpers/youtube_helper.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 15 additions & 2 deletions app/views/assets/_display_assets_carousel.html.erb
Original file line number Diff line number Diff line change
@@ -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? %>
<div data-controller="carousel"
class="relative swiper my-6 w-full"
data-carousel-options-value='{ "spaceBetween": 16, "slidesPerView": 1, "breakpoints": {} }'>
<div class="swiper-wrapper">
<% if video_url %>
<div class="swiper-slide">
<div class="relative aspect-[3/2] overflow-hidden rounded-lg bg-black">
<iframe src="<%= video_url %>" title="<%= local_assigns[:video_title] %>"
class="absolute inset-0 h-full w-full"
data-carousel-target="video"
allow="encrypted-media; picture-in-picture" allowfullscreen></iframe>
</div>
</div>
<% end %>
<% assets.each_with_index do |asset, idx| %>
<div class="swiper-slide">
<% portrait = portrait_image?(asset.file) %>
Expand All @@ -25,7 +38,7 @@
<% end %>
</div>

<% if assets.size > 1 %>
<% if slide_count > 1 %>
<button class="swiper-button-next-custom absolute top-1/2 -translate-y-1/2 right-2 w-12 h-12 rounded-full bg-white border border-gray-300 text-gray-700 shadow-md hover:shadow-lg flex items-center justify-center transition hover:bg-gray-50 z-40"
aria-label="Next">
<i class="fa-solid fa-chevron-right text-xl"></i>
Expand Down
3 changes: 2 additions & 1 deletion app/views/stories/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
<div id="upload-progress-bar" class="h-full w-0 bg-blue-500 transition-[width] duration-200 ease-out"></div>
</div>
<div id="hero-image" class="relative">
<%= 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" %>
<div class="absolute top-8 right-2 z-40">
<%= render "assets/primary_image_picker", owner: @story %>
</div>
Expand Down
9 changes: 9 additions & 0 deletions app/views/story_shares/_banner_video.html.erb
Original file line number Diff line number Diff line change
@@ -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)) %>
<iframe src="<%= embed_url %>"
title="<%= story.title %> video"
class="pointer-events-none absolute inset-0 h-full w-full <%= StorySharesHelper::STORY_BANNER_VIDEO_SCALE %>"
allow="autoplay; encrypted-media; picture-in-picture"
tabindex="-1" aria-hidden="true"></iframe>
<% end %>
2 changes: 1 addition & 1 deletion app/views/story_shares/_browse.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
<% @lead_stories.each do |story| %>
<div class="swiper-slide">
<%= 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 %>
<div class="from-brand-navy-950/85 absolute right-0 bottom-0 left-0 bg-gradient-to-t to-transparent p-6 text-white">
<h2 class="mb-1 font-display text-2xl"><%= story.title %></h2>
<p class="text-sm opacity-90">
Expand Down
1 change: 1 addition & 0 deletions app/views/story_shares/_home.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<%= link_to story_share_path(story), class: "block relative" do %>
<div class="bg-brand-navy-100 relative overflow-hidden <%= StorySharesHelper::STORY_BANNER_ASPECT %>">
<%= render "assets/fitted_image", file: story.display_image, alt: story.title %>
<%= render "story_shares/banner_video", story: story %>
</div>
<%= 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" %>
Expand Down
7 changes: 5 additions & 2 deletions app/views/story_shares/_story_image.html.erb
Original file line number Diff line number Diff line change
@@ -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) %>
<div class="relative <%= aspect %> overflow-hidden bg-gray-200">
<%= 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 %>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-white/70">
<i class="fa-solid fa-play ml-1 text-lg text-gray-800"></i>
Expand Down
13 changes: 3 additions & 10 deletions app/views/story_shares/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,15 @@

<%= render "tag_chips", story: @story %>

<!-- Story images -->
<%= render "assets/display_assets_carousel", resource: @story %>
<!-- Story video + images -->
<%= render "assets/display_assets_carousel", resource: @story,
video_url: youtube_embed_url(@story.youtube_url, js_api: true), video_title: "#{@story.title} video" %>

<!-- Body -->
<div class="mb-10 max-w-none prose prose-lg text-gray-700">
<%= @story.rhino_body %>
</div>

<!-- YouTube embed -->
<% if (embed_url = youtube_embed_url(@story.youtube_url)) %>
<div class="mb-10 aspect-video">
<iframe src="<%= embed_url %>" class="h-full w-full rounded-lg"
title="<%= @story.title %> video" allowfullscreen loading="lazy"></iframe>
</div>
<% end %>

<!-- Share -->
<div class="mb-8 border-t border-gray-200 pt-6">
<%= render "share_buttons", story: @story %>
Expand Down
56 changes: 38 additions & 18 deletions db/seeds/dev/story_share_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" ] },
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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/
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
49 changes: 49 additions & 0 deletions spec/helpers/youtube_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions spec/requests/stories_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading