From 07197c86582ca72a7cb1b84c35b34dc1729a8d06 Mon Sep 17 00:00:00 2001 From: Elliot Shine Date: Tue, 20 Oct 2026 13:26:21 +1030 Subject: [PATCH] oceanbench: add oceanmedia player This change adds a player for oceanmedia streams to oceanbench. This uses video.js as this is what we will be switching to in ausoceantv (as vidstack is no longer supported and the authors recommend using video.js). The default video.js live player skin has been vendored so that we can customise it. --- cmd/oceanbench/api_v1.go | 52 + cmd/oceanbench/main.go | 31 +- cmd/oceanbench/package-lock.json | 209 +- cmd/oceanbench/package.json | 4 +- cmd/oceanbench/s/broadcast.js | 55 +- cmd/oceanbench/t/broadcast.html | 8 +- cmd/oceanbench/t/tv-overview.html | 2 + cmd/oceanbench/ts/global.d.ts | 7 + cmd/oceanbench/ts/oceanmedia-player.ts | 73 + cmd/oceanbench/ts/tv-overview.ts | 59 +- cmd/oceanbench/ts/videojs/live-video/skin.css | 1959 +++++++++++++++++ .../ts/videojs/live-video/skin.html | 156 ++ cmd/oceanbench/ts/videojs/live-video/skin.ts | 72 + cmd/oceanbench/ts/videojs/styles/base.css | 115 + .../ts/videojs/styles/themes/preferences.css | 76 + .../ts/videojs/styles/themes/theme.css | 151 ++ .../ts/videojs/styles/video/base.css | 3 + .../ts/videojs/styles/video/captions.css | 37 + .../ts/videojs/styles/video/theme.css | 94 + 19 files changed, 3137 insertions(+), 26 deletions(-) create mode 100644 cmd/oceanbench/ts/oceanmedia-player.ts create mode 100644 cmd/oceanbench/ts/videojs/live-video/skin.css create mode 100644 cmd/oceanbench/ts/videojs/live-video/skin.html create mode 100644 cmd/oceanbench/ts/videojs/live-video/skin.ts create mode 100644 cmd/oceanbench/ts/videojs/styles/base.css create mode 100644 cmd/oceanbench/ts/videojs/styles/themes/preferences.css create mode 100644 cmd/oceanbench/ts/videojs/styles/themes/theme.css create mode 100644 cmd/oceanbench/ts/videojs/styles/video/base.css create mode 100644 cmd/oceanbench/ts/videojs/styles/video/captions.css create mode 100644 cmd/oceanbench/ts/videojs/styles/video/theme.css diff --git a/cmd/oceanbench/api_v1.go b/cmd/oceanbench/api_v1.go index 95af0c84..db3c2ee1 100644 --- a/cmd/oceanbench/api_v1.go +++ b/cmd/oceanbench/api_v1.go @@ -36,6 +36,7 @@ import ( "strings" "time" + "github.com/ausocean/cloud/cmd/oceantv/broadcasthost" "github.com/ausocean/cloud/datastore" "github.com/ausocean/cloud/gauth" "github.com/ausocean/cloud/model" @@ -60,6 +61,8 @@ func setupAPIV1Routes(api fiber.Router) { v1.Get("/logs/:mac", getV1LogsByDevice) v1.Put("/log/:mac", putV1LogByDevice) + v1.Get("/broadcast/stream", getV1BroadcastStreamHandler) + // Endpoints with site scope. v1.Group("/:"+skeyParamKey). Get("/broadcasts/", getV1BroadcastIDHandler). @@ -501,6 +504,55 @@ func getV1BroadcastHandler(c *fiber.Ctx) error { return c.JSON(cfg) } +// getV1BroadcastStreamHandler handles GET /api/v1/broadcast/stream?uuid=. +// It returns the HLS playlist URL for an OceanMedia broadcast and a viewer +// token scoped to that broadcast, minted with the shared OceanMedia secret. +func getV1BroadcastStreamHandler(c *fiber.Ctx) error { + uuid := c.Query("uuid") + if uuid == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "uuid query parameter is required"}) + } + + ctx := c.UserContext() + p := c.Locals(profileKey).(*gauth.Profile) + + broadcastVar, err := model.GetBroadcastVarByUUID(ctx, settingsStore, uuid) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "broadcast not found"}) + } + if !isAdmin(ctx, broadcastVar.Skey, p.Email) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "insufficient permission, requires admin permission"}) + } + + cfg, err := broadcastFromVars([]model.Variable{*broadcastVar}, uuid) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "broadcast not found"}) + } + if cfg.BroadcastHost != broadcasthost.OceanMediaHostName || cfg.BID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "broadcast is not an OceanMedia stream"}) + } + if len(oceanMediaJWTSecret) == 0 { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "OceanMedia is not configured"}) + } + + now := time.Now() + token, err := gauth.PutClaims(map[string]interface{}{ + "sub": cfg.BID, + "iss": projectID, + "iat": now.Unix(), + "exp": now.Add(oceanMediaTokenTTL).Unix(), + }, oceanMediaJWTSecret) + if err != nil { + log.Printf("could not create OceanMedia token for broadcast %s: %v", cfg.BID, err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "could not create stream token"}) + } + + return c.JSON(fiber.Map{ + "url": mediaURL + "/" + cfg.BID + ".m3u8", + "token": token, + }) +} + func getV1BroadcastIDHandler(c *fiber.Ctx) error { skParam := c.Params(skeyParamKey) if skParam == "" { diff --git a/cmd/oceanbench/main.go b/cmd/oceanbench/main.go index f71ee88a..73e690f1 100644 --- a/cmd/oceanbench/main.go +++ b/cmd/oceanbench/main.go @@ -65,6 +65,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/ausocean/cloud/backend" "github.com/ausocean/cloud/datastore" @@ -91,12 +92,17 @@ const ( ) const ( - projectID = "oceanbench" - oauthClientID = "802166617157-v67emnahdpvfuc13ijiqb7qm3a7sf45b.apps.googleusercontent.com" - oauthMaxAge = 60 * 60 * 24 * 7 // 7 days - tvServiceURL = "https://tv.cloudblue.org" - cronServiceURL = "https://cron.cloudblue.org" - cronServiceAccount = "oceancron@appspot.gserviceaccount.com" + projectID = "oceanbench" + oauthClientID = "802166617157-v67emnahdpvfuc13ijiqb7qm3a7sf45b.apps.googleusercontent.com" + oauthMaxAge = 60 * 60 * 24 * 7 // 7 days + tvServiceURL = "https://tv.cloudblue.org" + cronServiceURL = "https://cron.cloudblue.org" + oceanMediaServiceURL = "https://media.cloudblue.org" + cronServiceAccount = "oceancron@appspot.gserviceaccount.com" + + // oceanMediaTokenTTL is the lifetime of viewer tokens minted for + // OceanMedia playlists. + oceanMediaTokenTTL = 12 * time.Hour ) // page defines one page of the web app. @@ -147,8 +153,13 @@ var ( auth *gauth.UserAuth channelAuth *ytclient.ChannelAuth tvURL = tvServiceURL + mediaURL = oceanMediaServiceURL storePath string testDataFile string + + // oceanMediaJWTSecret is the shared secret used to mint viewer tokens + // for OceanMedia playlists. It must match OceanMedia's jwtSecret. + oceanMediaJWTSecret []byte ) var ( @@ -235,6 +246,7 @@ func main() { flag.IntVar(&port, "port", defaultPort, "Port we listen on in standalone mode") flag.StringVar(&cronURL, "cronurl", cronServiceURL, "Cron service URL") flag.StringVar(&tvURL, "tvurl", tvServiceURL, "TV service URL") + flag.StringVar(&mediaURL, "mediaurl", oceanMediaServiceURL, "OceanMedia service URL") flag.StringVar(&storePath, "filestore", "store", "File store path") flag.StringVar(&testDataFile, "testdata", "", "Path to a JSON file to populate the datastore") flag.Parse() @@ -266,6 +278,13 @@ func main() { log.Printf("could not get cronSecret: %v", err) } + // Shared secret used to mint OceanMedia viewer tokens. + if s, err := gauth.GetSecret(ctx, projectID, "oceanMediaJWTSecret"); err != nil { + log.Printf("could not get oceanMediaJWTSecret: %v", err) + } else { + oceanMediaJWTSecret = []byte(s) + } + // Warmup handler. app.Get("/_ah/warmup", func(c *fiber.Ctx) error { log.Println("warmup request received, version: " + version) diff --git a/cmd/oceanbench/package-lock.json b/cmd/oceanbench/package-lock.json index 8fe1ddd9..fa55118d 100644 --- a/cmd/oceanbench/package-lock.json +++ b/cmd/oceanbench/package-lock.json @@ -6,6 +6,8 @@ "": { "dependencies": { "cronstrue": "^3.30.0", + "@videojs/hlsjs-video": "^10.0.1", + "@videojs/html": "^10.0.1", "es-dev-server": "^2.1.0", "lit": "^2.6.1", "mime": "^3.0.0", @@ -1843,6 +1845,15 @@ "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz", "integrity": "sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==" }, + "node_modules/@lit/context": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz", + "integrity": "sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.2 || ^2.1.0" + } + }, "node_modules/@lit/reactive-element": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", @@ -3759,6 +3770,171 @@ "@types/node": "*" } }, + "node_modules/@videojs/core": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@videojs/core/-/core-10.0.1.tgz", + "integrity": "sha512-N+f41wgFcmpe0QUhO0Q9sFHpMxKvr38ojkX5FlxAo3LKDAo2UrG2KIeklte+1VVae3oaJfP6J/KYtrRRObRxxw==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/media": "10.0.1", + "@videojs/store": "10.0.1", + "@videojs/utils": "10.0.1" + } + }, + "node_modules/@videojs/element": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@videojs/element/-/element-10.0.1.tgz", + "integrity": "sha512-s42yzkN3PH3573ZVcFRLNAXCbQTraXX9X/nUUVjx4NH3n36VGTmFiqJ3jrGhU+IWg4euasKyuVnwSp6qp4C1lg==", + "license": "Apache-2.0", + "dependencies": { + "@lit/context": "^1.1.0" + } + }, + "node_modules/@videojs/hlsjs-video": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@videojs/hlsjs-video/-/hlsjs-video-10.0.1.tgz", + "integrity": "sha512-7Om99Vxsh7JYAVf04QO4SKgkIE2jrZ8c3KS5BtdQRqZaFYM5uWU9vubw3vwerI5nsrosg1KXcuzS5TGEUW4ToQ==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/media": "10.0.1", + "@videojs/native-hls-video": "10.0.1", + "@videojs/utils": "10.0.1", + "hls.js": "1.6.7" + } + }, + "node_modules/@videojs/html": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@videojs/html/-/html-10.0.1.tgz", + "integrity": "sha512-QAkGefFJU/u+w7ZNod4XyQSOBQ3Ij/uPHTO/hfW+ALlIeDYEsYzplQtHoLzU+Fh4mzHFhKKJI2iEW4x15fxj9Q==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/core": "10.0.1", + "@videojs/element": "10.0.1", + "@videojs/media": "10.0.1", + "@videojs/native-hls-video": "10.0.1", + "@videojs/spf": "10.0.1", + "@videojs/store": "10.0.1", + "@videojs/utils": "10.0.1" + }, + "peerDependencies": { + "@videojs/cloudflare-video": "10.0.1", + "@videojs/dash-video": "10.0.1", + "@videojs/google-cast": "10.0.1", + "@videojs/hlsjs-video": "10.0.1", + "@videojs/mux-audio": "10.0.1", + "@videojs/mux-data": "10.0.1", + "@videojs/mux-video": "10.0.1", + "@videojs/shaka-video": "10.0.1", + "@videojs/spotify-audio": "10.0.1", + "@videojs/tiktok-video": "10.0.1", + "@videojs/twitch-video": "10.0.1", + "@videojs/vimeo-video": "10.0.1", + "@videojs/wistia-video": "10.0.1", + "@videojs/youtube-video": "10.0.1" + }, + "peerDependenciesMeta": { + "@videojs/cloudflare-video": { + "optional": true + }, + "@videojs/dash-video": { + "optional": true + }, + "@videojs/google-cast": { + "optional": true + }, + "@videojs/hlsjs-video": { + "optional": true + }, + "@videojs/mux-audio": { + "optional": true + }, + "@videojs/mux-data": { + "optional": true + }, + "@videojs/mux-video": { + "optional": true + }, + "@videojs/shaka-video": { + "optional": true + }, + "@videojs/spotify-audio": { + "optional": true + }, + "@videojs/tiktok-video": { + "optional": true + }, + "@videojs/twitch-video": { + "optional": true + }, + "@videojs/vimeo-video": { + "optional": true + }, + "@videojs/wistia-video": { + "optional": true + }, + "@videojs/youtube-video": { + "optional": true + } + } + }, + "node_modules/@videojs/media": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@videojs/media/-/media-10.0.1.tgz", + "integrity": "sha512-hMC2aS/8PovwJBi8y2qShC/amYfH6SBce7SZAM54qzRq8hl4FwWGFeUMMmXp8x4agRxLuCSqGqZg/EoGH2I5mQ==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/utils": "10.0.1" + } + }, + "node_modules/@videojs/native-hls-video": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@videojs/native-hls-video/-/native-hls-video-10.0.1.tgz", + "integrity": "sha512-MElgeDaxOJUj+OjHV3++TuW8TTgipTuzlRkfFDMMbnutlPLkSlX3VKhXo5ZNPhhXkt47oBVx4RGnAQJLWzilPw==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/media": "10.0.1", + "@videojs/utils": "10.0.1" + } + }, + "node_modules/@videojs/spf": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@videojs/spf/-/spf-10.0.1.tgz", + "integrity": "sha512-1HUTI1W++EKZuO3MaklFdEU/cEUhkJlKKuQx0Xi3VwjeHgVToAz7b1ySDH903KluPQy6cAGqXpro5Wt00y4DYA==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/media": "10.0.1", + "@videojs/utils": "10.0.1", + "signal-polyfill": "^0.2.2" + } + }, + "node_modules/@videojs/store": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@videojs/store/-/store-10.0.1.tgz", + "integrity": "sha512-BTkkTRHAU0XaZi+n3yWhtQJbe0BNjQLBd414u/s/nMZDz5bc1fp6b/voNbNriuSMd0Ro+ZmyzfPaMskiCa6GUQ==", + "license": "Apache-2.0", + "dependencies": { + "@videojs/utils": "10.0.1", + "react-compiler-runtime": "^1.0.0" + }, + "peerDependencies": { + "@videojs/element": "10.0.1", + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@videojs/element": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@videojs/utils": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@videojs/utils/-/utils-10.0.1.tgz", + "integrity": "sha512-S7WO9lPfyCz5YQYWtpE42JZTtJKUaiWaAZGAa+VLoBiFMfpoogCloSP4pjChTYcQ6sGcLlQJNxVKhM1RvI/Dig==", + "license": "Apache-2.0" + }, "node_modules/@webcomponents/shadycss": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@webcomponents/shadycss/-/shadycss-1.11.2.tgz", @@ -5367,6 +5543,12 @@ "he": "bin/he" } }, + "node_modules/hls.js": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.7.tgz", + "integrity": "sha512-QW2fnwDGKGc9DwQUGLbmMOz8G48UZK7PVNJPcOUql1b8jubKx4/eMHNP5mGqr6tYlJNDG1g10Lx2U/qPzL6zwQ==", + "license": "Apache-2.0" + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -7020,6 +7202,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-compiler-runtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-compiler-runtime/-/react-compiler-runtime-1.0.0.tgz", + "integrity": "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" + } + }, "node_modules/read-pkg": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", @@ -7466,6 +7667,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/signal-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/signal-polyfill/-/signal-polyfill-0.2.2.tgz", + "integrity": "sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==", + "license": "Apache-2.0" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8891,4 +9098,4 @@ } } } -} +} \ No newline at end of file diff --git a/cmd/oceanbench/package.json b/cmd/oceanbench/package.json index 7b71d7d7..d0370084 100644 --- a/cmd/oceanbench/package.json +++ b/cmd/oceanbench/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "cronstrue": "^3.30.0", + "@videojs/hlsjs-video": "^10.0.1", + "@videojs/html": "^10.0.1", "es-dev-server": "^2.1.0", "lit": "^2.6.1", "mime": "^3.0.0", @@ -30,4 +32,4 @@ "tailwindcss": "^4.3.1", "vite": "^8.1.0" } -} +} \ No newline at end of file diff --git a/cmd/oceanbench/s/broadcast.js b/cmd/oceanbench/s/broadcast.js index 41966c76..22a0df1e 100644 --- a/cmd/oceanbench/s/broadcast.js +++ b/cmd/oceanbench/s/broadcast.js @@ -208,13 +208,13 @@ async function handleBroadcastSelect(uuid) { const loadingOverlay = document.getElementById("loading-overlay"); if (loadingOverlay) loadingOverlay.classList.remove("d-none"); - const ytContainer = document.getElementById("youtube-preview-container"); - const ytFrame = document.getElementById("youtube-preview-frame"); - if (ytContainer) { - ytContainer.classList.add("d-none"); - ytContainer.classList.remove("d-flex"); + const previewContainer = document.getElementById("broadcast-preview-container"); + const preview = document.getElementById("broadcast-preview"); + if (previewContainer) { + previewContainer.classList.add("d-none"); + previewContainer.classList.remove("d-flex"); } - if (ytFrame) ytFrame.src = ""; + if (preview) preview.replaceChildren(); if (!uuid) { if (loadingOverlay) loadingOverlay.classList.add("d-none"); @@ -385,18 +385,51 @@ function populateForm(data) { reportSens.checked = !!data.SendMsg; } - // Youtube Preview + // Broadcast preview: an OceanMedia player or a YouTube embed. if (data.BID) { - const c = document.getElementById("youtube-preview-container"); - const frame = document.getElementById("youtube-preview-frame"); - if (c && frame) { - frame.src = `https://www.youtube.com/embed/${data.BID}`; + const c = document.getElementById("broadcast-preview-container"); + const preview = document.getElementById("broadcast-preview"); + if (c && preview) { + if (data.BroadcastHost === "oceanmedia") { + renderOceanMediaPreview(preview, data.UUID); + } else { + renderYouTubePreview(preview, data.BID); + } c.classList.remove("d-none"); c.classList.add("d-flex"); } } } +// renderYouTubePreview renders a YouTube embed into the given element. +function renderYouTubePreview(preview, bid) { + const frame = document.createElement("iframe"); + frame.width = "560"; + frame.height = "315"; + frame.src = `https://www.youtube.com/embed/${bid}`; + frame.frameBorder = "0"; + frame.allowFullscreen = true; + preview.replaceChildren(frame); +} + +// renderOceanMediaPreview fetches a viewer token and renders the OceanMedia +// player for the given broadcast UUID into the given element. +async function renderOceanMediaPreview(preview, uuid) { + try { + const res = await fetch(`/api/v1/broadcast/stream?uuid=${uuid}`); + if (!res.ok) { + throw new Error(await res.text()); + } + const data = await res.json(); + const player = document.createElement("oceanmedia-player"); + player.setAttribute("src", `${data.url}?token=${encodeURIComponent(data.token)}`); + preview.replaceChildren(player); + } catch (err) { + console.error("error loading OceanMedia stream:", err); + preview.replaceChildren(); + } +} + // fetchBroadcast fetches the broadcast configuration asynchronously via the API. // This is exposed to allow custom functionality or components to fetch things like the NotifySuppressRules. async function fetchBroadcast(uuid) { diff --git a/cmd/oceanbench/t/broadcast.html b/cmd/oceanbench/t/broadcast.html index 8e371a63..02f2a9cd 100644 --- a/cmd/oceanbench/t/broadcast.html +++ b/cmd/oceanbench/t/broadcast.html @@ -13,6 +13,8 @@ + + @@ -351,10 +353,8 @@

Advanced Settings

-
-
- -
+
+
diff --git a/cmd/oceanbench/t/tv-overview.html b/cmd/oceanbench/t/tv-overview.html index 85a845f7..b95deaf6 100644 --- a/cmd/oceanbench/t/tv-overview.html +++ b/cmd/oceanbench/t/tv-overview.html @@ -9,6 +9,8 @@ CloudBlue | TV Overview + + diff --git a/cmd/oceanbench/ts/global.d.ts b/cmd/oceanbench/ts/global.d.ts index fcf01bbe..4cbdc101 100644 --- a/cmd/oceanbench/ts/global.d.ts +++ b/cmd/oceanbench/ts/global.d.ts @@ -4,3 +4,10 @@ declare module "*.css" { const content: string; export default content; } + +// Tells TypeScript that .html files can be imported as raw string modules +// using Vite's ?raw suffix. +declare module "*.html?raw" { + const content: string; + export default content; +} diff --git a/cmd/oceanbench/ts/oceanmedia-player.ts b/cmd/oceanbench/ts/oceanmedia-player.ts new file mode 100644 index 00000000..2a52b5d5 --- /dev/null +++ b/cmd/oceanbench/ts/oceanmedia-player.ts @@ -0,0 +1,73 @@ +import "@videojs/html/live-video/player"; +import "@videojs/html/media/hlsjs-video"; +import "./videojs/live-video/skin"; +import skinHtml from "./videojs/live-video/skin.html?raw"; + +const MEDIA_PLACEHOLDER = ""; + +class OceanMediaPlayer extends HTMLElement { + static get observedAttributes(): string[] { + return ["src"]; + } + + connectedCallback(): void { + if (!this.hasChildNodes()) { + const media = this.hasAttribute("autoplay") ? "" : ""; + this.innerHTML = `${skinHtml.replace(MEDIA_PLACEHOLDER, media)}`; + } + + this.style.display = "block"; + if (!this.style.aspectRatio) { + this.style.aspectRatio = "16 / 9"; + } + + this.updateSrc(); + } + + attributeChangedCallback(): void { + this.updateSrc(); + } + + get src(): string { + return this.getAttribute("src") ?? ""; + } + + set src(value: string) { + this.setAttribute("src", value); + } + + private updateSrc(): void { + const media = this.querySelector("hlsjs-video") as HlsjsVideoElement | null; + if (!media || !this.src) { + return; + } + media.source = { + src: this.src, + engine: { + hlsJs: { + liveSyncDurationCount: 4, // stay ~4 target durations behind the edge + maxBufferLength: 60, // buffer up to 60s ahead + backBufferLength: 300, // keep 5 minutes for DVR seeking + }, + }, + }; + } +} + +type HlsjsVideoElement = HTMLElement & { + source: + | { + src: string; + engine?: { hlsJs?: Record }; + } + | null + | undefined; +}; + +customElements.define("oceanmedia-player", OceanMediaPlayer); + +declare global { + interface HTMLElementTagNameMap { + "oceanmedia-player": OceanMediaPlayer; + } +} diff --git a/cmd/oceanbench/ts/tv-overview.ts b/cmd/oceanbench/ts/tv-overview.ts index 7f9e0822..728242f6 100644 --- a/cmd/oceanbench/ts/tv-overview.ts +++ b/cmd/oceanbench/ts/tv-overview.ts @@ -31,6 +31,8 @@ interface broadcast { Account: string; // The YouTube account email that this broadcast is associated with. InFailure: boolean; // True if the broadcast is in a failure state. RecoveringVoltage: boolean; // True if the broadcast is currently recovering voltage. + BroadcastHost: string; // The broadcast host (e.g. "youtube", "oceanmedia"). + StreamURL?: string; // OceanMedia HLS playlist URL (with token), if applicable. } // TVOverview is an element designed to list all of the broadcasts that are important @@ -78,6 +80,12 @@ export class TVOverview extends LitElement { iframe { border-radius: 0.25rem; } + + oceanmedia-player { + width: 560px; + border-radius: 0.25rem; + overflow: hidden; + } `; async firstUpdated() { @@ -109,9 +117,7 @@ export class TVOverview extends LitElement {

${b.Name}

-
- -
+
${this.renderPlayer(b)}

${new Date(b.Start).toLocaleTimeString()} - ${new Date(b.End).toLocaleTimeString()}

@@ -130,6 +136,24 @@ export class TVOverview extends LitElement { `; } + // renderPlayer renders an OceanMedia player for OceanMedia broadcasts, + // and a YouTube embed for all other broadcasts. + private renderPlayer(b: broadcast): HTMLTemplateResult { + if (b.BroadcastHost === "oceanmedia") { + if (!b.StreamURL) { + return html` +

Stream unavailable.

+ `; + } + return html` + + `; + } + return html` + + `; + } + // fetchConfig makes an API request to get the config for the signed in user. private async fetchConfig() { console.log("getting tv overview config"); @@ -171,12 +195,41 @@ export class TVOverview extends LitElement { // Filter out any failed requests. this.broadcasts = resolvedBroadcasts.filter((b): b is broadcast => b !== undefined && b !== null); + // OceanMedia broadcasts need a playlist URL and viewer token, which are + // minted on demand by the stream endpoint. + await Promise.all( + this.broadcasts.map(async (b) => { + if (b.BroadcastHost === "oceanmedia") { + b.StreamURL = await this.getStreamURL(b.UUID); + } + }), + ); + console.log("finished fetching all broadcasts"); // This seems to be required, as otherwise a re-render isn't triggered. this.requestUpdate(); } + // getStreamURL fetches the OceanMedia playlist URL and viewer token for a + // broadcast, returning the full playlist URL (with token) or undefined. + private async getStreamURL(uuid: string): Promise { + try { + const resp = await fetch(`/api/v1/broadcast/stream?uuid=${uuid}`); + if (!resp.ok) { + throw await resp.text(); + } + const data = await resp.json(); + if (!data || !data.url) { + return undefined; + } + return `${data.url}?token=${encodeURIComponent(data.token)}`; + } catch (err) { + console.log("error getting OceanMedia stream:", err); + return undefined; + } + } + // getBroadcastWithUUID makes requests to the api endpoint to get the broadcast configuration // for a broadcast with the passed uuid, returning a promise for the fetch. private async getBroadcastWithUUID(uuid: string): Promise { diff --git a/cmd/oceanbench/ts/videojs/live-video/skin.css b/cmd/oceanbench/ts/videojs/live-video/skin.css new file mode 100644 index 00000000..7b457006 --- /dev/null +++ b/cmd/oceanbench/ts/videojs/live-video/skin.css @@ -0,0 +1,1959 @@ +@layer components { + @scope (.media-skin[data-theme="default"]) { + .media-button { + width: var(--media-control-size); + height: var(--media-control-size); + cursor: pointer; + touch-action: manipulation; + border-radius: var(--media-control-radius); + text-align: center; + min-height: 0; + color: inherit; + outline-offset: -2px; + transition-property: background-color, color, outline-offset, scale; + transition-duration: var(--media-duration); + will-change: scale; + -webkit-user-select: none; + user-select: none; + corner-shape: var(--media-control-corner-shape); + background-color: #0000; + border-style: solid; + border-width: 0; + outline: 2px solid #0000; + flex-shrink: 0; + place-items: center; + padding: 0; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + display: grid; + } + + @media (hover: hover) { + .media-button:not([aria-disabled="true"]):hover { + background-color: var(--media-accent); + color: var(--media-accent-foreground); + } + } + + .media-button:focus-visible { + outline-offset: 2px; + outline-color: var(--media-ring); + } + + .media-button:not([aria-disabled="true"]):active { + scale: 0.97; + } + + .media-button[aria-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; + } + + @media (prefers-reduced-motion: reduce) { + .media-button { + will-change: auto; + transition-property: background-color, color; + scale: 1; + } + } + + .media-button:where(:focus-visible, [aria-expanded="true"], [data-highlighted]):where( + :not([aria-disabled="true"]) + ) { + background-color: var(--media-accent); + color: var(--media-accent-foreground); + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-dialog-actions { + gap: calc(var(--media-spacing) * 2); + flex-shrink: 0; + display: flex; + } + + .media-dialog-backdrop { + z-index: 40; + border-radius: inherit; + background-color: var(--media-backdrop); + opacity: 1; + backdrop-filter: var(--media-backdrop-filter-dialog); + transition-property: opacity; + transition-duration: 0.15s; + transition-delay: var(--media-delay-dialog); + transition-duration: var(--media-duration-dialog); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + position: absolute; + inset: 0; + } + + @supports (color: color-mix(in lab, red, red)) { + .media-dialog-backdrop { + background-color: color-mix(in oklab, var(--media-backdrop) 20%, transparent); + } + } + + .media-dialog-backdrop:not([data-open]) { + display: none; + } + + .media-dialog-backdrop[data-ending-style] { + transition-delay: 0s; + transition-duration: var(--media-duration-slower); + } + + .media-dialog-backdrop:is([data-starting-style], [data-ending-style]) { + opacity: 0; + } + + .media-dialog-close { + height: var(--media-control-size); + width: 100%; + padding-inline: calc(var(--media-spacing) * 4); + padding-block: calc(var(--media-spacing) * 2); + flex: 1; + font-weight: 500; + background-color: var(--media-primary) !important; + color: var(--media-primary-foreground) !important; + } + + .media-dialog-content { + gap: calc(var(--media-spacing) * 2); + min-height: 0; + padding-inline: calc(var(--media-spacing) * 2); + padding-top: calc(var(--media-spacing) * 2); + padding-bottom: calc(var(--media-spacing) * 1.5); + flex-direction: column; + display: flex; + overflow-y: auto; + } + + .media-dialog-description { + overflow-wrap: anywhere; + opacity: 0.7; + margin: 0; + } + + .media-dialog-popup { + z-index: 50; + max-height: calc(100% - 0.5rem); + width: var(--media-dialog-width); + max-width: var(--media-dialog-max-width); + gap: calc(var(--media-spacing) * 3); + border-radius: var(--media-dialog-radius); + background-color: var(--media-popover); + padding: calc(var(--media-spacing) * 3); + color: var(--media-popover-foreground); + box-shadow: + 0 0 0 1px var(--media-surface-border, var(--media-border)), + var(--media-shadow-surface); + backdrop-filter: var(--media-backdrop-filter-surface); + transition-property: opacity, scale; + transition-duration: 0.15s; + transition-delay: var(--media-delay-dialog); + transition-duration: var(--media-duration-dialog); + text-shadow: var(--media-text-shadow-dialog); + outline-style: none; + flex-direction: column; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + display: flex; + position: absolute; + top: 50%; + left: 50%; + translate: -50% -50%; + } + + @media (forced-colors: active) { + .media-dialog-popup { + outline-offset: 2px; + outline: 2px solid #0000; + } + } + + .media-dialog-popup:not([data-open]) { + display: none; + } + + .media-dialog-popup:after { + content: ""; + z-index: 10; + pointer-events: none; + border-radius: inherit; + box-shadow: var(--media-shadow-surface-inset); + position: absolute; + inset: 0; + } + + .media-dialog-popup[data-ending-style] { + transition-delay: 0s; + transition-duration: var(--media-duration-slower); + } + + .media-dialog-popup:is([data-starting-style], [data-ending-style]) { + scale: var(--media-hidden-popup-scale) var(--media-hidden-popup-scale); + opacity: 0; + } + + .media-dialog-title { + font-size: calc(var(--media-spacing) * 3.75); + margin: 0; + font-weight: 600; + line-height: 1.25; + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-buffering-indicator { + pointer-events: none; + color: var(--media-controls-foreground); + place-content: center; + display: none; + position: absolute; + inset: 0; + } + + .media-buffering-indicator:not([data-visible]) { + --media-spinner-animation: none; + } + + .media-buffering-indicator:before { + background-color: var(--media-backdrop); + content: ""; + backdrop-filter: var(--media-backdrop-filter-indicator); + position: absolute; + inset: 0; + } + + @supports (color: color-mix(in lab, red, red)) { + .media-buffering-indicator:before { + background-color: color-mix(in oklab, var(--media-backdrop) 35%, transparent); + } + } + + .media-buffering-indicator[data-visible] { + display: grid; + } + + .media-buffering-indicator-spinner-icon { + z-index: 30; + width: var(--media-icon-size, calc(var(--media-spacing) * 4.5)); + height: var(--media-icon-size, calc(var(--media-spacing) * 4.5)); + filter: drop-shadow(0 1px 0 var(--media-shadow-current-color)); + position: relative; + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-poster { + pointer-events: none; + border-radius: inherit; + width: 100%; + height: 100%; + transition-property: opacity; + transition-duration: 0.15s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: var(--media-duration-slower); + position: absolute; + inset: 0; + } + + .media-poster:not([data-visible]) { + opacity: 0; + } + + .media-poster-image { + border-radius: inherit; + object-fit: var(--media-object-fit, contain); + object-position: var(--media-object-position, center); + width: 100%; + height: 100%; + position: absolute; + inset: 0; + } + + .media-poster-image:not([src]):not([srcset]) { + visibility: hidden; + } + } + + .media-poster > slot::slotted(img) { + border-radius: inherit; + object-fit: var(--media-object-fit, contain); + object-position: var(--media-object-position, center); + width: 100%; + height: 100%; + position: absolute; + inset: 0; + height: 100% !important; + } + + .media-poster > slot::slotted(img:not([src]):not([srcset])) { + visibility: hidden; + } + + :where(.media-skin[data-theme="default"]) .media-poster { + pointer-events: none; + border-radius: inherit; + width: 100%; + height: 100%; + transition-property: opacity; + transition-duration: 0.15s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: var(--media-duration-slower); + position: absolute; + inset: 0; + } + + :where(.media-skin[data-theme="default"]) .media-poster:not([data-visible]) { + opacity: 0; + } + + :where(.media-skin[data-theme="default"]) .media-poster-image { + border-radius: inherit; + object-fit: var(--media-object-fit, contain); + object-position: var(--media-object-position, center); + width: 100%; + height: 100%; + position: absolute; + inset: 0; + } + + :where(.media-skin[data-theme="default"]) .media-poster-image:not([src]):not([srcset]) { + visibility: hidden; + } + + @scope (.media-skin[data-theme="default"]) { + .media-title { + pointer-events: none; + isolation: isolate; + z-index: 20; + min-height: calc(var(--media-control-size) + (calc(var(--media-spacing) * 2))); + transform-origin: top; + padding-inline: calc(var(--media-spacing) * 6); + padding-top: calc(var(--media-spacing) * 2.5); + font-size: calc(var(--media-spacing) * 3.25); + letter-spacing: -0.0125em; + overflow-wrap: anywhere; + color: var(--media-controls-foreground); + transition-property: filter, opacity, scale, translate; + transition-duration: 0.15s; + transition-duration: calc(var(--media-duration-controls) / 2); + text-shadow: 0 1px 0 var(--media-shadow-current-color); + inset-inline: 0; + align-items: center; + font-weight: 500; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + display: flex; + position: absolute; + top: 0; + } + + .media-title:not([data-visible]) { + translate: 0 calc(var(--media-hidden-offset) * -1); + scale: var(--media-hidden-scale) var(--media-hidden-scale); + opacity: 0; + transition-duration: var(--media-duration-controls); + } + + @media (pointer: fine) { + .media-title:not([data-visible]) { + filter: blur(var(--media-hidden-blur)); + } + } + + @container media-root (width >= 28rem) { + .media-title { + padding-inline: calc(var(--media-spacing) * 6); + padding-top: calc(var(--media-spacing) * 4); + font-size: calc(var(--media-spacing) * 4.25); + } + } + + @container media-root (width < 32rem) { + .media-title { + padding-right: calc(var(--media-spacing) * 36); + } + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-container, + :scope.media-container { + isolation: isolate; + border-radius: var(--media-video-border-radius); + background-color: var(--media-background); + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; + font-family: var(--media-font-family, "Inter Variable", Inter, ui-sans-serif, system-ui, sans-serif); + font-size: calc(var(--media-spacing) * 3.25); + -webkit-font-smoothing: auto; + -moz-osx-font-smoothing: auto; + outline-offset: -4px; + transition-property: outline-offset, outline-color; + transition-duration: 0.15s; + transition-duration: var(--media-duration-fast); + --spacing: var(--media-spacing); + outline: 2px solid #0000; + line-height: 1.5; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + display: block; + position: relative; + overflow: clip; + container: media-root / inline-size; + } + + .media-container:after, + :scope.media-container:after { + pointer-events: none; + z-index: 10; + border-radius: inherit; + content: ""; + border-style: solid; + border-width: 1px; + border-color: var(--media-frame-border); + position: absolute; + inset: 0; + } + + .media-container:focus-visible, + :scope.media-container:focus-visible { + outline-offset: 2px; + outline-color: var(--media-ring); + } + + .media-container:fullscreen:after, + :scope.media-container:fullscreen:after { + content: ""; + display: none; + } + } + + .media-container > slot::slotted(video), + :scope.media-container > slot::slotted(video) { + border-radius: inherit; + object-fit: var(--media-object-fit, contain); + object-position: var(--media-object-position, center); + width: 100%; + max-width: 100%; + height: 100%; + margin: 0; + display: block; + } + + @scope (.media-skin[data-theme="default"]) { + .media-status-announcer { + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + position: absolute; + overflow: hidden; + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-indicator { + pointer-events: none; + top: calc(var(--media-spacing) * 3); + transform-origin: top; + border-radius: var(--media-control-radius); + background-color: var(--media-backdrop); + color: var(--media-popover-foreground); + box-shadow: + 0 0 0 1px var(--media-surface-border, var(--media-border)), + var(--media-shadow-surface); + backdrop-filter: var(--media-backdrop-filter-surface); + transition-duration: var(--media-duration-fast); + font-weight: 500; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + position: absolute; + } + + @supports (color: color-mix(in lab, red, red)) { + .media-indicator { + background-color: color-mix(in oklab, var(--media-backdrop) 25%, transparent); + } + } + + .media-indicator:after { + content: ""; + z-index: 10; + pointer-events: none; + border-radius: inherit; + box-shadow: var(--media-shadow-surface-inset); + position: absolute; + inset: 0; + } + + .media-indicator[data-ending-style] { + translate: 0 var(--media-hidden-indicator-offset); + } + + @media (pointer: coarse) { + .media-indicator { + will-change: scale, translate, opacity; + transition-property: scale, translate, opacity; + } + } + + @media (pointer: fine) { + .media-indicator { + will-change: scale, translate, filter, opacity; + transition-property: scale, translate, filter, opacity; + } + } + + @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { + .media-indicator { + background-color: var(--media-background); + } + } + + .media-indicator:is([data-starting-style], [data-ending-style]) { + opacity: 0; + transition-duration: var(--media-duration-indicator); + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); + } + + @media (pointer: fine) { + .media-indicator:is([data-starting-style], [data-ending-style]) { + scale: var(--media-hidden-indicator-scale) var(--media-hidden-indicator-scale); + filter: blur(var(--media-hidden-blur)); + } + } + + .media-indicator-content { + justify-content: space-between; + align-items: center; + gap: calc(var(--media-spacing) * 2); + padding-inline: calc(var(--media-spacing) * 2.5); + padding-block: var(--media-spacing); + } + + .media-playback-status-indicator { + background-color: var(--media-backdrop); + padding: calc(var(--media-spacing) * 4); + text-align: center; + backdrop-filter: var(--media-backdrop-filter-indicator); + transition-property: opacity, scale; + transition-duration: 0.15s; + transition-duration: var(--media-duration-slow); + border-radius: 9999px; + grid-row-start: 1; + grid-column-start: 2; + place-content: center; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + display: grid; + } + + @supports (color: color-mix(in lab, red, red)) { + .media-playback-status-indicator { + background-color: color-mix(in oklab, var(--media-backdrop) 35%, transparent); + } + } + + .media-playback-status-indicator[data-ending-style] { + transition-duration: var(--media-duration-fast); + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); + } + + .media-playback-status-indicator:is([data-starting-style], [data-ending-style]) { + scale: var(--media-hidden-playback-scale) var(--media-hidden-playback-scale); + opacity: 0; + } + + .media-playback-status-indicator-pause-icon { + width: calc(var(--media-icon-size, calc(var(--media-spacing) * 4.5)) * 1.5); + height: calc(var(--media-icon-size, calc(var(--media-spacing) * 4.5)) * 1.5); + scale: var(--media-hidden-icon-scale) var(--media-hidden-icon-scale); + opacity: 0; + transition-property: opacity, scale; + transition-duration: 0.15s; + transition-duration: var(--media-duration); + grid-row-start: 1; + grid-column-start: 1; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + } + + @scope (.media-playback-status-indicator) { + &[data-status="pause"] .media-playback-status-indicator-pause-icon { + opacity: 1; + scale: 1; + } + } + + .media-playback-status-indicator-play-icon { + width: calc(var(--media-icon-size, calc(var(--media-spacing) * 4.5)) * 1.5); + height: calc(var(--media-icon-size, calc(var(--media-spacing) * 4.5)) * 1.5); + scale: var(--media-hidden-icon-scale) var(--media-hidden-icon-scale); + opacity: 0; + transition-property: opacity, scale; + transition-duration: 0.15s; + transition-duration: var(--media-duration); + grid-row-start: 1; + grid-column-start: 1; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + } + + @scope (.media-playback-status-indicator) { + &[data-status="play"] .media-playback-status-indicator-play-icon { + opacity: 1; + translate: 1px; + scale: 1; + } + } + + .media-status-indicator-captions-off-icon { + mix-blend-mode: difference; + flex-shrink: 0; + display: none; + } + + @scope (.media-status-indicator) { + &[data-status="captions-off"] .media-status-indicator-captions-off-icon { + display: block; + } + } + + .media-status-indicator-captions-on-icon { + mix-blend-mode: difference; + flex-shrink: 0; + display: none; + } + + @scope (.media-status-indicator) { + &[data-status="captions-on"] .media-status-indicator-captions-on-icon { + display: block; + } + } + + .media-status-indicator-content { + width: 100%; + display: flex; + } + + .media-status-indicator-fullscreen-enter-icon { + mix-blend-mode: difference; + flex-shrink: 0; + display: none; + } + + @scope (.media-status-indicator) { + &[data-status="fullscreen"] .media-status-indicator-fullscreen-enter-icon { + display: block; + } + } + + .media-status-indicator-fullscreen-exit-icon { + mix-blend-mode: difference; + flex-shrink: 0; + display: none; + } + + @scope (.media-status-indicator) { + &[data-status="exit-fullscreen"] .media-status-indicator-fullscreen-exit-icon { + display: block; + } + } + + .media-status-indicator-pip-enter-icon { + mix-blend-mode: difference; + flex-shrink: 0; + display: none; + } + + @scope (.media-status-indicator) { + &[data-status="pip"] .media-status-indicator-pip-enter-icon { + display: block; + } + } + + .media-status-indicator-pip-exit-icon { + mix-blend-mode: difference; + flex-shrink: 0; + display: none; + } + + @scope (.media-status-indicator) { + &[data-status="exit-pip"] .media-status-indicator-pip-exit-icon { + display: block; + } + } + + .media-status-indicator-value { + mix-blend-mode: difference; + margin-left: auto; + } + } + + @scope (.media-skin[data-theme="default"]) { + @supports (color: color-mix(in lab, red, red)) { + } + + @media (pointer: coarse) { + } + + @media (pointer: fine) { + } + + @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { + } + + @media (pointer: fine) { + } + + .media-volume-indicator { + width: min(80%, 12rem); + transform: translateX(0); + } + + @media (prefers-reduced-motion: no-preference) { + .media-volume-indicator:is([data-min], [data-max]):not([data-starting-style], [data-ending-style]) { + transition: transform 0.3s linear(0, -24 20%, 16 40%, -8 60%, 4 80%, 1); + transform: translateX(0.25px); + } + } + + .media-volume-indicator-fill { + border-radius: inherit; + background-image: linear-gradient(currentColor, currentColor); + background-size: var(--media-volume-fill, 0%) 100%; + width: 100%; + transition-property: background-size; + transition-duration: 0.15s; + transition-duration: var(--media-duration-slow); + background-position: 0; + background-repeat: no-repeat; + transition-timing-function: linear; + display: flex; + } + + .media-volume-indicator-high-icon { + mix-blend-mode: difference; + flex-shrink: 0; + display: none; + } + + @scope (.media-volume-indicator) { + &[data-level="high"] .media-volume-indicator-high-icon { + display: block; + } + } + + .media-volume-indicator-low-icon { + mix-blend-mode: difference; + flex-shrink: 0; + display: none; + } + + @scope (.media-volume-indicator) { + &[data-level="low"] .media-volume-indicator-low-icon { + display: block; + } + } + + .media-volume-indicator-off-icon { + mix-blend-mode: difference; + flex-shrink: 0; + display: none; + } + + @scope (.media-volume-indicator) { + &[data-level="off"] .media-volume-indicator-off-icon { + display: block; + } + } + + .media-volume-indicator-value { + mix-blend-mode: difference; + margin-left: auto; + } + } + + @scope (.media-skin[data-theme="default"][data-preset="live-video"]) { + .video-status-indicators { + pointer-events: none; + z-index: 20; + color: var(--media-controls-foreground); + grid-template-columns: repeat(3, minmax(0, 1fr)); + place-items: center; + display: grid; + position: absolute; + inset: 0; + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-airplay-button:not([data-airplay-state="connected"]) { + --media-icon-airplay-fill-animation: none; + --media-icon-airplay-triangle-animation: none; + } + + .media-airplay-button-enter-icon { + opacity: 0; + } + + @scope (.media-airplay-button) { + &:not([data-airplay-state="connected"]) .media-airplay-button-enter-icon { + opacity: 1; + scale: 1; + } + } + + .media-airplay-button-exit-icon { + opacity: 0; + } + + @scope (.media-airplay-button) { + &[data-airplay-state="connected"] .media-airplay-button-exit-icon { + opacity: 1; + scale: 1; + } + } + + .media-button-icon { + width: var(--media-icon-size, calc(var(--media-spacing) * 4.5)); + height: var(--media-icon-size, calc(var(--media-spacing) * 4.5)); + filter: drop-shadow(0 1px 0 var(--media-shadow-current-color)); + transition-property: opacity, scale; + transition-duration: 0.15s; + transition-duration: var(--media-duration); + text-shadow: inherit; + grid-row-start: 1; + grid-column-start: 1; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-popup { + color: inherit; + border-style: solid; + border-width: 0; + margin: 0; + overflow: visible; + } + + .media-popup[data-ending-style] { + transform: none; + } + + .media-popup[data-starting-style] { + transform: translate(var(--media-popup-translate-x-distance, 0), var(--media-popup-translate-y-distance, 0)); + } + + .media-popup[data-side="bottom"] { + transform-origin: top; + --media-popup-translate-y-distance: calc(var(--media-popup-translate-distance) * -1); + } + + .media-popup[data-side="left"] { + transform-origin: 100%; + --media-popup-translate-x-distance: var(--media-popup-translate-distance); + } + + .media-popup[data-side="right"] { + transform-origin: 0; + --media-popup-translate-x-distance: calc(var(--media-popup-translate-distance) * -1); + } + + .media-popup[data-side="top"] { + transform-origin: bottom; + --media-popup-translate-y-distance: var(--media-popup-translate-distance); + } + + .media-popup:is([data-starting-style], [data-ending-style]) { + scale: var(--media-hidden-popup-scale) var(--media-hidden-popup-scale); + opacity: 0; + filter: blur(var(--media-hidden-popup-blur)); + } + + .media-popup-safe-area:before { + pointer-events: auto; + content: ""; + position: absolute; + } + + .media-popup-safe-area[data-side="bottom"]:before { + content: ""; + height: var(--media-popup-side-offset); + inset-inline: 0; + bottom: 100%; + } + + .media-popup-safe-area[data-side="left"]:before { + content: ""; + width: var(--media-popup-side-offset); + inset-block: 0; + left: 100%; + } + + .media-popup-safe-area[data-side="right"]:before { + content: ""; + width: var(--media-popup-side-offset); + inset-block: 0; + right: 100%; + } + + .media-popup-safe-area[data-side="top"]:before { + content: ""; + height: var(--media-popup-side-offset); + inset-inline: 0; + top: 100%; + } + + .media-popup-surface { + background-color: var(--media-popover); + color: var(--media-popover-foreground); + box-shadow: + 0 0 0 1px var(--media-surface-border, var(--media-border)), + var(--media-shadow-surface); + backdrop-filter: var(--media-backdrop-filter-surface); + } + + .media-popup-surface:after { + content: ""; + z-index: 10; + pointer-events: none; + border-radius: inherit; + box-shadow: var(--media-shadow-surface-inset); + position: absolute; + inset: 0; + } + + .media-popup-transition { + transition-property: opacity, filter, transform, scale; + transition-duration: var(--media-duration-fast); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + } + + .media-popup-transition[data-ending-style] { + transition-duration: var(--media-duration-instant); + } + + .media-tooltip { + border-radius: var(--media-control-radius); + padding-inline: calc(var(--media-spacing) * 2.5); + padding-block: var(--media-spacing); + font-size: calc(var(--media-spacing) * 3.25); + white-space: nowrap; + --media-popup-side-offset: var(--media-tooltip-side-offset); + } + + .media-tooltip[data-open] { + align-items: center; + gap: var(--media-spacing); + display: flex; + } + + .media-tooltip-shortcut { + border-radius: var(--media-spacing); + background-color: var(--media-muted); + text-align: center; + min-width: 1.5em; + font-family: inherit; + font-size: calc(var(--media-spacing) * 2.75); + padding: 0.1em; + font-weight: 600; + line-height: 1.25; + } + } + + :where(.media-skin[data-theme="default"]) .media-popup-surface { + background-color: var(--media-popover); + color: var(--media-popover-foreground); + box-shadow: + 0 0 0 1px var(--media-surface-border, var(--media-border)), + var(--media-shadow-surface); + backdrop-filter: var(--media-backdrop-filter-surface); + } + + :where(.media-skin[data-theme="default"]) .media-popup-surface:after { + content: ""; + z-index: 10; + pointer-events: none; + border-radius: inherit; + box-shadow: var(--media-shadow-surface-inset); + position: absolute; + inset: 0; + } + + @scope (.media-skin[data-theme="default"]) { + .media-cast-button-enter-icon { + opacity: 0; + } + + @scope (.media-cast-button) { + &:not([data-cast-state="connected"]) .media-cast-button-enter-icon { + opacity: 1; + scale: 1; + } + } + + .media-cast-button-exit-icon { + opacity: 0; + } + + @scope (.media-cast-button) { + &[data-cast-state="connected"] .media-cast-button-exit-icon { + opacity: 1; + scale: 1; + } + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-fullscreen-button-enter-icon { + opacity: 0; + } + + @scope (.media-fullscreen-button) { + &:not([data-fullscreen]) .media-fullscreen-button-enter-icon { + opacity: 1; + scale: 1; + } + } + + .media-fullscreen-button-exit-icon { + opacity: 0; + } + + @scope (.media-fullscreen-button) { + &[data-fullscreen] .media-fullscreen-button-exit-icon { + opacity: 1; + scale: 1; + } + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-live-button { + align-items: center; + gap: calc(var(--media-spacing) * 1.5); + width: auto; + padding-inline: calc(var(--media-spacing) * 3); + padding-block: calc(var(--media-spacing) * 2); + font-size: calc(var(--media-spacing) * 2.75); + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 600; + line-height: 1; + display: inline-flex !important; + } + + .media-live-button:before { + width: calc(var(--media-spacing) * 2); + height: calc(var(--media-spacing) * 2); + transition-property: background-color; + transition-duration: 0.15s; + transition-duration: var(--media-duration); + content: ""; + background-color: currentColor; + border-radius: 9999px; + flex-shrink: 0; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + display: inline-block; + } + + @supports (color: color-mix(in lab, red, red)) { + .media-live-button:before { + background-color: color-mix(in oklab, currentcolor 40%, transparent); + } + } + + .media-live-button[data-live-edge]:before { + content: ""; + background-color: var(--media-live-color); + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-pip-button-enter-icon { + opacity: 0; + } + + @scope (.media-pip-button) { + &:not([data-pip]) .media-pip-button-enter-icon { + opacity: 1; + scale: 1; + } + } + + .media-pip-button-exit-icon { + opacity: 0; + } + + @scope (.media-pip-button) { + &[data-pip] .media-pip-button-exit-icon { + opacity: 1; + scale: 1; + } + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-play-button-pause-icon { + scale: var(--media-hidden-icon-scale) var(--media-hidden-icon-scale); + opacity: 0; + } + + @scope (.media-play-button) { + &[data-started]:not([data-paused]):not([data-ended]) .media-play-button-pause-icon { + opacity: 1; + scale: 1; + } + } + + .media-play-button-play-icon { + scale: var(--media-hidden-icon-scale) var(--media-hidden-icon-scale); + opacity: 0; + } + + @scope (.media-play-button) { + &:not([data-ended]):not([data-started]) .media-play-button-play-icon, + &:not([data-ended])[data-paused] .media-play-button-play-icon { + opacity: 1; + scale: 1; + } + } + + .media-play-button-restart-icon { + scale: var(--media-hidden-icon-scale) var(--media-hidden-icon-scale); + opacity: 0; + } + + @scope (.media-play-button) { + &[data-ended] .media-play-button-restart-icon { + opacity: 1; + scale: 1; + } + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-captions-button-off-icon { + opacity: 0; + } + + @scope (.media-captions-button) { + &:not([data-active]) .media-captions-button-off-icon { + opacity: 1; + scale: 1; + } + } + + .media-captions-button-on-icon { + opacity: 0; + } + + @scope (.media-captions-button) { + &[data-active] .media-captions-button-on-icon { + opacity: 1; + scale: 1; + } + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-menu-item-indicator { + opacity: 0; + flex-shrink: 0; + margin-inline-start: auto; + margin-inline-end: calc(var(--media-spacing) * -1); + } + + @scope (.media-menu-radio-item) { + &[aria-checked="true"] .media-menu-item-indicator { + opacity: 1; + } + } + + .media-menu-radio-item { + cursor: pointer; + justify-content: space-between; + align-items: center; + gap: calc(var(--media-spacing) * 1.5); + border-radius: var(--media-menu-item-radius); + padding-inline: calc(var(--media-spacing) * 2); + padding-block: calc(var(--media-spacing) * 1.5); + text-align: start; + white-space: nowrap; + color: inherit; + font-variant-numeric: tabular-nums; + outline-offset: -2px; + transition-property: background-color, color; + transition-duration: 0.15s; + transition-duration: var(--media-duration-fast); + -webkit-user-select: none; + user-select: none; + text-shadow: 0 1px 0 var(--media-shadow-current-color); + outline: 2px solid #0000; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + position: relative; + } + + .media-menu-radio-item:focus-visible { + outline-offset: 2px; + outline-color: var(--media-ring); + } + + .media-menu-radio-item[aria-disabled="true"] { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + } + + .media-menu-radio-item[data-availability="unavailable"], + .media-menu-radio-item[data-availability="unsupported"] { + display: none; + } + + .media-menu-radio-item:where(:focus-visible, [aria-expanded="true"], [data-highlighted]):where( + :not([aria-disabled="true"]) + ) { + background-color: var(--media-accent); + color: var(--media-accent-foreground); + } + + @supports (top: anchor(top)) { + .media-menu-radio-item { + transition-duration: var(--media-duration-instant); + } + + .media-menu-radio-item[data-highlighted] { + anchor-name: --media-menu-item-highlight-anchor; + } + + .media-menu-radio-item:where(:focus-visible, [aria-expanded="true"], [data-highlighted]):where( + :not([aria-disabled="true"]) + ) { + transition-duration: var(--media-duration-slow); + background-color: #0000; + } + } + + .media-menu-radio-item-icon { + width: var(--media-icon-size, calc(var(--media-spacing) * 4.5)); + height: var(--media-icon-size, calc(var(--media-spacing) * 4.5)); + color: var(--media-muted-foreground); + filter: drop-shadow(0 1px 0 var(--media-shadow-current-color)); + flex-shrink: 0; + } + + @scope (.media-menu-radio-item) { + &:where(:focus-visible, [aria-expanded="true"], [data-highlighted]):where(:not([aria-disabled="true"])) + .media-menu-radio-item-icon { + color: inherit; + } + } + } + + @scope (.media-skin[data-theme="default"]) { + } + + @scope (.media-skin[data-theme="default"]) { + .media-menu-content { + max-height: inherit; + overscroll-behavior: none; + transition-property: translate, filter; + transition-duration: 0.15s; + transition-duration: var(--media-duration-menu); + anchor-scope: --media-menu-item-highlight-anchor; + outline-style: none; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + position: absolute; + overflow: auto; + } + + @media (forced-colors: active) { + .media-menu-content { + outline-offset: 2px; + outline: 2px solid #0000; + } + } + + .media-menu-content:not([data-submenu]) { + inset-inline: var(--media-spacing); + top: var(--media-spacing); + gap: calc(var(--media-spacing) * 0.5); + flex-direction: column; + display: flex; + } + + .media-menu-content:not([data-submenu])[data-child-open] { + filter: blur(var(--media-hidden-blur)); + translate: -100%; + } + + .media-menu-content:not([data-submenu])[data-child-open]:before { + content: ""; + display: none; + } + + .media-menu-content[data-submenu] { + z-index: 10; + max-height: inherit; + padding: var(--media-spacing); + inset-inline: 0; + top: 0; + } + + .media-menu-content:not([data-submenu])[data-child-open]:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { + translate: 100%; + } + + .media-menu-content:is([data-starting-style], [data-ending-style]):before { + content: ""; + display: none; + } + + .media-menu-content[data-submenu]:is([data-starting-style], [data-ending-style]) { + pointer-events: none; + filter: blur(var(--media-hidden-blur)); + overflow: hidden; + translate: 100%; + } + + .media-menu-content[data-submenu]:is([data-starting-style], [data-ending-style]):where( + :dir(rtl), + [dir="rtl"], + [dir="rtl"] * + ) { + translate: -100%; + } + + @supports (top: anchor(top)) { + .media-menu-content:before { + content: ""; + position-anchor: --media-menu-item-highlight-anchor; + inset: anchor(inside); + overflow-anchor: none; + pointer-events: none; + border-radius: var(--media-menu-item-radius); + background-color: var(--media-accent); + transition: inset var(--media-duration-fast) ease-in-out; + position: absolute; + } + + .media-menu-content:has([data-highlighted=""]):before { + content: ""; + transition-duration: 0s; + } + } + + .media-menu-content:is([data-starting-style], [data-ending-style]) :before { + content: ""; + display: none; + } + + .media-menu-popup { + height: var(--media-menu-height); + max-height: min( + var(--media-menu-available-height, calc(var(--media-spacing) * 56)), + calc(var(--media-spacing) * 56) + ); + width: var(--media-menu-width); + max-width: var(--media-menu-available-width); + min-width: calc(var(--media-spacing) * 44); + overscroll-behavior: none; + border-radius: var(--media-popup-radius); + padding: var(--media-spacing); + transition-property: opacity, filter, transform, scale; + transition-duration: var(--media-duration-fast); + --media-popup-side-offset: var(--media-popover-side-offset); + border-style: solid; + border-width: 0; + margin: 0; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + overflow: hidden !important; + } + + .media-menu-popup:is([data-starting-style], [data-ending-style]) { + transition-property: opacity, filter, transform, scale; + transition-duration: var(--media-duration-fast); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + } + + .media-menu-radio-group { + max-height: inherit; + gap: calc(var(--media-spacing) * 0.5); + anchor-scope: --media-menu-item-highlight-anchor; + flex-direction: column; + display: flex; + } + + .media-menu-radio-group:is([data-starting-style], [data-ending-style]):before { + content: ""; + display: none; + } + + @supports (top: anchor(top)) { + .media-menu-radio-group:before { + content: ""; + position-anchor: --media-menu-item-highlight-anchor; + inset: anchor(inside); + overflow-anchor: none; + pointer-events: none; + border-radius: var(--media-menu-item-radius); + background-color: var(--media-accent); + transition: inset var(--media-duration-fast) ease-in-out; + position: absolute; + } + + .media-menu-radio-group:has([data-highlighted=""]):before { + content: ""; + transition-duration: 0s; + } + } + + .media-menu-radio-group:is([data-starting-style], [data-ending-style]) :before { + content: ""; + display: none; + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-mute-button-high-icon { + opacity: 0; + } + + @scope (.media-mute-button) { + &:not([data-muted]):not([data-volume-level="low"]) .media-mute-button-high-icon { + opacity: 1; + scale: 1; + } + } + + .media-mute-button-low-icon { + opacity: 0; + } + + @scope (.media-mute-button) { + &:not([data-muted])[data-volume-level="low"] .media-mute-button-low-icon { + opacity: 1; + scale: 1; + } + } + + .media-mute-button-off-icon { + opacity: 0; + } + + @scope (.media-mute-button) { + &[data-muted] .media-mute-button-off-icon { + opacity: 1; + scale: 1; + } + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-slider-buffer { + pointer-events: none; + position: absolute; + inset: 0; + } + + .media-slider-buffer:before { + border-radius: var(--media-control-radius); + content: ""; + background-color: currentColor; + width: 100%; + height: 100%; + position: absolute; + } + + @supports (color: color-mix(in lab, red, red)) { + .media-slider-buffer:before { + background-color: color-mix(in oklab, currentcolor 20%, transparent); + } + } + + .media-slider-buffer[data-orientation="horizontal"] { + left: calc(var(--media-slider-chapter-start, 0%) + var(--media-spacing) * var(--media-chapter-inset-start, 0)); + right: max( + calc(100% - var(--media-slider-buffer, 0%)), + calc(100% - var(--media-slider-chapter-end, 100%) + var(--media-spacing) * var(--media-chapter-inset-end, 0)) + ); + clip-path: inset(-1px 0); + } + + .media-slider-buffer[data-orientation="horizontal"]:before { + content: ""; + min-width: var(--media-spacing); + left: 0; + } + + .media-slider-buffer[data-orientation="vertical"] { + bottom: calc(var(--media-slider-chapter-start, 0%) + var(--media-spacing) * var(--media-chapter-inset-start, 0)); + top: max( + calc(100% - var(--media-slider-buffer, 0%)), + calc(100% - var(--media-slider-chapter-end, 100%) + var(--media-spacing) * var(--media-chapter-inset-end, 0)) + ); + clip-path: inset(0 -1px); + } + + .media-slider-buffer[data-orientation="vertical"]:before { + content: ""; + min-height: var(--media-spacing); + bottom: 0; + } + + .media-slider-fill { + pointer-events: none; + position: absolute; + inset: 0; + } + + .media-slider-fill:before { + border-radius: var(--media-control-radius); + content: ""; + background-color: var(--media-primary); + width: 100%; + height: 100%; + position: absolute; + } + + .media-slider-fill[data-orientation="horizontal"] { + left: calc(var(--media-slider-chapter-start, 0%) + var(--media-spacing) * var(--media-chapter-inset-start, 0)); + right: max( + calc(100% - var(--media-slider-fill, 0%)), + calc(100% - var(--media-slider-chapter-end, 100%) + var(--media-spacing) * var(--media-chapter-inset-end, 0)) + ); + clip-path: inset(-1px 0); + } + + @scope (.media-slider) { + &[data-dragging] .media-slider-fill[data-orientation="horizontal"] { + left: calc(var(--media-slider-chapter-start, 0%) + var(--media-spacing) * var(--media-chapter-inset-start, 0)); + right: max( + calc(100% - var(--media-slider-pointer, 0%)), + calc(100% - var(--media-slider-chapter-end, 100%) + var(--media-spacing) * var(--media-chapter-inset-end, 0)) + ); + clip-path: inset(-1px 0); + } + } + + .media-slider-fill[data-orientation="horizontal"]:before { + content: ""; + min-width: var(--media-spacing); + left: 0; + } + + .media-slider-fill[data-orientation="vertical"] { + bottom: calc(var(--media-slider-chapter-start, 0%) + var(--media-spacing) * var(--media-chapter-inset-start, 0)); + top: max( + calc(100% - var(--media-slider-fill, 0%)), + calc(100% - var(--media-slider-chapter-end, 100%) + var(--media-spacing) * var(--media-chapter-inset-end, 0)) + ); + clip-path: inset(0 -1px); + } + + @scope (.media-slider) { + &[data-dragging] .media-slider-fill[data-orientation="vertical"] { + bottom: calc( + var(--media-slider-chapter-start, 0%) + var(--media-spacing) * var(--media-chapter-inset-start, 0) + ); + top: max( + calc(100% - var(--media-slider-pointer, 0%)), + calc(100% - var(--media-slider-chapter-end, 100%) + var(--media-spacing) * var(--media-chapter-inset-end, 0)) + ); + clip-path: inset(0 -1px); + } + } + + .media-slider-fill[data-orientation="vertical"]:before { + content: ""; + min-height: var(--media-spacing); + bottom: 0; + } + + .media-slider-thumb { + top: 50%; + left: var(--media-slider-fill, 0%); + z-index: 10; + width: calc(var(--media-spacing) * 3); + height: calc(var(--media-spacing) * 3); + border-radius: var(--media-control-radius); + box-shadow: + 0 0 #0000, + 0 0 #0000, + 0 0 #0000, + 0 0 #0000, + var(--media-shadow-thumb); + outline-offset: -4px; + transition-property: opacity, height, width, outline-offset, scale; + transition-duration: 0.15s; + transition-duration: var(--media-duration-slider); + -webkit-user-select: none; + user-select: none; + background-color: #fff; + outline: 4px solid #0000; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + position: absolute; + translate: -50% -50%; + } + + @scope (.media-slider) { + &[data-dragging] .media-slider-thumb { + scale: 0.9; + } + } + + .media-slider-thumb:after { + pointer-events: none; + inset: calc(var(--media-spacing) * -1); + border-radius: inherit; + opacity: 0; + transition-property: opacity, scale; + transition-duration: 0.15s; + transition-duration: var(--media-duration); + content: ""; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + position: absolute; + scale: 0.5; + box-shadow: + 0 0 #0000, + 0 0 #0000, + 0 0 #0000, + 0 0 #0000, + 0 0 0 2px; + } + + @media (hover: hover) { + .media-slider-thumb:hover { + outline-offset: 0px; + outline-color: currentColor; + } + + @supports (color: color-mix(in lab, red, red)) { + .media-slider-thumb:hover { + outline-color: color-mix(in oklab, currentcolor 15%, transparent); + } + } + } + + .media-slider-thumb:focus-visible { + outline-offset: 0px; + outline-color: currentColor; + } + + @supports (color: color-mix(in lab, red, red)) { + .media-slider-thumb:focus-visible { + outline-color: color-mix(in oklab, currentcolor 15%, transparent); + } + } + + .media-slider-thumb:focus-visible:after { + content: ""; + opacity: 1; + scale: 1; + } + + @scope (.media-slider) { + &[data-dragging] .media-slider-thumb[data-orientation="horizontal"] { + left: var(--media-slider-pointer); + } + } + + .media-slider-thumb[data-orientation="vertical"] { + top: calc(100% - var(--media-slider-fill, 0%)); + left: 50%; + } + + @scope (.media-slider) { + &[data-dragging] .media-slider-thumb[data-orientation="vertical"] { + top: calc(100% - var(--media-slider-pointer)); + } + } + + .media-slider-track { + isolation: isolate; + -webkit-user-select: none; + user-select: none; + border-radius: 9999px; + width: 100%; + position: relative; + } + + .media-slider-track:before { + pointer-events: none; + border-radius: var(--media-control-radius); + content: ""; + background-color: currentColor; + position: absolute; + inset: 0; + } + + @supports (color: color-mix(in lab, red, red)) { + .media-slider-track:before { + background-color: color-mix(in oklab, currentcolor 20%, transparent); + } + } + + .media-slider-track[data-orientation="horizontal"] { + height: var(--media-spacing); + } + + .media-slider-track[data-orientation="vertical"] { + height: 100%; + width: var(--media-spacing); + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-slider { + cursor: pointer; + transition-property: --media-slider-fill, --media-slider-buffer; + transition-duration: 0.15s; + transition-duration: var(--media-duration-slider); + border-radius: 9999px; + outline-style: none; + flex: 1; + justify-content: center; + align-items: center; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + display: flex; + position: relative; + } + + @media (forced-colors: active) { + .media-slider { + outline-offset: 2px; + outline: 2px solid #0000; + } + } + + .media-slider[data-disabled] { + pointer-events: none; + } + + .media-slider[data-dragging] { + transition-duration: 0s; + } + + .media-slider[data-orientation="horizontal"] { + height: var(--media-slider-height, calc(var(--media-spacing) * 8)); + min-width: calc(var(--media-spacing) * 18); + } + + .media-slider[data-orientation="vertical"] { + height: calc(var(--media-spacing) * 18); + width: calc(var(--media-spacing) * 8); + min-width: 0; + } + + .media-volume-slider-thumb { + opacity: 1; + scale: 1; + } + } + + @scope (.media-skin[data-theme="default"]) { + .media-volume-popover { + border-radius: var(--media-control-radius); + padding-inline: 0; + padding-block: calc(var(--media-spacing) * 3); + --media-popup-side-offset: var(--media-popover-side-offset); + } + + .media-volume-popover[data-side="right"] { + padding: 0; + padding-inline: calc(var(--media-spacing) * 3); + --media-popover-side-offset: 0rem; + border-radius: 0; + box-shadow: none !important; + backdrop-filter: none !important; + background-color: #0000 !important; + } + + .media-volume-popover[data-side="right"]:after { + content: ""; + display: none; + } + } + + @scope (.media-skin[data-theme="default"][data-preset="live-video"]) { + .video-controls-backdrop { + pointer-events: none; + z-index: 10; + border-radius: inherit; + background-image: var(--media-controls-gradient); + transition-property: opacity; + transition-duration: 0.15s; + transition-duration: var(--media-duration-controls); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + position: absolute; + inset: 0; + } + + .video-controls-backdrop:not([data-visible]) { + opacity: 0; + } + + .video-controls-content { + padding: var(--media-spacing); + color: var(--media-controls-foreground); + transition-property: filter, opacity, scale, translate; + transition-duration: 0.15s; + transition-duration: calc(var(--media-duration-controls) / 2); + text-shadow: 0 1px 0 var(--media-shadow-current-color); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + display: contents; + } + + @container media-root (width >= 32rem) { + .video-controls-content { + inset-inline: calc(var(--media-spacing) * 2); + bottom: calc(var(--media-spacing) * 2); + z-index: 30; + border-radius: var(--media-controls-radius); + background-color: var(--media-popover); + color: var(--media-popover-foreground); + box-shadow: + 0 0 0 1px var(--media-surface-border, var(--media-border)), + var(--media-shadow-surface); + backdrop-filter: var(--media-backdrop-filter-surface); + align-items: center; + display: flex; + position: absolute; + } + + .video-controls-content:not([data-visible]) { + pointer-events: none; + translate: 0 var(--media-hidden-offset); + scale: var(--media-hidden-scale) var(--media-hidden-scale); + opacity: 0; + transition-duration: var(--media-duration-controls); + } + + .video-controls-content:after { + content: ""; + z-index: 10; + pointer-events: none; + border-radius: inherit; + box-shadow: var(--media-shadow-surface-inset); + position: absolute; + inset: 0; + } + + .video-controls-content:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { + flex-direction: row-reverse; + } + + @media (pointer: fine) { + .video-controls-content:not([data-visible]) { + filter: blur(var(--media-hidden-blur)); + } + } + } + + @container media-root (width >= 42rem) { + .video-controls-content { + inset-inline: calc(var(--media-spacing) * 3); + bottom: calc(var(--media-spacing) * 3); + } + } + + .video-controls-primary { + inset-inline: calc(var(--media-spacing) * 2); + bottom: calc(var(--media-spacing) * 2); + z-index: 30; + transform-origin: bottom; + border-radius: var(--media-controls-radius); + padding: calc(var(--media-spacing) * 0.5); + transition-property: filter, opacity, scale, translate; + transition-duration: 0.15s; + transition-duration: calc(var(--media-duration-controls) / 2); + align-items: center; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + display: flex; + position: absolute; + } + + .video-controls-primary:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { + flex-direction: row-reverse; + } + + @container media-root (width >= 32rem) { + .video-controls-primary { + display: contents; + } + } + + @container media-root (width < 32rem) { + .video-controls-primary { + background-color: var(--media-popover); + color: var(--media-popover-foreground); + box-shadow: + 0 0 0 1px var(--media-surface-border, var(--media-border)), + var(--media-shadow-surface); + backdrop-filter: var(--media-backdrop-filter-surface); + } + + @scope (.video-controls-content) { + &:not([data-visible]) .video-controls-primary { + pointer-events: none; + translate: 0 var(--media-hidden-offset); + scale: var(--media-hidden-scale) var(--media-hidden-scale); + opacity: 0; + transition-duration: var(--media-duration-controls); + } + } + + .video-controls-primary:after { + content: ""; + z-index: 10; + pointer-events: none; + border-radius: inherit; + box-shadow: var(--media-shadow-surface-inset); + position: absolute; + inset: 0; + } + + @media (pointer: fine) { + @scope (.video-controls-content) { + &:not([data-visible]) .video-controls-primary { + filter: blur(var(--media-hidden-blur)); + } + } + } + } + + .video-controls-spacer { + flex: 1; + } + } + + @scope (.media-skin[data-theme="default"][data-preset="live-video"]) { + @container media-root (width < 32rem) { + .video-controls-captions-menu { + display: none; + } + } + + .video-controls-spaced { + gap: 1px; + } + } + + @scope (.media-skin[data-theme="default"][data-preset="live-video"]) { + @media (pointer: fine) { + .video-skin:not([data-controls-visible]), + :scope.video-skin:not([data-controls-visible]) { + cursor: none; + } + } + } +} + +/* OceanMedia: time slider added to the live-video skin for DVR scrubbing. */ +.oceanmedia-time-slider { + flex: 1 1 auto; + min-width: 4rem; +} diff --git a/cmd/oceanbench/ts/videojs/live-video/skin.html b/cmd/oceanbench/ts/videojs/live-video/skin.html new file mode 100644 index 00000000..6e14a4d3 --- /dev/null +++ b/cmd/oceanbench/ts/videojs/live-video/skin.html @@ -0,0 +1,156 @@ + + + + + + + + + + + +
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + +
+
+ + + + +
+
diff --git a/cmd/oceanbench/ts/videojs/live-video/skin.ts b/cmd/oceanbench/ts/videojs/live-video/skin.ts new file mode 100644 index 00000000..c7583086 --- /dev/null +++ b/cmd/oceanbench/ts/videojs/live-video/skin.ts @@ -0,0 +1,72 @@ +import "../styles/video/base.css"; +import "./skin.css"; +import "@videojs/html/ui/container"; +import "@videojs/html/ui/poster"; +import "@videojs/html/ui/buffering-indicator"; +import "@videojs/html/ui/error-dialog"; +import "@videojs/html/ui/dialog-backdrop"; +import "@videojs/html/ui/dialog-popup"; +import "@videojs/html/ui/dialog-title"; +import "@videojs/html/ui/dialog-description"; +import "@videojs/html/ui/dialog-close"; +import "@videojs/html/ui/title"; +import "@videojs/html/ui/controls"; +import "@videojs/html/ui/controls-backdrop"; +import "@videojs/html/ui/controls-content"; +import "@videojs/html/ui/tooltip-group"; +import "@videojs/html/ui/controls-group"; +import "@videojs/html/ui/play-button"; +import "@videojs/html/ui/tooltip"; +import "@videojs/html/ui/tooltip-label"; +import "@videojs/html/ui/tooltip-shortcut"; +import "@videojs/html/ui/live-button"; +import "@videojs/html/ui/mute-button"; +import "@videojs/html/ui/volume-popover"; +import "@videojs/html/ui/volume-slider"; +import "@videojs/html/ui/slider-track"; +import "@videojs/html/ui/slider-fill"; +import "@videojs/html/ui/slider-thumb"; +import "@videojs/html/ui/slider-buffer"; +import "@videojs/html/ui/slider-value"; +import "@videojs/html/ui/time-slider"; +import "@videojs/html/ui/captions-button"; +import "@videojs/html/ui/menu"; +import "@videojs/html/ui/menu-content"; +import "@videojs/html/ui/captions-radio-group"; +import "@videojs/html/ui/menu-radio-item"; +import "@videojs/html/ui/menu-item-indicator"; +import "@videojs/html/ui/cast-button"; +import "@videojs/html/ui/airplay-button"; +import "@videojs/html/ui/pip-button"; +import "@videojs/html/ui/fullscreen-button"; +import "@videojs/html/ui/hotkey"; +import "@videojs/html/ui/gesture"; +import "@videojs/html/ui/status-announcer"; +import "@videojs/html/ui/volume-indicator"; +import "@videojs/html/ui/volume-indicator-fill"; +import "@videojs/html/ui/volume-indicator-value"; +import "@videojs/html/ui/status-indicator"; +import "@videojs/html/ui/status-indicator-value"; +import { registerIcons } from "@videojs/html/icons"; +import { airPlayEnterIcon, airPlayExitIcon, captionsOffIcon, captionsOnIcon, castEnterIcon, castExitIcon, checkIcon, fullscreenEnterIcon, fullscreenExitIcon, pauseIcon, pipEnterIcon, pipExitIcon, playIcon, restartIcon, spinnerIcon, volumeHighIcon, volumeLowIcon, volumeOffIcon } from "@videojs/html/icons"; + +registerIcons("default", { + "airplay-enter": airPlayEnterIcon, + "airplay-exit": airPlayExitIcon, + "captions-off": captionsOffIcon, + "captions-on": captionsOnIcon, + "cast-enter": castEnterIcon, + "cast-exit": castExitIcon, + check: checkIcon, + "fullscreen-enter": fullscreenEnterIcon, + "fullscreen-exit": fullscreenExitIcon, + pause: pauseIcon, + "pip-enter": pipEnterIcon, + "pip-exit": pipExitIcon, + play: playIcon, + restart: restartIcon, + spinner: spinnerIcon, + "volume-high": volumeHighIcon, + "volume-low": volumeLowIcon, + "volume-off": volumeOffIcon, +}); diff --git a/cmd/oceanbench/ts/videojs/styles/base.css b/cmd/oceanbench/ts/videojs/styles/base.css new file mode 100644 index 00000000..7eba7cb1 --- /dev/null +++ b/cmd/oceanbench/ts/videojs/styles/base.css @@ -0,0 +1,115 @@ +@layer base, components, utilities; +/* Preset adaptations and preference overrides win over theme tokens regardless of import order. */ +@layer base.theme, base.preset, base.preferences; + +@import "./themes/theme.css"; +@import "./themes/preferences.css"; + +/* Interpolate progress before chapter bounds are applied. */ +@property --media-slider-fill { + syntax: ""; + inherits: true; + initial-value: 0%; +} + +@property --media-slider-buffer { + syntax: ""; + inherits: true; + initial-value: 0%; +} + +@layer base { + @scope (.media-skin) { + :scope { + font-style: normal; + font-weight: 400; + text-align: start; + text-transform: none; + letter-spacing: normal; + } + + :scope, + *, + ::before, + ::after { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + + button { + font: inherit; + color: inherit; + text-transform: none; + letter-spacing: inherit; + appearance: none; + background: none; + } + + img, + svg, + media-icon { + display: block; + max-width: 100%; + } + + svg[fill="currentColor"] { + fill: currentColor; + } + + svg[fill="none"] { + fill: none; + } + + svg[stroke="currentColor"] { + stroke: currentColor; + } + + video { + display: block; + width: 100%; + height: 100%; + max-width: 100%; + border-radius: inherit; + object-fit: var(--media-object-fit, contain); + object-position: var(--media-object-position, center); + } + + media-icon > svg { + width: 100%; + height: 100%; + } + + media-tooltip-group, + media-dialog, + media-alert-dialog, + media-error-dialog, + media-controls { + display: contents; + } + + [hidden][hidden] { + /* Hidden must override component display styles. */ + display: none !important; + } + } + + @media (prefers-reduced-motion: no-preference) { + .media-skin { + interpolate-size: allow-keywords; + } + } + + /* Without the Popover API nothing hides a closed popup, so hide it until its component opens it or while it exits. */ + @supports not selector(:popover-open) { + :where(.media-skin) [popover]:not([data-open], [data-ending-style]) { + display: none !important; + } + + /* Popups stay inside the player without the top layer, so let them extend past the short audio bar. */ + .media-skin:is([data-preset="audio"], [data-preset="live-audio"]) { + overflow: visible !important; + } + } +} diff --git a/cmd/oceanbench/ts/videojs/styles/themes/preferences.css b/cmd/oceanbench/ts/videojs/styles/themes/preferences.css new file mode 100644 index 00000000..161d8afe --- /dev/null +++ b/cmd/oceanbench/ts/videojs/styles/themes/preferences.css @@ -0,0 +1,76 @@ +@layer base.theme, base.preset, base.preferences; + +@layer base.preferences { + @media (pointer: fine) and (prefers-reduced-motion: no-preference) { + .media-skin:not([data-controls-visible]) { + --media-duration-controls: 300ms; + } + } + + @media (pointer: coarse) and (prefers-reduced-motion: no-preference) { + .media-skin:not([data-controls-visible]) { + --media-duration-controls: 150ms; + } + } + + @media (prefers-reduced-motion: reduce) { + .media-skin { + --media-duration-fast: var(--media-duration-instant); + --media-duration: var(--media-duration-instant); + --media-duration-slow: var(--media-duration-instant); + --media-duration-slower: var(--media-duration-instant); + --media-duration-controls: var(--media-duration-instant); + --media-duration-dialog: var(--media-duration-instant); + --media-duration-indicator: var(--media-duration-instant); + --media-duration-menu: 0ms; + --media-duration-slider: 0ms; + --media-delay-dialog: 0ms; + --media-hidden-scale: 1; + --media-hidden-blur: 0px; + --media-hidden-offset: 0px; + --media-hidden-icon-scale: 1; + --media-hidden-indicator-scale: 1; + --media-hidden-indicator-offset: 0px; + --media-hidden-playback-scale: 1; + --media-hidden-popup-scale: 1; + --media-hidden-popup-blur: 0px; + --media-hidden-preview-scale: 1; + --media-hidden-preview-offset: 0px; + --media-hidden-seek-offset: 0px; + --media-popup-translate-distance: 0px; + --media-spinner-animation: none; + --media-icon-airplay-fill-animation: none; + --media-icon-airplay-triangle-animation: none; + } + } + + @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { + .media-skin { + --media-popover: var(--media-background); + --media-scrollbar-thumb-color: color-mix(in oklab, currentColor 80%, transparent); + --media-backdrop-filter-surface: none; + --media-backdrop-filter-indicator: none; + --media-backdrop-filter-dialog: none; + --media-shadow-surface-inset: inset 0 1px 0 0 rgb(255 255 255 / 0.25), inset 0 0 0 1px rgb(255 255 255 / 0.125); + + scrollbar-width: auto; + } + + @supports (color: oklch(from currentColor l c h)) { + .media-skin { + --media-scrollbar-thumb-color: oklch(from currentColor l c h / 0.8); + } + } + } + + @media (forced-colors: active) { + .media-skin { + --media-border: CanvasText; + --media-frame-border: CanvasText; + --media-popover: Canvas; + --media-popover-foreground: CanvasText; + --media-ring: CanvasText; + --media-shadow-surface-inset: inset 0 1px 0 0 CanvasText, inset 0 0 0 1px CanvasText; + } + } +} diff --git a/cmd/oceanbench/ts/videojs/styles/themes/theme.css b/cmd/oceanbench/ts/videojs/styles/themes/theme.css new file mode 100644 index 00000000..09d303c6 --- /dev/null +++ b/cmd/oceanbench/ts/videojs/styles/themes/theme.css @@ -0,0 +1,151 @@ +@layer base.theme, base.preset, base.preferences; + +@layer base.theme { + /* Default theme tokens. Neutral selectors, preset adaptations, and preferences override these. */ + .media-skin { + /* Colors. */ + --media-background: oklch(0 0 0); + --media-foreground: oklch(1 0 0); + --media-controls: oklch(0 0 0 / 0.35); + --media-controls-foreground: var(--media-foreground); + --media-popover: oklch(1 0 0 / 0.1); + --media-popover-foreground: var(--media-foreground); + --media-primary: var(--media-accent-color, var(--media-default-accent-color)); + /* Browsers without `contrast-color()` use the theme's default text color; see the `@supports` blocks below. */ + --media-primary-foreground: var(--media-accent-text-color, var(--media-default-accent-text-color)); + --media-accent: var(--media-accent-color, color-mix(in oklab, var(--media-default-accent-color) 10%, transparent)); + --media-accent-foreground: var(--media-accent-text-color, currentColor); + --media-muted: oklch(1 0 0 / 0.15); + --media-muted-foreground: oklch(1 0 0 / 0.65); + --media-border: oklch(0 0 0 / 0.1); + --media-ring: oklch(1 0 0); + --media-backdrop: oklch(0 0 0); + --media-live-color: oklch(0.65 0.22 27); + + /* Shadows and surfaces. Preferences switch the backdrop filters off and strengthen the inset highlight. */ + --media-shadow-surface: 0 1px 3px 0 oklch(0 0 0 / 0.15), 0 1px 2px -1px oklch(0 0 0 / 0.15); + --media-shadow-surface-inset: inset 0 1px 0 0 rgb(255 255 255 / 0.1), inset 0 0 0 1px rgb(255 255 255 / 0.05); + --media-shadow-thumb: 0 0 0 1px rgb(0 0 0 / 0.1), 0 1px 3px 0 rgb(0 0 0 / 0.35), 0 1px 2px -1px rgb(0 0 0 / 0.35); + --media-shadow-tooltip: 0 0 0 1px var(--media-border), var(--media-shadow-surface); + --media-shadow-separator: 0 1px 0 0 oklch(1 0 0 / 0.075); + --media-shadow-current-color: oklch(from currentColor 0 0 0 / clamp(0, calc((l - 0.5) * 0.5), 0.15)); + --media-shadow-subtle-current-color: color-mix(in oklab, var(--media-shadow-current-color) 40%, transparent); + --media-text-shadow-dialog: 0 1px 0 rgb(0 0 0 / 0.25); + --media-backdrop-filter-surface: blur(16px) saturate(110%); + --media-backdrop-filter-indicator: blur(8px); + --media-backdrop-filter-dialog: blur(16px) saturate(110%); + + /* Controls. */ + --media-control-corner-shape: round; + --media-control-radius: 99px; + --media-control-size: calc(var(--media-spacing) * 9); + --media-controls-radius: var(--media-control-radius); + --media-default-accent-color: oklch(1 0 0); + --media-default-accent-text-color: oklch(0 0 0); + + /* Motion. Preferences collapse durations and neutralize hidden-state values under reduced motion. */ + --media-duration-instant: 50ms; + --media-duration-fast: 100ms; + --media-duration: 150ms; + --media-duration-slow: 200ms; + --media-duration-slower: 250ms; + --media-duration-controls: 100ms; + --media-duration-dialog: 350ms; + --media-duration-indicator: var(--media-duration-slower); + --media-duration-menu: 250ms; + --media-duration-slider: var(--media-duration-fast); + --media-delay-dialog: 100ms; + --media-hidden-scale: 0.95; + --media-hidden-blur: 8px; + --media-hidden-offset: calc(var(--media-spacing) * 1); + --media-hidden-icon-scale: 0; + --media-hidden-indicator-scale: 0.9; + --media-hidden-indicator-offset: -25%; + --media-hidden-playback-scale: 0.85; + --media-hidden-popup-scale: 0.95; + --media-hidden-popup-blur: 4px; + --media-hidden-preview-scale: 0.8; + --media-hidden-preview-offset: calc(var(--media-spacing) * 2); + --media-hidden-seek-offset: 60%; + + /* Popups and menus. */ + --media-menu-item-radius: calc(var(--media-spacing) * 2); + --media-popover-boundary-offset: calc(var(--media-spacing) * 3); + --media-popover-side-offset: calc(var(--media-spacing) * 3); + --media-popup-radius: calc(var(--media-spacing) * 3); + --media-popup-translate-distance: calc(var(--media-spacing) * 2); + --media-tooltip-boundary-offset: var(--media-popover-boundary-offset); + --media-tooltip-side-offset: var(--media-popover-side-offset); + + /* Dialogs. */ + --media-dialog-radius: calc(var(--media-spacing) * 7); + --media-dialog-width: calc(100% - var(--media-spacing) * 6); + --media-dialog-max-width: calc(var(--media-spacing) * 72); + + /* Sliders. */ + --media-slider-preview-label-offset: calc(var(--media-spacing) * 10.5); + + /* Player frame and scale. */ + --media-video-border-radius: var(--media-border-radius, 28px); + --media-scale: 1; + --media-scrollbar-thumb-color: color-mix(in oklab, currentColor 30%, transparent); + --media-spacing: calc(var(--media-scale-unit, 16px) * var(--media-scale) / 4); + + scrollbar-color: var(--media-scrollbar-thumb-color) transparent; + scrollbar-width: thin; + } + + /* + * Newer color functions stay behind these checks, and the rule above holds the fallback for browsers without them. + * Relative colors that keep their origin's channels fall back to the same color through `color-mix()`. + */ + @supports (color: contrast-color(red)) { + .media-skin { + --media-primary-foreground: var(--media-accent-text-color, contrast-color(var(--media-primary))); + --media-accent-foreground: var( + --media-accent-text-color, + contrast-color(var(--media-accent-color, var(--media-internal-accent-text-fallback, oklch(0 0 0)))) + ); + } + } + + @supports (color: oklch(from red l c h)) { + .media-skin { + --media-shadow-subtle-current-color: oklch(from var(--media-shadow-current-color) l c h / calc(alpha * 0.4)); + } + } + + @supports (color: oklch(from currentColor l c h)) { + .media-skin { + --media-scrollbar-thumb-color: oklch(from currentColor l c h / 0.3); + } + } + + .media-skin::-webkit-scrollbar-thumb { + background: var(--media-scrollbar-thumb-color); + border-radius: 9999px; + } + + .media-skin:fullscreen { + --media-object-fit: contain; + --media-video-border-radius: 0; + } + + @media (min-width: 1280px) { + .media-skin:fullscreen { + --media-scale: 1.25; + } + } + + @media (min-width: 1536px) { + .media-skin:fullscreen { + --media-scale: 1.5; + } + } + + @media (min-width: 1920px) { + .media-skin:fullscreen { + --media-scale: 1.75; + } + } +} diff --git a/cmd/oceanbench/ts/videojs/styles/video/base.css b/cmd/oceanbench/ts/videojs/styles/video/base.css new file mode 100644 index 00000000..3ade64cf --- /dev/null +++ b/cmd/oceanbench/ts/videojs/styles/video/base.css @@ -0,0 +1,3 @@ +@import "../base.css"; +@import "./captions.css"; +@import "./theme.css"; diff --git a/cmd/oceanbench/ts/videojs/styles/video/captions.css b/cmd/oceanbench/ts/videojs/styles/video/captions.css new file mode 100644 index 00000000..a0e8ce40 --- /dev/null +++ b/cmd/oceanbench/ts/videojs/styles/video/captions.css @@ -0,0 +1,37 @@ +@layer components { + @scope (.media-skin) { + :scope { + --media-caption-track-duration: var(--media-duration-controls); + --media-caption-track-delay: 25ms; + --media-caption-track-y: calc(var(--media-spacing) * -2); + } + + video::-webkit-media-text-track-container { + z-index: 20; + font-family: inherit; + scale: 0.98; + translate: 0 var(--media-caption-track-y); + transition: translate var(--media-caption-track-duration) ease-out; + transition-delay: var(--media-caption-track-delay); + } + + :scope[data-controls-visible] { + --media-caption-track-y: var(--media-caption-controls-y); + } + + /* Compat combines the offset and scale into one transform for browsers without individual transform properties. */ + :scope[data-theme="compat"] video::-webkit-media-text-track-container { + scale: none; + translate: none; + transform: translateY(var(--media-caption-track-y, 0px)) scale(0.98); + transition-property: transform; + } + + /* Matches the `--container-media-2xl` layout breakpoint. */ + @container media-root (width >= 42rem) { + :scope[data-theme="neutral"][data-controls-visible] video { + --media-caption-track-y: calc(var(--media-spacing) * -12); + } + } + } +} diff --git a/cmd/oceanbench/ts/videojs/styles/video/theme.css b/cmd/oceanbench/ts/videojs/styles/video/theme.css new file mode 100644 index 00000000..58b5b865 --- /dev/null +++ b/cmd/oceanbench/ts/videojs/styles/video/theme.css @@ -0,0 +1,94 @@ +@layer base.theme, base.preset, base.preferences; + +@layer base.preset { + .media-skin:is([data-preset="video"], [data-preset="live-video"]) { + /* Light border for browsers without `light-dark()`; see the `@supports` block below. */ + --media-frame-border: var(--media-border-color, rgb(0 0 0 / 0.1)); + /* Scrims for browsers without relative colors or gradient interpolation; the `@supports` blocks below refine them. */ + --media-controls-gradient: linear-gradient( + to top, + color-mix(in oklab, var(--media-backdrop) 40%, transparent), + color-mix(in oklab, var(--media-backdrop) 20%, transparent), + color-mix(in oklab, var(--media-backdrop) 50%, transparent) + ); + --media-caption-controls-y: calc(var(--media-spacing) * -14); + --media-slider-preview-offset: calc(var(--media-spacing) * 9); + --media-thumbnail-gradient: linear-gradient( + to top, + color-mix(in oklab, var(--media-backdrop) 50%, transparent), + color-mix(in oklab, var(--media-backdrop) 10%, transparent), + transparent + ); + } + + @supports (color: oklch(from red l c h)) and (background-image: linear-gradient(in oklab, red, red)) { + .media-skin:is([data-preset="video"], [data-preset="live-video"]) { + --media-controls-gradient: linear-gradient( + to top in oklab, + oklch(from var(--media-backdrop) l c h / 0.4), + oklch(from var(--media-backdrop) l c h / 0.2), + oklch(from var(--media-backdrop) l c h / 0.5) + ); + } + } + + @supports (color: oklch(from red l c h)) { + .media-skin:is([data-preset="video"], [data-preset="live-video"]) { + --media-thumbnail-gradient: linear-gradient( + to top, + oklch(from var(--media-backdrop) l c h / 0.5), + oklch(from var(--media-backdrop) l c h / 0.1), + transparent + ); + } + } + + .media-skin:is([data-preset="video"], [data-preset="live-video"])[data-theme="neutral"] { + --media-frame-border: var(--media-border-color, rgb(0 0 0 / 0.15)); + --media-controls-gradient: linear-gradient( + to top, + color-mix(in oklab, var(--media-backdrop) 50%, transparent), + color-mix(in oklab, var(--media-backdrop) 20%, transparent) + ); + --media-indicator-gradient: linear-gradient( + to bottom, + color-mix(in oklab, var(--media-backdrop) 35%, transparent), + color-mix(in oklab, var(--media-backdrop) 20%, transparent) calc(var(--media-spacing) * 12), + transparent + ); + --media-caption-controls-y: calc(var(--media-spacing) * -18); + --media-slider-preview-offset: calc(var(--media-spacing) * 11); + } + + @supports (color: oklch(from red l c h)) and (background-image: linear-gradient(in oklab, red, red)) { + .media-skin:is([data-preset="video"], [data-preset="live-video"])[data-theme="neutral"] { + --media-controls-gradient: linear-gradient( + to top in oklab, + oklch(from var(--media-backdrop) l c h / 0.5), + oklch(from var(--media-backdrop) l c h / 0.2) + ); + } + } + + @supports (color: oklch(from red l c h)) { + .media-skin:is([data-preset="video"], [data-preset="live-video"])[data-theme="neutral"] { + --media-indicator-gradient: linear-gradient( + to bottom, + oklch(from var(--media-backdrop) l c h / 0.35), + oklch(from var(--media-backdrop) l c h / 0.2) calc(var(--media-spacing) * 12), + transparent + ); + } + } + + /* Keep `light-dark()` behind this check so a build that lowers it cannot rewrite it; see `audio/theme.css`. */ + @supports (color: light-dark(red, red)) { + .media-skin:is([data-preset="video"], [data-preset="live-video"]) { + --media-frame-border: var(--media-border-color, light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.15))); + } + + .media-skin:is([data-preset="video"], [data-preset="live-video"])[data-theme="neutral"] { + --media-frame-border: var(--media-border-color, light-dark(rgb(0 0 0 / 0.15), rgb(255 255 255 / 0.15))); + } + } +}