diff --git a/app/controllers/RelayRound.scala b/app/controllers/RelayRound.scala index 8d90f4443551d..00a6d1a388cb8 100644 --- a/app/controllers/RelayRound.scala +++ b/app/controllers/RelayRound.scala @@ -268,20 +268,21 @@ final class RelayRound( isSubscribed <- ctx.me.soFu: me => env.relay.api.isSubscribed(rt.tour.id, me.userId) videoUrls <- embed match - case VideoEmbed.Auto => - fuccess: - rt.tour.pinnedStream - .ifFalse(rt.round.isFinished) - .flatMap(_.upstream) - .map(_.urls(netDomain).toPair) - case VideoEmbed.No => fuccess(none) case VideoEmbed.Stream(userId) => env.streamer.api .find(userId) .flatMapz(s => env.streamer.liveStreamApi.of(s).dmap(some)) .map: _.flatMap(_.stream).map(_.urls(netDomain).toPair) - crossSiteIsolation = videoUrls.isEmpty + case VideoEmbed.PinnedStream => + fuccess: + rt.tour.pinnedStream + .ifFalse(rt.round.isFinished) + .flatMap(_.upstream) + .map(_.urls(netDomain).toPair) + case _ => fuccess(none) + crossSiteIsolation = videoUrls.isEmpty || (rt.tour.pinnedStream.isDefined && crossOriginPolicy + .supportsCredentiallessIFrames(ctx.req)) data = env.relay.jsonView.makeData( rt.tour.withRounds(rounds.map(_.round)), rt.round.id, diff --git a/modules/relay/src/main/JsonView.scala b/modules/relay/src/main/JsonView.scala index a7bf9e8a646b2..534f33bc8c36f 100644 --- a/modules/relay/src/main/JsonView.scala +++ b/modules/relay/src/main/JsonView.scala @@ -128,8 +128,12 @@ final class JsonView(baseUrl: BaseUrl, markup: RelayMarkup, picfitUrl: PicfitUrl .add("lcc", trs.rounds.find(_.id == currentRoundId).map(_.sync.upstream.exists(_.hasLcc))) .add("isSubscribed" -> isSubscribed) .add("videoUrls" -> videoUrls) - .add("pinnedStream" -> pinned) - .add("note" -> trs.tour.note.ifTrue(canContribute)), + .add("note" -> trs.tour.note.ifTrue(canContribute)) + .add("pinned" -> pinned.map: p => + Json + .obj("name" -> p.name) + .add("redirect" -> p.upstream.map(_.urls(lila.core.config.NetDomain("")).redirect)) + .add("text" -> p.text)), study = studyData.study, analysis = studyData.analysis, group = group.map(_.group.name) diff --git a/modules/relay/src/main/RelayPinnedStream.scala b/modules/relay/src/main/RelayPinnedStream.scala index 5c27cddb858ae..73427117a03ff 100644 --- a/modules/relay/src/main/RelayPinnedStream.scala +++ b/modules/relay/src/main/RelayPinnedStream.scala @@ -4,22 +4,19 @@ import io.mola.galimatias.URL import scala.jdk.CollectionConverters.* import lila.core.config.NetDomain -case class RelayPinnedStream(name: String, url: URL): +case class RelayPinnedStream(name: String, url: URL, text: Option[String]): import RelayPinnedStream.* def upstream: Option[RelayPinnedStream.Upstream] = parseYoutube.orElse(parseTwitch) - // https://www.youtube.com/live/Lg0askmGqvo - // https://www.youtube.com/live/Lg0askmGqvo?si=KKOexnmA2xPcyStZ def parseYoutube: Option[YouTube] = - url.host.toString - .endsWith("youtube.com") - .so: - url.pathSegments.asScala.toList match - case List("live", id) => YouTube(id).some - case _ => none + if List("www.youtube.com", "youtube.com", "youtu.be").contains(url.host.toString) then + url.pathSegments.asScala.toList match + case List("live", id) => Some(YouTube(id)) + case _ => Option(url.queryParameter("v")).map(YouTube.apply) + else None // https://www.twitch.tv/tcec_chess_tv def parseTwitch: Option[Twitch] = @@ -37,11 +34,11 @@ object RelayPinnedStream: def urls(parent: NetDomain): Urls case class YouTube(id: String) extends Upstream: def urls(parent: NetDomain) = Urls( - s"https://www.youtube.com/embed/${id}?disablekb=1&modestbranding=1", + s"https://www.youtube.com/embed/${id}?disablekb=1&modestbranding=1&autoplay=1", s"https://www.youtube.com/watch?v=${id}" ) case class Twitch(id: String) extends Upstream: def urls(parent: NetDomain) = Urls( - s"https://player.twitch.tv/?channel=${id}&parent=${parent}", + s"https://player.twitch.tv/?channel=${id}&parent=${parent}&autoplay=true", s"https://www.twitch.tv/${id}" ) diff --git a/modules/relay/src/main/RelayTourForm.scala b/modules/relay/src/main/RelayTourForm.scala index b32792c96f1d8..95eb2eea4b07b 100644 --- a/modules/relay/src/main/RelayTourForm.scala +++ b/modules/relay/src/main/RelayTourForm.scala @@ -35,7 +35,9 @@ final class RelayTourForm(langList: lila.core.i18n.LangList): private val pinnedStreamMapping = mapping( "name" -> cleanNonEmptyText(maxLength = 100), - "url" -> url.field.verifying("Invalid stream URL", url => RelayPinnedStream("", url).upstream.isDefined) + "url" -> url.field + .verifying("Invalid stream URL", url => RelayPinnedStream("", url, None).upstream.isDefined), + "text" -> optional(cleanText(maxLength = 100)) )(RelayPinnedStream.apply)(unapply) private given Formatter[RelayTour.Tier] = diff --git a/modules/relay/src/main/RelayVideoEmbed.scala b/modules/relay/src/main/RelayVideoEmbed.scala index 4d7886b1bd795..f8e1392f56c99 100644 --- a/modules/relay/src/main/RelayVideoEmbed.scala +++ b/modules/relay/src/main/RelayVideoEmbed.scala @@ -7,11 +7,11 @@ import play.api.mvc.Result enum RelayVideoEmbed: case No case Auto + case PinnedStream case Stream(userId: UserId) override def toString = this match - case No => "no" - case Auto => "" - case Stream(u) => u.toString + case No => "no" + case _ => "" final class RelayVideoEmbedStore(baker: LilaCookie): @@ -21,11 +21,11 @@ final class RelayVideoEmbedStore(baker: LilaCookie): def read(using req: RequestHeader): RelayVideoEmbed = def fromCookie = req.cookies.get(cookieName).map(_.value).filter(_.nonEmpty) match case Some("no") => No + case Some("ps") => PinnedStream case _ => Auto req.queryString.get("embed") match - case Some(Nil) => fromCookie - case Some(Seq("")) => Auto case Some(Seq("no")) => No + case Some(Seq("ps")) => PinnedStream case Some(Seq(name)) => UserStr.read(name).fold(Auto)(u => Stream(u.id)) case _ => fromCookie diff --git a/modules/relay/src/main/ui/RelayFormUi.scala b/modules/relay/src/main/ui/RelayFormUi.scala index d2739362275d3..489bcaf690649 100644 --- a/modules/relay/src/main/ui/RelayFormUi.scala +++ b/modules/relay/src/main/ui/RelayFormUi.scala @@ -645,6 +645,17 @@ Team Dogs ; Scooby Doo"""), "Stream name", half = true )(form3.input(_)) + ), + form3.split( + form3.group( + form("pinnedStream.text"), + "Stream link label", + help = frag( + "Optional. Show a label on the image link to your live stream.", + br, + "Example: 'Watch us live on YouTube!'" + ).some + )(form3.input(_)) ) ) ) diff --git a/ui/analyse/css/study/relay/_layout.scss b/ui/analyse/css/study/relay/_layout.scss index d23bea278cd56..9dc2b3f68aa5e 100644 --- a/ui/analyse/css/study/relay/_layout.scss +++ b/ui/analyse/css/study/relay/_layout.scss @@ -12,6 +12,7 @@ body { main.is-relay { .relay-tour { grid-area: relay-tour; + overflow: visible; &__side { grid-area: side; } @@ -22,7 +23,6 @@ main.is-relay { } @include mq-at-least-col3 { - #video-player-placeholder, button.streamer-show { display: block; } diff --git a/ui/analyse/css/study/relay/_tour.scss b/ui/analyse/css/study/relay/_tour.scss index 331c54efdd448..f134b983b6e92 100644 --- a/ui/analyse/css/study/relay/_tour.scss +++ b/ui/analyse/css/study/relay/_tour.scss @@ -117,10 +117,13 @@ $hover-bg: $m-primary_bg--mix-30; flex: 0 0 50%; } line-height: 0; - img { + > img { width: 100%; @include broken-img(2 / 1); } + .video-player-close { + display: none; + } text-align: center; } &__image-upload { diff --git a/ui/analyse/css/study/relay/_video-player.scss b/ui/analyse/css/study/relay/_video-player.scss index 9d3019de23f5f..6990acf1968d4 100644 --- a/ui/analyse/css/study/relay/_video-player.scss +++ b/ui/analyse/css/study/relay/_video-player.scss @@ -7,16 +7,99 @@ #video-player-placeholder { aspect-ratio: 16/9; + position: relative; width: 100%; } -img.video-player-close { +.video-player-close { z-index: $z-video-player-controls-101; position: absolute; - height: 20px; - width: 20px; + pointer-events: auto; + top: 6px; + right: 6px; + height: 24px; + width: 24px; + padding: 2px; + border-radius: 50%; + background-color: black; cursor: pointer; &:hover { - filter: brightness(10); + filter: brightness(3); + } +} + +#video-player-placeholder.link { + cursor: pointer; + overflow: hidden; + outline-offset: -3px; + outline: 3px solid $m-bad--alpha-50; + + .image { + position: absolute; + background: center / cover; + overflow: hidden; + inset: 0; + filter: blur(4px) brightness(0.7); + } + + .play-button { + position: absolute; + pointer-events: none; + transform: translate(-50%, -50%); + top: 50%; + left: 50%; + width: 18%; + opacity: 0.6; + fill: white; + filter: drop-shadow(0 0 12px #0000004f); + + circle { + filter: drop-shadow(0 0 8px #840000); + paint-order: stroke fill; + stroke: #fff9; + stroke-width: 3px; + fill: $c-bad; + } + } + + &:has(.text-box) .play-button { + top: 56%; + } + + .text-box { + @extend %flex-column; + position: absolute; + pointer-events: none; + justify-content: center; + align-items: center; + top: 10%; + left: 10%; + right: 10%; + } + + .text-box div { + margin: auto; + pointer-events: none; + border-radius: 5px; + border: 1px solid #8888; + padding: 5px 8px; + text-align: center; + line-height: normal; + color: #ddde; + background-color: #333d; + font-family: 'Noto Sans'; + font-size: 1.2em; + } + + &:hover:not(:has(.video-player-close:hover)) { + box-shadow: 0 0 12px $c-bad; + + .play-button { + opacity: 1; + } + + .image { + filter: blur(4px) brightness(0.6); + } } } diff --git a/ui/analyse/src/analyse.ts b/ui/analyse/src/analyse.ts index 2131f0ea27fbc..04f68cf94250d 100644 --- a/ui/analyse/src/analyse.ts +++ b/ui/analyse/src/analyse.ts @@ -5,9 +5,8 @@ import { wsConnect } from 'common/socket'; export { patch }; -export const start = makeStart(patch); - -export const boot = makeBoot(start); +const start = makeStart(patch); +const boot = makeBoot(start); export function initModule({ mode, cfg }: { mode: 'userAnalysis' | 'replay'; cfg: any }) { if (mode === 'replay') boot(cfg); diff --git a/ui/analyse/src/interfaces.ts b/ui/analyse/src/interfaces.ts index 94eb304a0abc6..74ef61457b810 100644 --- a/ui/analyse/src/interfaces.ts +++ b/ui/analyse/src/interfaces.ts @@ -156,6 +156,8 @@ export interface AnalyseOpts { inlinePgn?: string; externalEngineEndpoint: string; embed?: boolean; + socketUrl?: string; + socketVersion?: number; } export interface JustCaptured extends Piece { diff --git a/ui/analyse/src/plugins/analyse.study.ts b/ui/analyse/src/plugins/analyse.study.ts index e793576789599..efae622bbac16 100644 --- a/ui/analyse/src/plugins/analyse.study.ts +++ b/ui/analyse/src/plugins/analyse.study.ts @@ -1,18 +1,18 @@ import { patch } from '../view/util'; -import makeBoot from '../boot'; import makeStart from '../start'; +import type { AnalyseOpts } from '../interfaces'; +import type { AnalyseSocketSend } from '../socket'; import * as studyDeps from '../study/studyDeps'; import { wsConnect } from 'common/socket'; export { patch }; -export const start = makeStart(patch, studyDeps); -export const boot = makeBoot(start); +const start = makeStart(patch, studyDeps); -export function initModule(cfg: any) { - cfg.socketSend = wsConnect(cfg.socketUrl || '/analysis/socket/v5', cfg.socketVersion, { +export function initModule(cfg: AnalyseOpts) { + cfg.socketSend = wsConnect(cfg.socketUrl || '/analysis/socket/v5', cfg.socketVersion ?? false, { receive: (t: string, d: any) => analyse.socketReceive(t, d), ...(cfg.embed ? { params: { flag: 'embed' } } : {}), - }).send; + }).send as AnalyseSocketSend; const analyse = start(cfg); } diff --git a/ui/analyse/src/study/relay/interfaces.ts b/ui/analyse/src/study/relay/interfaces.ts index ddf0ab794f2fe..aebc0d3bdac55 100644 --- a/ui/analyse/src/study/relay/interfaces.ts +++ b/ui/analyse/src/study/relay/interfaces.ts @@ -5,7 +5,7 @@ export interface RelayData { group?: RelayGroup; isSubscribed?: boolean; // undefined if anon videoUrls?: [string, string]; - pinnedStream?: { name: string; youtube?: string; twitch?: string }; + pinned?: { name: string; redirect: string; text?: string }; note?: string; lcc?: boolean; } diff --git a/ui/analyse/src/study/relay/relayCtrl.ts b/ui/analyse/src/study/relay/relayCtrl.ts index 77f8aee390370..3e69c95712b31 100644 --- a/ui/analyse/src/study/relay/relayCtrl.ts +++ b/ui/analyse/src/study/relay/relayCtrl.ts @@ -2,7 +2,7 @@ import type { RelayData, LogEvent, RelaySync, RelayRound, RoundId } from './inte import type { BothClocks, ChapterId, ChapterSelect, Federations, ServerClockMsg } from '../interfaces'; import type { StudyMemberCtrl } from '../studyMembers'; import type { AnalyseSocketSend } from '../../socket'; -import { type Prop, type Toggle, defined, myUserId, notNull, prop, toggle } from 'common'; +import { type Prop, type Toggle, myUserId, notNull, prop, toggle } from 'common'; import RelayTeams from './relayTeams'; import RelayPlayers from './relayPlayers'; import type { StudyChapters } from '../studyChapters'; @@ -59,19 +59,27 @@ export default class RelayCtrl { redraw, ); this.stats = new RelayStats(this.currentRound(), redraw); - this.videoPlayer = this.data.videoUrls?.[0] ? new VideoPlayer(this.data.videoUrls[0], redraw) : undefined; - setInterval(() => this.redraw(true), 1000); - - const pinned = data.pinnedStream; - if (pinned && this.pinStreamer()) this.streams.push(['', pinned.name]); + if (data.videoUrls?.[0] || this.isPinnedStreamOngoing()) + this.videoPlayer = new VideoPlayer( + { + embed: this.data.videoUrls?.[0] || false, + redirect: this.data.videoUrls?.[1] || this.data.pinned?.redirect, + image: this.data.tour.image, + text: this.data.pinned?.text, + }, + redraw, + ); + const pinnedName = this.isPinnedStreamOngoing() && data.pinned?.name; + if (pinnedName) this.streams.push(['ps', pinnedName]); pubsub.on('socket.in.crowd', d => { const s = (d.streams as [string, string][]) ?? []; - if (pinned && this.pinStreamer()) s.unshift(['', pinned.name]); + if (pinnedName) s.unshift(['ps', pinnedName]); if (this.streams.length === s.length && this.streams.every(([id], i) => id === s[i][0])) return; this.streams = s; this.redraw(); }); + setInterval(() => this.redraw(true), 1000); } openTab = (t: RelayTab) => { @@ -131,10 +139,16 @@ export default class RelayCtrl { isStreamer = () => this.streams.some(([id]) => id === myUserId()); - pinStreamer = () => - defined(this.data.pinnedStream) && - !this.currentRound().finished && - Date.now() > this.currentRound().startsAt! - 1000 * 3600; + isPinnedStreamOngoing = () => { + if (!this.data.pinned) return false; + if (this.currentRound().finished) return false; + if (Date.now() < this.currentRound().startsAt! - 1000 * 3600) return false; + return true; + }; + + noEmbed() { + return document.cookie.includes('relayVideo=no'); + } private socketHandlers = { relayData: (d: RelayData) => { diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts index d4a4a900f45df..1206492645acd 100644 --- a/ui/analyse/src/study/relay/relayTourView.ts +++ b/ui/analyse/src/study/relay/relayTourView.ts @@ -15,7 +15,6 @@ import { statsView } from './relayStats'; import { makeChatEl, type RelayViewContext } from '../../view/components'; import { gamesList } from './relayGames'; import { renderStreamerMenu } from './relayView'; -import { renderVideoPlayer } from './videoPlayer'; import { playersView } from './relayPlayers'; import { gameLinksListener } from '../studyChapters'; import { copyMeInput } from 'common/copyMe'; @@ -350,10 +349,9 @@ const teams = (ctx: RelayViewContext) => [ const stats = (ctx: RelayViewContext) => [...header(ctx), statsView(ctx.relay.stats)]; const header = (ctx: RelayViewContext) => { - const { ctrl, relay, allowVideo } = ctx; + const { ctrl, relay } = ctx; const d = relay.data, group = d.group, - embedVideo = d.videoUrls && allowVideo, studyD = ctrl.study?.data.description; return [ @@ -365,20 +363,7 @@ const header = (ctx: RelayViewContext) => { roundSelect(relay, ctx.study), ]), ]), - h( - `div.relay-tour__header__image${embedVideo ? '.video' : ''}`, - embedVideo - ? renderVideoPlayer(relay) - : d.tour.image - ? h('img', { attrs: { src: d.tour.image } }) - : ctx.study.members.isOwner() - ? h( - 'a.button.relay-tour__header__image-upload', - { attrs: { href: `/broadcast/${d.tour.id}/edit` } }, - i18n.broadcast.uploadImage, - ) - : undefined, - ), + broadcastImageOrStream(ctx), ]), studyD && h('div.relay-tour__note.pinned', h('div', [h('div', { hook: richHTML(studyD, false) })])), d.note && @@ -461,3 +446,24 @@ const roundStateIcon = (round: RelayRound, titleAsText: boolean) => { attrs: { ...dataIcon(licon.Checkmark), title: !titleAsText && i18n.site.finished } }, titleAsText && i18n.site.finished, ); + +const broadcastImageOrStream = (ctx: RelayViewContext) => { + const { relay, allowVideo } = ctx; + const d = relay.data, + embedVideo = (d.videoUrls || relay.isPinnedStreamOngoing()) && allowVideo; + + return h( + `div.relay-tour__header__image${embedVideo ? '.video' : ''}`, + embedVideo + ? relay.videoPlayer?.render() + : d.tour.image + ? h('img', { attrs: { src: d.tour.image } }) + : ctx.study.members.isOwner() + ? h( + 'a.button.relay-tour__header__image-upload', + { attrs: { href: `/broadcast/${d.tour.id}/edit` } }, + i18n.broadcast.uploadImage, + ) + : undefined, + ); +}; diff --git a/ui/analyse/src/study/relay/relayView.ts b/ui/analyse/src/study/relay/relayView.ts index e12d67c84fb40..546968987e505 100644 --- a/ui/analyse/src/study/relay/relayView.ts +++ b/ui/analyse/src/study/relay/relayView.ts @@ -6,7 +6,6 @@ import type AnalyseCtrl from '../../ctrl'; import { view as keyboardView } from '../../keyboard'; import type * as studyDeps from '../studyDeps'; import { tourSide, renderRelayTour } from './relayTourView'; -import { renderVideoPlayer } from './videoPlayer'; import { type RelayViewContext, viewContext, @@ -69,7 +68,7 @@ function renderBoardView(ctx: RelayViewContext) { return [ renderBoard(ctx), gaugeOn && cevalView.renderGauge(ctrl), - renderTools(ctx, renderVideoPlayer(ctx.relay)), + renderTools(ctx, relay.noEmbed() ? undefined : relay.videoPlayer?.render()), renderControls(ctrl), !ctrl.isEmbed && renderUnderboard(ctx), tourSide(ctx), diff --git a/ui/analyse/src/study/relay/videoPlayer.ts b/ui/analyse/src/study/relay/videoPlayer.ts index e2ce1ea4106d9..1cc685d5b379d 100644 --- a/ui/analyse/src/study/relay/videoPlayer.ts +++ b/ui/analyse/src/study/relay/videoPlayer.ts @@ -1,48 +1,32 @@ -import { looseH as h, type Redraw, type VNode } from 'common/snabbdom'; -import type RelayCtrl from './relayCtrl'; +import { looseH as h, type Redraw, type VNode, onInsert } from 'common/snabbdom'; import { allowVideo } from './relayView'; export class VideoPlayer { private iframe: HTMLIFrameElement; private close: HTMLImageElement; - private autoplay: boolean; private animationFrameId?: number; constructor( - private url: string, + private o: { embed: string | false; redirect?: string; image?: string; text?: string }, private redraw: Redraw, ) { - this.autoplay = location.search.includes('embed='); + if (!o.embed) return; this.iframe = document.createElement('iframe'); + this.iframe.setAttribute('credentialless', ''); + this.iframe.style.display = 'none'; this.iframe.id = 'video-player'; - this.iframe.setAttribute('credentialless', ''); // a feeble mewling ignored by all - if (this.autoplay) { - this.url += '&autoplay=1'; - this.iframe.allow = 'autoplay'; - } else { - this.url += '&autoplay=false'; // needs to be "false" for twitch - } - this.iframe.src = this.url; - this.iframe.setAttribute('credentialless', 'credentialless'); + this.iframe.src = o.embed; + this.iframe.allow = 'autoplay'; + this.close = document.createElement('img'); this.close.src = site.asset.flairSrc('symbols.cancel'); this.close.className = 'video-player-close'; + this.close.addEventListener('click', () => this.onEmbed('no'), true); - this.close.addEventListener('click', this.onClose, true); - - this.onWindowResize(); + this.addWindowResizer(); } - private onClose = () => { - // we need to reload the page unfortunately, - // so that a better local engine can be loaded - // once the iframe and its CSP are gone - const url = new URL(location.href); - url.searchParams.set('embed', 'no'); - window.location.replace(url); - }; - cover = (el?: HTMLElement) => { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); @@ -66,7 +50,7 @@ export class VideoPlayer { }); }; - onWindowResize = () => { + addWindowResizer = () => { let showingVideo = false; window.addEventListener( 'resize', @@ -81,16 +65,60 @@ export class VideoPlayer { { passive: true }, ); }; -} -export function renderVideoPlayer(relay: RelayCtrl): VNode | undefined { - const player = relay.videoPlayer; - return player - ? h('div#video-player-placeholder', { - hook: { - insert: (vnode: VNode) => player.cover(vnode.elm as HTMLElement), - update: (_, vnode: VNode) => player.cover(vnode.elm as HTMLElement), - }, - }) - : undefined; + render = () => { + return this.o.embed + ? h('div#video-player-placeholder', { + hook: { + insert: (vnode: VNode) => this.cover(vnode.elm as HTMLElement), + update: (_, vnode: VNode) => this.cover(vnode.elm as HTMLElement), + }, + }) + : h('div#video-player-placeholder.link', [ + h('div.image', { + attrs: { style: `background-image: url(${this.o.image})` }, + hook: onInsert((el: HTMLElement) => { + el.addEventListener('click', e => { + if (e.ctrlKey || e.shiftKey) window.open(this.o.redirect, '_blank'); + else this.onEmbed('ps'); + }); + el.addEventListener('contextmenu', () => window.open(this.o.redirect, '_blank')); + }), + }), + h('img.video-player-close', { + attrs: { src: site.asset.flairSrc('symbols.cancel') }, + hook: onInsert((el: HTMLElement) => el.addEventListener('click', () => this.onEmbed('no'))), + }), + this.o.text && h('div.text-box', h('div', this.o.text)), + h( + 'svg.play-button', + { + attrs: { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 200 200', + }, + }, + [ + h('circle', { + attrs: { + cx: '100', + cy: '100', + r: '90', + }, + }), + h('path', { + attrs: { + d: 'M 68 52 A 5 5 0 0 1 74 46 L 154 96 A 5 5 0 0 1 154 104 L 74 154 A 5 5 0 0 1 68 148 Z', + }, + }), + ], + ), + ]); + }; + + onEmbed = (stream: 'ps' | 'no') => { + const urlWithEmbed = new URL(location.href); + urlWithEmbed.searchParams.set('embed', stream); + window.location.href = urlWithEmbed.toString(); + }; }