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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/pwa/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@profullstack/footer": "latest",
"@profullstack/libsql-pg": "^0.1.2",
"@profullstack/notifications": "^0.1.0",
"@profullstack/player": "^0.17.0",
"@profullstack/redeem-credits": "^0.2.0",
"@profullstack/synconfig": "^0.1.1",
"@simplewebauthn/browser": "^13.3.0",
Expand Down
42 changes: 42 additions & 0 deletions apps/pwa/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions apps/pwa/public/pf-player-init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Upgrades every server-rendered <div data-pf-player><video controls></video></div> to
// @profullstack/player (one control bar, auto-hide, fullscreen including iPhone). The <video> keeps
// its native controls until the player has loaded, and keeps them for good if it never does.
const hosts = document.querySelectorAll("[data-pf-player]");
if (hosts.length) {
if (!document.querySelector("link[data-pf-player-css]")) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/pf-player.css";
link.dataset.pfPlayerCss = "";
document.head.append(link);
}
import("/pf-player.js")
.then(({ createPlayer }) => {
for (const host of hosts) {
const media = host.querySelector("video, audio");
const src = host.dataset.src || media?.getAttribute("src");
if (!media || !src) continue;
try {
createPlayer(host, {
src,
media,
kind: host.dataset.kind || undefined,
mediaId: host.dataset.mediaId || undefined,
poster: media.getAttribute("poster") || undefined,
});
} catch (error) {
console.error("player failed, keeping native controls", error);
}
}
})
.catch((error) => console.error("player unavailable, keeping native controls", error));
}
3 changes: 3 additions & 0 deletions apps/pwa/src/client/player-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// The browser side of @profullstack/player, bundled by the server (src/lib/player-bundle.mjs) and
// loaded by public/pf-player-init.js only on a page that has something to play.
export { createPlayer } from "@profullstack/player";
9 changes: 7 additions & 2 deletions apps/pwa/src/lib/moshpit-site-page.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { esc } from "./html.mjs";
import { feedDate } from "./moshpit-feed-page.mjs";
import { navFor, postsFor } from "./moshpit-content.mjs";

