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
14 changes: 9 additions & 5 deletions .github/scripts/test-autoplay.js
Original file line number Diff line number Diff line change
@@ -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 } = {}) {
Expand Down Expand Up @@ -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 <title> 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`);
Expand Down
40 changes: 40 additions & 0 deletions .github/scripts/test-mobile-fit.js
Original file line number Diff line number Diff line change
@@ -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);
});
21 changes: 21 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions scripts/index/80-index.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -306,6 +322,12 @@ tee /usr/share/nginx/html/index.html << EOF >/dev/null
}
}, 3000);
} else {
// The <title> 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) {
Expand Down