diff --git a/.github/scripts/test-autoplay.js b/.github/scripts/test-autoplay.js
index 670c87b..e980bed 100644
--- a/.github/scripts/test-autoplay.js
+++ b/.github/scripts/test-autoplay.js
@@ -1,15 +1,15 @@
const { chromium } = require('playwright');
const CONFIG = {
- error: { overlaySelector: '#site-error', clickSelector: '#site-error button' },
- loading: { overlaySelector: '#loading-screen', clickSelector: 'body' }
+ error: { overlaySelector: '#site-error', clickSelector: '#site-error button', initialTitle: 'Internal Server Error' },
+ loading: { overlaySelector: '#loading-screen', clickSelector: 'body', initialTitle: 'Loading...' }
};
const overlayType = process.argv[2];
if (!CONFIG[overlayType]) {
throw new Error(`usage: node test-autoplay.js <${Object.keys(CONFIG).join('|')}>, got "${overlayType}"`);
}
-const { overlaySelector, clickSelector } = CONFIG[overlayType];
+const { overlaySelector, clickSelector, initialTitle } = CONFIG[overlayType];
const cookieSelector = '#cookie-banner';
async function waitFor(page, predicate, { timeout = 5000, interval = 100 } = {}) {
@@ -51,8 +51,12 @@ async function waitFor(page, predicate, { timeout = 5000, interval = 100 } = {})
if (!initial.muted) {
throw new Error(`expected video to start muted, got muted=${initial.muted}`);
}
- if (initial.title !== 'Loading...') {
- throw new Error(`expected initial title "Loading...", got "${initial.title}"`);
+ // The
tag is static HTML and always starts as PRE_TITLE
+ // ("Loading..." by default) regardless of which state actually gets
+ // picked - the page JS has to override it to match, or the tab title
+ // says "Loading..." over a page that's showing a 500 error.
+ if (initial.title !== initialTitle) {
+ throw new Error(`expected initial title "${initialTitle}" for ${overlayType}, got "${initial.title}"`);
}
if (!initial.overlayVisible) {
throw new Error(`expected the ${overlayType} overlay to be visible, but it wasn't`);
diff --git a/.github/scripts/test-mobile-fit.js b/.github/scripts/test-mobile-fit.js
new file mode 100644
index 0000000..5f670ac
--- /dev/null
+++ b/.github/scripts/test-mobile-fit.js
@@ -0,0 +1,40 @@
+const { chromium } = require('playwright');
+
+// A 16:9 video with object-fit: cover looks fine on a wide desktop
+// viewport, but on a narrow/portrait one (basically any phone) it has
+// to scale up so far to cover the height that it ends up extremely
+// cropped/zoomed in. Confirms the aspect-ratio media query correctly
+// switches to contain on narrow/portrait viewports and leaves
+// cover in place everywhere from small desktop windows up to 4K.
+const VIEWPORTS = [
+ { name: 'mobile portrait', width: 375, height: 812, expected: 'contain' },
+ { name: 'tablet portrait', width: 768, height: 1024, expected: 'contain' },
+ { name: 'tablet landscape', width: 1024, height: 768, expected: 'cover' },
+ { name: 'small desktop', width: 1280, height: 800, expected: 'cover' },
+ { name: '1080p desktop', width: 1920, height: 1080, expected: 'cover' },
+ { name: '4K desktop', width: 3840, height: 2160, expected: 'cover' }
+];
+
+(async () => {
+ const browser = await chromium.launch();
+
+ for (const { name, width, height, expected } of VIEWPORTS) {
+ const page = await browser.newPage({ viewport: { width, height } });
+ await page.goto('http://localhost:8080/', { waitUntil: 'load' });
+ const fit = await page.evaluate(() => {
+ const v = document.getElementById('video');
+ return getComputedStyle(v).objectFit;
+ });
+ await page.close();
+
+ if (fit !== expected) {
+ throw new Error(`${name} (${width}x${height}): expected object-fit: ${expected}, got "${fit}"`);
+ }
+ console.log(`OK: ${name} (${width}x${height}) -> object-fit: ${fit}`);
+ }
+
+ await browser.close();
+})().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 08bf33d..e4b5c0a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,12 +7,14 @@ on:
- scripts/**
- .github/workflows/test.yml
- .github/scripts/test-autoplay.js
+ - .github/scripts/test-mobile-fit.js
pull_request:
paths:
- Dockerfile
- scripts/**
- .github/workflows/test.yml
- .github/scripts/test-autoplay.js
+ - .github/scripts/test-mobile-fit.js
workflow_dispatch:
jobs:
@@ -198,3 +200,22 @@ jobs:
- name: Stop OVERLAY=loading container
if: always()
run: docker rm -f rickroll-loading || true
+
+ - name: Start container for mobile video-fit test
+ run: docker run -d --name rickroll-mobile -p 8080:8080 rickroll:test
+
+ - name: Wait for mobile-fit container to be healthy
+ run: |
+ for i in $(seq 1 30); do
+ docker exec rickroll-mobile curl -fsS http://localhost:9090/healthz && exit 0
+ sleep 1
+ done
+ docker logs rickroll-mobile
+ exit 1
+
+ - name: Verify video uses contain on narrow viewports, cover on wide ones
+ run: node .github/scripts/test-mobile-fit.js
+
+ - name: Stop mobile-fit container
+ if: always()
+ run: docker rm -f rickroll-mobile || true
diff --git a/README.md b/README.md
index 5537ca7..5699ed4 100755
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ Also published to GHCR if you'd rather pull from there: `ghcr.io/modem7/docker-r
- Every browser autoplays a muted video with zero restrictions, but every browser also actively refuses to let a page play sound without a genuine click/tap/keypress first - there's no trick or workaround for this, it's a deliberately and increasingly strictly enforced policy (the same reason YouTube and every other site with audio needs a click too). So the video autoplays muted immediately, and a decoy page state - a stuck-loading spinner or a fake "Something went wrong" site error, picked at random - entices that first click, which is all it takes to unmute. A fake cookie-consent banner sits on top of either one, since that's realistic regardless of what the rest of the page is doing.
- The video keeps loading/playing muted in the background the whole time so it's instantly ready, but it's completely covered by the decoy until the reveal - nothing looks suspicious, and nothing gives it away early.
- Only genuine clicks/taps/keypresses count for this - deliberately not mouse movement or scrolling, since browsers don't count those as real interaction either, and unmuting off one of those just gets the video paused by the browser's autoplay enforcement instead of actually unmuted.
+- The video defaults to `object-fit: cover` (fills the whole screen), but on a narrow/portrait viewport - basically any phone - that crops a 16:9 video down to a heavily zoomed-in sliver. Below a 1:1 aspect ratio it automatically switches to `contain` (letterboxed, but the whole frame is visible) instead, regardless of the `OBJECT_FIT` setting, since cover never looks right there. Desktop/landscape is untouched.
- The video is served through nginx's mp4 module, so seeking/scrubbing and byte-range requests work properly and responses are cached.
- The video isn't stored in git. It's fetched from a GitHub Release asset at build time and baked into the image, so the shipped container is still fully self-contained and works offline - git just doesn't carry the binary around.
- Built for both linux/amd64 and linux/arm64/v8.
diff --git a/scripts/index/80-index.sh b/scripts/index/80-index.sh
index eeb207d..5ef86d3 100644
--- a/scripts/index/80-index.sh
+++ b/scripts/index/80-index.sh
@@ -49,6 +49,21 @@ tee /usr/share/nginx/html/index.html << EOF >/dev/null
z-index: 0;
}
+ /* object-fit: cover looks great on a wide/landscape screen, but
+ this video is 16:9 - on a narrow/portrait viewport (basically
+ any phone held normally), cover has to scale the video up so
+ much to fill the height that it ends up extremely cropped and
+ zoomed in, losing most of the picture. Switch to contain
+ (letterboxed, but the whole frame is visible) for anything
+ narrower than it is tall, regardless of the OBJECT_FIT setting
+ above - cover never looks right in that situation. */
+ @media (max-aspect-ratio: 1/1) {
+ video {
+ object-fit: contain;
+ background: #000;
+ }
+ }
+
#headline {
position: fixed;
top: 1rem;
@@ -298,6 +313,7 @@ tee /usr/share/nginx/html/index.html << EOF >/dev/null
cookieBanner.classList.remove('hidden');
if (choice === 'loading') {
+ document.title = "$PRE_TITLE";
loadingScreen.classList.remove('hidden');
setTimeout(function () {
var fallback = document.getElementById('loading-fallback');
@@ -306,6 +322,12 @@ tee /usr/share/nginx/html/index.html << EOF >/dev/null
}
}, 3000);
} else {
+ // The tag is static HTML, so it always starts out
+ // as PRE_TITLE ("Loading..." by default) regardless of
+ // which state gets picked - override it here so the tab
+ // title actually matches what's on screen instead of
+ // saying "Loading..." over a page that says it errored.
+ document.title = 'Internal Server Error';
siteError.classList.remove('hidden');
var ref = document.getElementById('error-reference');
if (ref) {