diff --git a/Gemfile.lock b/Gemfile.lock index 71c5d4d67..46bb9a01d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -169,7 +169,7 @@ GEM rest-client (>= 2.0.0) cocoon (1.2.15) colorize (0.8.1) - concurrent-ruby (1.3.5) + concurrent-ruby (1.3.4) countable-rails (0.0.1) railties (>= 3.1) crack (0.4.5) diff --git a/app/assets/stylesheets/osem-splash.scss b/app/assets/stylesheets/osem-splash.scss index 35a27dca5..e6e31ebd5 100644 --- a/app/assets/stylesheets/osem-splash.scss +++ b/app/assets/stylesheets/osem-splash.scss @@ -173,6 +173,26 @@ color: white; } } + + #video { + .video-wrapper { + position: relative; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + padding-top: 25px; + height: 0; + max-width: 1080px; + margin: 0 auto; + + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } + } + } } .publicorprivate { diff --git a/app/controllers/admin/splashpages_controller.rb b/app/controllers/admin/splashpages_controller.rb index ad4f5e9bc..f42cb0018 100644 --- a/app/controllers/admin/splashpages_controller.rb +++ b/app/controllers/admin/splashpages_controller.rb @@ -50,7 +50,8 @@ def splashpage_params :include_venue, :include_registrations, :include_tickets, :include_lodgings, :include_sponsors, :include_social_media, - :include_booths, :include_past_editions) + :include_booths, :include_past_editions, + :video_url) end end end diff --git a/app/models/splashpage.rb b/app/models/splashpage.rb index 5cd9ef0d3..c2869a7b7 100644 --- a/app/models/splashpage.rb +++ b/app/models/splashpage.rb @@ -4,4 +4,109 @@ class Splashpage < ApplicationRecord belongs_to :conference has_paper_trail ignore: [:updated_at], meta: { conference_id: :conference_id } + + validates :video_url, + format: { with: URI::regexp(%w(http https)), message: :invalid_url }, + allow_blank: true + + validate :video_url_is_supported_platform, if: :video_url? + + # Configuration for supported video platforms + # To add a new platform: add an entry with patterns (regex) and embed_template + SUPPORTED_PLATFORMS = { + youtube: { + patterns: [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|m\.youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/ + ], + embed_template: 'https://www.youtube.com/embed/%{video_id}', + autoplay_params: 'autoplay=1&loop=1&mute=1&controls=0&disablekb=1&fs=0&iv_load_policy=3&modestbranding=1&playsinline=1' + }, + vimeo: { + patterns: [/vimeo\.com\/(?:video\/)?(\d+)/], + embed_template: 'https://player.vimeo.com/video/%{video_id}', + autoplay_params: 'autoplay=1&muted=1&loop=1&playsinline=1' + }, + peertube: { + patterns: [ + /(https?:\/\/[^\/]+)\/videos\/watch\/([a-zA-Z0-9-]+)/, + /(https?:\/\/[^\/]+)\/w\/([a-zA-Z0-9-]+)/ + ], + embed_template: '%{instance_url}/videos/embed/%{video_id}', + autoplay_params: 'autoplay=1&loop=1' + }, + invidious: { + patterns: [/(https?:\/\/[^\/]+)\/watch\?v=([a-zA-Z0-9_-]{11})/], + embed_template: '%{instance_url}/embed/%{video_id}', + autoplay_params: 'autoplay=1&loop=1' + } + }.freeze + + # Detect platform from video URL + def video_platform + return nil unless video_url.present? + + SUPPORTED_PLATFORMS.find do |platform, config| + config[:patterns].any? { |pattern| video_url.match?(pattern) } + end&.first + end + + # Extract video ID from URL + def video_id + match_data = url_match_data + return nil unless match_data + + # For federated platforms (PeerTube, Invidious), video_id is the second capture + match_data.captures.length > 1 ? match_data[2] : match_data[1] + end + + # Extract instance URL for federated platforms (PeerTube, Invidious) + def video_instance_url + return nil unless [:peertube, :invidious].include?(video_platform) + + match_data = url_match_data + match_data&.captures&.length.to_i > 1 ? match_data[1] : nil + end + + # Generate complete embed URL with autoplay parameters + def video_embed_url + return nil unless video_platform && video_id + + config = SUPPORTED_PLATFORMS[video_platform] + base_url = build_base_url(config) + + "#{base_url}?#{config[:autoplay_params]}" + end + + private + + # Get regex match data for the current video URL + def url_match_data + return nil unless video_url.present? && video_platform.present? + + config = SUPPORTED_PLATFORMS[video_platform] + config[:patterns].each do |pattern| + match = video_url.match(pattern) + return match if match + end + + nil + end + + # Build base embed URL from template + def build_base_url(config) + template = config[:embed_template] + + if [:peertube, :invidious].include?(video_platform) + template % { instance_url: video_instance_url, video_id: video_id } + else + template % { video_id: video_id } + end + end + + # Validation: ensure URL is from a supported platform + def video_url_is_supported_platform + return if video_platform.present? + + errors.add(:video_url, :unsupported_platform) + end end diff --git a/app/views/admin/splashpages/_form.html.haml b/app/views/admin/splashpages/_form.html.haml index 80e128476..d189fd81f 100644 --- a/app/views/admin/splashpages/_form.html.haml +++ b/app/views/admin/splashpages/_form.html.haml @@ -45,6 +45,16 @@ %label = f.check_box :include_social_media Display the social media links? + %h4 + = t('admin.splashpages.form.video.title') + %hr + .form-group + = f.label :video_url + = f.url_field :video_url, + class: 'form-control', + placeholder: t('admin.splashpages.form.video.placeholder') + %p.help-block + = t('admin.splashpages.form.video.help_html').html_safe %h4 Access %hr diff --git a/app/views/conferences/_video.haml b/app/views/conferences/_video.haml new file mode 100644 index 000000000..1e4dfede0 --- /dev/null +++ b/app/views/conferences/_video.haml @@ -0,0 +1,14 @@ +- cache [splashpage, splashpage.video_url, '#splash#video'] do + - if splashpage.video_embed_url.present? + %section#video + .container + .row + .col-md-8.col-md-offset-2 + .video-wrapper + %iframe#video-iframe{ + src: splashpage.video_embed_url, + frameborder: "0", + allowfullscreen: "", + allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share", + title: "Conference Video" + } diff --git a/app/views/conferences/show.html.haml b/app/views/conferences/show.html.haml index 3c3278753..5e25e628d 100644 --- a/app/views/conferences/show.html.haml +++ b/app/views/conferences/show.html.haml @@ -24,6 +24,9 @@ -# header/description = render 'header', conference: @conference, venue: @conference.venue + -# Video section + = render 'video', splashpage: @conference.splashpage + -# calls for content, or program - if @conference.splashpage.include_cfp = render 'call_for_content', conference: @conference, diff --git a/config/locales/en.yml b/config/locales/en.yml index f88786259..41a606429 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -48,3 +48,12 @@ en: nickname: Nickname biography: Biography affiliation: Affiliation + splashpage: + video_url: Video URL + errors: + models: + splashpage: + attributes: + video_url: + invalid_url: must be a valid URL + unsupported_platform: must be from a supported platform (Youtube, Vimeo, Peertube, Invidious) diff --git a/config/locales/es.yml b/config/locales/es.yml index 4c13aaaef..44b7d018e 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -13,3 +13,12 @@ es: nickname: Nickname biography: Biografía affiliation: Afiliación + splashpage: + video_url: URL del vídeo + errors: + models: + splashpage: + attributes: + video_url: + invalid_url: debe ser una URL válida + unsupported_platform: debe ser de una plataforma soportada (Youtube, Vimeo, Peertube, Invidious) diff --git a/config/locales/views/splashpages/en.yml b/config/locales/views/splashpages/en.yml new file mode 100644 index 000000000..525d22567 --- /dev/null +++ b/config/locales/views/splashpages/en.yml @@ -0,0 +1,12 @@ +en: + admin: + splashpages: + form: + video: + title: Video + label: Video URL + placeholder: "https://www.youtube.com/watch?v=..." + help_html: | + Video URL to display on the splash page (will autoplay muted). +
+ Supported: YouTube, Vimeo, PeerTube, Invidious diff --git a/config/locales/views/splashpages/es.yml b/config/locales/views/splashpages/es.yml new file mode 100644 index 000000000..81e22d668 --- /dev/null +++ b/config/locales/views/splashpages/es.yml @@ -0,0 +1,12 @@ +es: + admin: + splashpages: + form: + video: + title: Vídeo + label: URL del vídeo + placeholder: "https://www.youtube.com/watch?v=..." + help_html: | + URL del vídeo para mostrar en la página de inicio (se reproducirá automáticamente silenciado). +
+ Soportados: YouTube, Vimeo, PeerTube, Invidious diff --git a/db/migrate/20260208190303_add_video_url_to_splashpages.rb b/db/migrate/20260208190303_add_video_url_to_splashpages.rb new file mode 100644 index 000000000..b69a875dd --- /dev/null +++ b/db/migrate/20260208190303_add_video_url_to_splashpages.rb @@ -0,0 +1,5 @@ +class AddVideoUrlToSplashpages < ActiveRecord::Migration[7.0] + def change + add_column :splashpages, :video_url, :string + end +end diff --git a/spec/factories/splashpages.rb b/spec/factories/splashpages.rb index e52e8478e..ff6af28eb 100644 --- a/spec/factories/splashpages.rb +++ b/spec/factories/splashpages.rb @@ -20,6 +20,22 @@ include_sponsors { true } include_lodgings { true } include_cfp { true } + video_url { 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' } + end + + factory :splashpage_with_youtube_video do + public { true } + video_url { 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' } + end + + factory :splashpage_with_vimeo_video do + public { true } + video_url { 'https://vimeo.com/123456789' } + end + + factory :splashpage_with_peertube_video do + public { true } + video_url { 'https://peertube.example.com/videos/watch/abc-123-def' } end end end diff --git a/spec/models/splashpage_spec.rb b/spec/models/splashpage_spec.rb new file mode 100644 index 000000000..c9366cc67 --- /dev/null +++ b/spec/models/splashpage_spec.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Splashpage do + let(:conference) { create(:conference) } + let(:splashpage) { build(:splashpage, conference: conference) } + + describe 'validations' do + context 'video_url' do + it 'allows blank video_url' do + splashpage.video_url = nil + expect(splashpage.valid?).to be true + end + + it 'allows empty string video_url' do + splashpage.video_url = '' + expect(splashpage.valid?).to be true + end + + it 'validates video_url is a valid URL format' do + splashpage.video_url = 'not-a-url' + expect(splashpage.valid?).to be false + expect(splashpage.errors[:video_url]).to be_present + end + + it 'validates video_url is from a supported platform' do + splashpage.video_url = 'https://example.com/video' + expect(splashpage.valid?).to be false + expect(splashpage.errors[:video_url]).to be_present + end + + it 'accepts valid youtube.com/watch URLs' do + splashpage.video_url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.valid?).to be true + end + + it 'accepts valid youtu.be URLs' do + splashpage.video_url = 'https://youtu.be/dQw4w9WgXcQ' + expect(splashpage.valid?).to be true + end + + it 'accepts valid youtube.com/embed URLs' do + splashpage.video_url = 'https://www.youtube.com/embed/dQw4w9WgXcQ' + expect(splashpage.valid?).to be true + end + + it 'accepts valid vimeo.com URLs' do + splashpage.video_url = 'https://vimeo.com/123456789' + expect(splashpage.valid?).to be true + end + + it 'accepts valid PeerTube URLs' do + splashpage.video_url = 'https://peertube.example.com/videos/watch/abc-123-def' + expect(splashpage.valid?).to be true + end + + it 'accepts valid Invidious URLs' do + splashpage.video_url = 'https://invidious.example.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.valid?).to be true + end + end + end + + describe '#video_platform' do + it 'detects YouTube from youtube.com/watch URL' do + splashpage.video_url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.video_platform).to eq(:youtube) + end + + it 'detects YouTube from youtu.be URL' do + splashpage.video_url = 'https://youtu.be/dQw4w9WgXcQ' + expect(splashpage.video_platform).to eq(:youtube) + end + + it 'detects YouTube from youtube.com/embed URL' do + splashpage.video_url = 'https://www.youtube.com/embed/dQw4w9WgXcQ' + expect(splashpage.video_platform).to eq(:youtube) + end + + it 'detects YouTube from m.youtube.com URL' do + splashpage.video_url = 'https://m.youtube.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.video_platform).to eq(:youtube) + end + + it 'detects Vimeo from vimeo.com URL' do + splashpage.video_url = 'https://vimeo.com/123456789' + expect(splashpage.video_platform).to eq(:vimeo) + end + + it 'detects Vimeo from vimeo.com/video URL' do + splashpage.video_url = 'https://vimeo.com/video/123456789' + expect(splashpage.video_platform).to eq(:vimeo) + end + + it 'detects PeerTube from videos/watch URL' do + splashpage.video_url = 'https://peertube.example.com/videos/watch/abc-123-def' + expect(splashpage.video_platform).to eq(:peertube) + end + + it 'detects PeerTube from /w/ URL' do + splashpage.video_url = 'https://peertube.example.com/w/abc-123-def' + expect(splashpage.video_platform).to eq(:peertube) + end + + it 'detects Invidious from watch URL' do + splashpage.video_url = 'https://invidious.example.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.video_platform).to eq(:invidious) + end + + it 'returns nil for unsupported platform' do + splashpage.video_url = 'https://dailymotion.com/video/x12345' + expect(splashpage.video_platform).to be_nil + end + + it 'returns nil when video_url is blank' do + splashpage.video_url = nil + expect(splashpage.video_platform).to be_nil + end + end + + describe '#video_id' do + context 'YouTube' do + it 'extracts video ID from youtube.com/watch URL' do + splashpage.video_url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.video_id).to eq('dQw4w9WgXcQ') + end + + it 'extracts video ID from youtu.be URL' do + splashpage.video_url = 'https://youtu.be/dQw4w9WgXcQ' + expect(splashpage.video_id).to eq('dQw4w9WgXcQ') + end + + it 'extracts video ID from youtube.com/embed URL' do + splashpage.video_url = 'https://www.youtube.com/embed/dQw4w9WgXcQ' + expect(splashpage.video_id).to eq('dQw4w9WgXcQ') + end + + it 'handles URLs with additional query parameters' do + splashpage.video_url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=10s' + expect(splashpage.video_id).to eq('dQw4w9WgXcQ') + end + end + + context 'Vimeo' do + it 'extracts video ID from vimeo.com URL' do + splashpage.video_url = 'https://vimeo.com/123456789' + expect(splashpage.video_id).to eq('123456789') + end + + it 'extracts video ID from vimeo.com/video URL' do + splashpage.video_url = 'https://vimeo.com/video/123456789' + expect(splashpage.video_id).to eq('123456789') + end + end + + context 'PeerTube' do + it 'extracts video ID from videos/watch URL' do + splashpage.video_url = 'https://peertube.example.com/videos/watch/abc-123-def' + expect(splashpage.video_id).to eq('abc-123-def') + end + + it 'extracts video ID from /w/ URL' do + splashpage.video_url = 'https://peertube.example.com/w/xyz-456-ghi' + expect(splashpage.video_id).to eq('xyz-456-ghi') + end + end + + context 'Invidious' do + it 'extracts video ID from Invidious URL' do + splashpage.video_url = 'https://invidious.example.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.video_id).to eq('dQw4w9WgXcQ') + end + end + + it 'returns nil when video_url is blank' do + splashpage.video_url = nil + expect(splashpage.video_id).to be_nil + end + + it 'returns nil for unsupported platform' do + splashpage.video_url = 'https://example.com/video' + expect(splashpage.video_id).to be_nil + end + end + + describe '#video_instance_url' do + it 'extracts instance URL from PeerTube videos/watch URL' do + splashpage.video_url = 'https://peertube.example.com/videos/watch/abc-123-def' + expect(splashpage.video_instance_url).to eq('https://peertube.example.com') + end + + it 'extracts instance URL from PeerTube /w/ URL' do + splashpage.video_url = 'https://peertube.example.com/w/abc-123-def' + expect(splashpage.video_instance_url).to eq('https://peertube.example.com') + end + + it 'extracts instance URL from Invidious URL' do + splashpage.video_url = 'https://invidious.example.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.video_instance_url).to eq('https://invidious.example.com') + end + + it 'returns nil for YouTube URL' do + splashpage.video_url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.video_instance_url).to be_nil + end + + it 'returns nil for Vimeo URL' do + splashpage.video_url = 'https://vimeo.com/123456789' + expect(splashpage.video_instance_url).to be_nil + end + + it 'returns nil when video_url is blank' do + splashpage.video_url = nil + expect(splashpage.video_instance_url).to be_nil + end + end + + describe '#video_embed_url' do + it 'generates YouTube embed URL with autoplay parameters' do + splashpage.video_url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.video_embed_url).to eq('https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1&loop=1&mute=1&controls=0&disablekb=1&fs=0&iv_load_policy=3&modestbranding=1&playsinline=1') + end + + it 'generates Vimeo embed URL with autoplay parameters' do + splashpage.video_url = 'https://vimeo.com/123456789' + expect(splashpage.video_embed_url).to eq('https://player.vimeo.com/video/123456789?autoplay=1&muted=1&loop=1&playsinline=1') + end + + it 'generates PeerTube embed URL with autoplay' do + splashpage.video_url = 'https://peertube.example.com/videos/watch/abc-123-def' + expect(splashpage.video_embed_url).to eq('https://peertube.example.com/videos/embed/abc-123-def?autoplay=1&loop=1') + end + + it 'generates Invidious embed URL with autoplay' do + splashpage.video_url = 'https://invidious.example.com/watch?v=dQw4w9WgXcQ' + expect(splashpage.video_embed_url).to eq('https://invidious.example.com/embed/dQw4w9WgXcQ?autoplay=1&loop=1') + end + + it 'returns nil when video_url is blank' do + splashpage.video_url = nil + expect(splashpage.video_embed_url).to be_nil + end + + it 'returns nil for unsupported platform' do + splashpage.video_url = 'https://example.com/video' + expect(splashpage.video_embed_url).to be_nil + end + end +end