/** A direct media file — something <video> can play without anyone's player. */
/** A direct media file — something @profullstack/player (or a bare <video>) plays itself. */
const VIDEO_FILE = /\.(mp4|webm|ogv|ogg|mov|m4v)(\?|#|$)/i;

/**
Expand Down Expand Up @@ -115,6 +115,8 @@ export const SITE_CSS = `
background:var(--surface)}
.site-video{width:100%;aspect-ratio:16/9;border-radius:10px;border:1px solid var(--line);
background:#000;margin-top:12px;display:block}
.site-player{overflow:hidden}
.site-player video{display:block;width:100%;height:100%;background:#000}
.site-card{display:flex;gap:12px;align-items:center;margin-top:12px;padding:12px 14px;
border:1px solid var(--line-2);border-radius:10px;background:var(--bg-tint)}
.site-card .mono{font-size:.76rem;color:var(--dim);word-break:break-all}
Expand Down Expand Up @@ -159,7 +161,10 @@ function media(item) {

if (item.kind === "video" && item.url) {
if (VIDEO_FILE.test(item.url)) {
return `<video class="site-video" controls preload="none" src="${esc(item.url)}"></video>`;
// @profullstack/player takes over the <video> once /pf-player-init.js has
// loaded it; until then, or without JS, the native controls play it.
return `<div class="site-video site-player" data-pf-player data-src="${esc(item.url)}"><video controls playsinline preload="none" src="${esc(item.url)}"></video></div>
<script type="module" src="/pf-player-init.js"></script>`;
}
const embed = videoEmbed(item.url);
if (embed) {
Expand Down
39 changes: 39 additions & 0 deletions apps/pwa/src/lib/player-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @profullstack/player for the browser, served from app.moshcode.sh's own origin.
*
* The PWA has no client build step: public/ is plain JS. The player's dist imports hls.js and
* mpegts.js by bare name, so it cannot be served from node_modules as it stands; it is bundled
* here instead, once, the first time it is asked for, into one ES module (/pf-player.js) plus its
* stylesheet (/pf-player.css). public/pf-player-init.js imports it only on a page with a
* [data-pf-player] host. Same pattern as pit.world's src/player-bundle.ts. Needs Bun (the
* production runtime); under Node the route answers 503 and the native <video> stays.
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";

let js = null;
let css = null;

export function playerJs() {
js ??= (async () => {
if (typeof Bun === "undefined") throw new Error("player bundle needs the Bun runtime");
const out = await Bun.build({
entrypoints: [new URL("../client/player-entry.mjs", import.meta.url).pathname],
target: "browser",
format: "esm",
splitting: false,
minify: true,
});
if (!out.success) throw new Error(`player bundle failed: ${out.logs.map((l) => l.message).join("; ")}`);
return await out.outputs[0].text();
})().catch((error) => {
js = null;
throw error;
});
return js;
}

export function playerCss() {
css ??= readFileSync(createRequire(import.meta.url).resolve("@profullstack/player/player.css"), "utf8");
return css;
}
13 changes: 13 additions & 0 deletions apps/pwa/src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { MAX_BATCH, MAX_PUBLISH_BYTES } from "./lib/moshpit-content.mjs";
import { endExpiredLeases, expireOffers } from "./moshpit.mjs";
import { startRingFooterRefresh, RING_FOOTER_OPTIONS } from "./lib/ring-footer.mjs";
import { descriptor } from "@profullstack/footer";
import { playerCss, playerJs } from "./lib/player-bundle.mjs";

const app = express();
app.disable("x-powered-by");
Expand Down Expand Up @@ -68,6 +69,18 @@ const vendor = {
for (const [route, file] of Object.entries(vendor)) {
app.get(route, (_req, res) => res.sendFile(path.join(config.root, file), { maxAge: "1h" }));
}
// @profullstack/player, bundled once on first use (lib/player-bundle.mjs); public/pf-player-init.js
// loads it only on a page with a video file on it (a name's published site).
app.get("/pf-player.js", async (_req, res) => {
try {
res.set("cache-control", "public, max-age=3600").type("text/javascript").send(await playerJs());
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
res.status(503).type("text/plain").send("player unavailable");
}
});
app.get("/pf-player.css", (_req, res) =>
res.set("cache-control", "public, max-age=3600").type("text/css").send(playerCss()));

app.get("/healthz", (_req, res) => res.json({ ok: true, env: config.env }));
// The Profullstack webring descriptor (the ring footer is on the sign-in page).
Expand Down
4 changes: 3 additions & 1 deletion apps/pwa/test/moshpit-site-page.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ test("site: every post type renders as itself", skip, async () => {
assert.match(html, /class="site-out" href="https:\/\/example\.com\/thing"[^>]*>→ example\.com/);
assert.match(html, /<img class="site-shot" src="https:\/\/cdn\.example\/p\.jpg"/);
assert.match(html, /class="site-gallery"/);
assert.match(html, /<video class="site-video" controls preload="none" src="https:\/\/cdn\.example\/clip\.mp4">/);
// A direct file goes to @profullstack/player, with the bare <video> as the fallback.
assert.match(html, /<div class="site-video site-player" data-pf-player data-src="https:\/\/cdn\.example\/clip\.mp4"><video controls playsinline preload="none" src="https:\/\/cdn\.example\/clip\.mp4"><\/video><\/div>/);
assert.match(html, /<script type="module" src="\/pf-player-init\.js"><\/script>/);
assert.match(html, /youtube-nocookie\.com\/embed\/dQw4w9WgXcQ/);
// An embed is a card, not a frame — an arbitrary URL does not get an iframe
// on the origin where accounts live.
Expand Down
Loading