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
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#18181b" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Slideshow Maker</title>
</head>
Expand Down
Binary file added issue-19-pwa-aspect-ratio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "Slideshow Maker",
"short_name": "Slideshow",
"description": "Turn folders of photos and videos into beat-synced slideshows. Local-first: your media never leaves your disk.",
"id": "/",
"start_url": "/",
"display": "standalone",
"background_color": "#18181b",
"theme_color": "#18181b",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
69 changes: 69 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* App-shell service worker.
*
* Caches only the same-origin app shell (HTML, hashed JS/CSS/font/icon assets)
* so the editor loads offline. It must NEVER cache:
* - project media: served from blob: URLs, which never reach this handler
* - Jamendo (or any cross-origin) requests: search/preview/download stay
* network-only and fail gracefully offline
*/
const CACHE_NAME = 'slideshow-shell-v1'
const SHELL_URL = '/'

self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.add(SHELL_URL)),
)
self.skipWaiting()
})

self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
).then(() => self.clients.claim()),
)
})

async function networkFirstShell(request) {
const cache = await caches.open(CACHE_NAME)
try {
const response = await fetch(request)
if (response.ok) cache.put(SHELL_URL, response.clone())
return response
} catch {
const cached = await cache.match(SHELL_URL)
if (cached) return cached
throw new Error('offline and app shell not cached')
}
}

async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME)
const cached = await cache.match(request)
const refresh = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone())
return response
})
.catch(() => undefined)
if (cached) return cached
const fresh = await refresh
if (fresh) return fresh
throw new Error('offline and asset not cached')
}

self.addEventListener('fetch', (event) => {
const { request } = event
if (request.method !== 'GET') return

const url = new URL(request.url)
// Cross-origin (e.g. Jamendo) and non-http schemes bypass the cache entirely.
if (url.origin !== self.location.origin) return

if (request.mode === 'navigate') {
event.respondWith(networkFirstShell(request))
return
}
event.respondWith(staleWhileRevalidate(request))
})
45 changes: 45 additions & 0 deletions scripts/generate-pwa-icons.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// One-off generator: renders public/favicon.svg onto a dark square canvas and
// screenshots PNG app icons for the PWA manifest. Re-run if the favicon changes:
// node scripts/generate-pwa-icons.mjs
import { createRequire } from 'node:module'
import { mkdir, readdir, readFile } from 'node:fs/promises'
import path from 'node:path'

const root = path.resolve(import.meta.dirname, '..')

// playwright is a transitive dep of @playwright/cli; resolve it through the pnpm store.
async function resolvePlaywright() {
const pnpmDir = path.join(root, 'node_modules', '.pnpm')
const entries = await readdir(pnpmDir)
const cliDir = entries.find((name) => name.startsWith('@playwright+cli@'))
if (!cliDir) throw new Error('@playwright/cli not found in node_modules/.pnpm')
const requireFromCli = createRequire(
path.join(pnpmDir, cliDir, 'node_modules', '@playwright', 'cli', 'package.json'),
)
return import(requireFromCli.resolve('playwright'))
}

const SIZES = [192, 512]
const BACKGROUND = '#18181b'
// Logo occupies 60% of the canvas so it stays inside the maskable safe zone.
const LOGO_SCALE = 0.6

const playwright = await resolvePlaywright()
const chromium = playwright.chromium ?? playwright.default.chromium
const svg = await readFile(path.join(root, 'public', 'favicon.svg'), 'utf8')
const outDir = path.join(root, 'public', 'icons')
await mkdir(outDir, { recursive: true })

// channel: 'chrome' uses the system Chrome so no Playwright browser download is needed
const browser = await chromium.launch({ channel: 'chrome' })
const page = await browser.newPage()
for (const size of SIZES) {
await page.setViewportSize({ width: size, height: size })
await page.setContent(`<!doctype html>
<html><body style="margin:0;width:${size}px;height:${size}px;background:${BACKGROUND};display:flex;align-items:center;justify-content:center">
<div style="width:${Math.round(size * LOGO_SCALE)}px">${svg.replace('<svg ', '<svg style="width:100%;height:auto" ')}</div>
</body></html>`)
await page.screenshot({ path: path.join(outDir, `icon-${size}.png`) })
console.log(`wrote public/icons/icon-${size}.png`)
}
await browser.close()
173 changes: 173 additions & 0 deletions scripts/playwright-issue-19-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Smoke check for issue #19 (PWA + aspect ratio). Run against a PROD preview,
// not the dev server — the service worker only registers in PROD builds:
// pnpm build && pnpm preview then node scripts/playwright-issue-19-smoke.mjs
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'

const PREVIEW_URL = process.env.PREVIEW_URL ?? 'http://localhost:4173/'

function run(args) {
const result = spawnSync('npx', ['playwright-cli', ...args], {
cwd: process.cwd(),
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024,
})
if (result.stdout) process.stdout.write(result.stdout)
if (result.stderr) process.stderr.write(result.stderr)
if (result.error) console.error(result.error)
return result.status ?? 1
}

const demo = path.resolve('test-fixtures/demo')
const names = ['photo.jpg', 'photo2.jpg']
const payload = Object.fromEntries(
names.map((name) => [name, fs.readFileSync(path.join(demo, name)).toString('base64')]),
)
payload['slideshow.json'] = Buffer.from(JSON.stringify({
aspectRatio: '9:16',
globalSettings: {
fitMode: 'cover',
imageDurationSecs: 3,
kenBurns: true,
transitionType: 'crossfade',
},
schemaVersion: 1,
slides: [
{ durationInFrames: 90, excluded: false, filename: 'photo.jpg', id: 'photo-a', type: 'image' },
{ durationInFrames: 90, excluded: false, filename: 'photo2.jpg', id: 'photo-b', type: 'image' },
],
}, null, 2)).toString('base64')

const code = `async page => {
const payload = ${JSON.stringify(payload)};

// --- 1. Installability signals: manifest + active service worker ---
await page.goto(${JSON.stringify(PREVIEW_URL)});
const manifest = await page.evaluate(async () => {
const link = document.querySelector('link[rel="manifest"]');
if (!link) throw new Error('no <link rel="manifest"> in document');
const res = await fetch(link.href);
if (!res.ok) throw new Error('manifest fetch failed: ' + res.status);
return res.json();
});
if (!manifest.icons || manifest.icons.length < 2 || manifest.display !== 'standalone') {
throw new Error('manifest not installable: ' + JSON.stringify(manifest));
}
await page.evaluate(() => Promise.race([
navigator.serviceWorker.ready,
new Promise((_, reject) => setTimeout(() => reject(new Error('service worker never ready')), 10000)),
]));

// Reload so the SW controls the page and runtime-caches the hashed assets.
await page.reload();
const controlled = await page.evaluate(() => !!navigator.serviceWorker.controller);
if (!controlled) throw new Error('page not controlled by service worker after reload');
console.log('PASS installability: manifest ok, service worker controlling');

// --- 2. 9:16 project: portrait composition, all fit modes render ---
await page.evaluate((files) => {
function decodeBase64(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
return bytes;
}
class MockFileHandle {
constructor(name, base64) {
this.kind = 'file';
this.name = name;
this._base64 = base64;
}
async getFile() {
const bytes = decodeBase64(this._base64);
const type = this.name.endsWith('.jpg') ? 'image/jpeg' : 'application/json';
return new File([bytes], this.name, { type, lastModified: 1781145972852 });
}
async createWritable() {
const handle = this;
return {
write: async (data) => {
const buffer = data instanceof Blob ? await data.arrayBuffer() : data;
handle._base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
},
close: async () => {},
};
}
}
class MockDirHandle {
constructor(files) {
this.name = 'demo';
this._files = files;
}
async getFileHandle(name, options) {
const handle = this._files.get(name);
if (handle) return handle;
if (options?.create) {
const created = new MockFileHandle(name, '');
this._files.set(name, created);
return created;
}
throw new DOMException('NotFoundError');
}
async *values() {
for (const handle of this._files.values()) yield handle;
}
}
const handles = new Map(
Object.entries(files).map(([name, base64]) => [name, new MockFileHandle(name, base64)]),
);
window.__mockHandles = handles;
window.showDirectoryPicker = async () => new MockDirHandle(handles);
}, payload);

await page.getByRole('button', { name: 'Open Folder' }).click();
await page.getByText('photo2.jpg').first().waitFor();

async function assertPortraitPlayer(label) {
const ratio = await page.evaluate(() => {
const player = document.querySelector('.__remotion-player');
if (!player) return null;
const rect = player.getBoundingClientRect();
return rect.width / rect.height;
});
if (ratio === null) throw new Error(label + ': remotion player element not found');
if (Math.abs(ratio - 9 / 16) > 0.01) {
throw new Error(label + ': expected portrait 9:16 player, got width/height=' + ratio.toFixed(3));
}
console.log('PASS ' + label + ': player aspect ' + ratio.toFixed(3));
}

const aspectToggle = page.getByRole('radio', { name: '9:16' });
if (!(await aspectToggle.getAttribute('data-state')).includes('on')) {
throw new Error('aspect toggle did not restore 9:16 from slideshow.json');
}
await assertPortraitPlayer('9:16 restored (cover)');

// Play briefly to prove playback works in portrait.
await page.keyboard.press(' ');
await page.waitForTimeout(700);
await page.keyboard.press(' ');

for (const fit of ['Letterbox', 'Blur fill']) {
await page.getByLabel('Fit mode').click();
await page.getByRole('option', { name: fit }).click();
await page.waitForTimeout(400);
await assertPortraitPlayer('9:16 ' + fit);
}
await page.screenshot({ path: 'issue-19-pwa-aspect-ratio.png', fullPage: true });

// --- 3. Offline: app shell still loads ---
await page.context().setOffline(true);
await page.reload();
await page.getByRole('button', { name: 'Open Folder' }).waitFor({ timeout: 10000 });
console.log('PASS offline: app shell loaded while offline');
await page.context().setOffline(false);
}`

let status = run(['open', PREVIEW_URL, '--browser=chrome'])
if (status !== 0) process.exit(status)

status = run(['run-code', code])
run(['close'])
process.exit(status)
9 changes: 8 additions & 1 deletion src/editor-shell/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
createTitleSlide,
} from '../timeline-core'
import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core'
import { applyTheme } from '../timeline-core'
import { applyTheme, dimensionsForAspectRatio } from '../timeline-core'
import { plan, slideIdAtFrame, startFrameForSlideId } from '../sequence-planner'
import { AppHeader } from './AppHeader'
import { DropImportLayer } from './DropImportLayer'
Expand All @@ -32,6 +32,8 @@ export function App() {
const clearSelection = useCallback(() => setSelectedSlideId(null), [])
const project = useProject({ onFolderLoaded: clearSelection })
const {
aspectRatio,
setAspectRatio,
audioTracks,
globalSettings,
setGlobalSettings,
Expand Down Expand Up @@ -106,6 +108,7 @@ export function App() {
[globalSettings, selectedSoundtrack, slides],
)
const totalFrames = renderPlan.totalFrames > 0 ? renderPlan.totalFrames : FPS
const canvas = dimensionsForAspectRatio(aspectRatio)

const handleFrameChange = useCallback((frame: number) => {
setCurrentSlideId(slideIdAtFrame(renderPlan, frame))
Expand Down Expand Up @@ -168,6 +171,8 @@ export function App() {
<EditorLayout
player={(
<PlayerPane
compositionHeight={canvas.height}
compositionWidth={canvas.width}
onFrameChange={handleFrameChange}
playerRef={playerRef}
renderPlan={renderPlan}
Expand All @@ -186,6 +191,8 @@ export function App() {
) : null}
sidebar={
<EditorSidebar
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
settings={globalSettings}
themeName={themeName}
onSettingsChange={handleSettingsChange}
Expand Down
8 changes: 7 additions & 1 deletion src/editor-shell/EditorSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import {
AccordionTrigger,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import type { GlobalSettings, ThemeName } from '../timeline-core'
import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core'
import type { AudioTrack } from '../project-store'
import type { JamendoAttribution, JamendoTrack } from '../jamendo/types'
import { GlobalSettingsPanel } from './GlobalSettingsPanel'
import { SoundtrackPanel } from './SoundtrackPanel'
import { JamendoPanel } from './JamendoPanel'

type Props = {
aspectRatio: AspectRatio
onAspectRatioChange: (ratio: AspectRatio) => void
settings: GlobalSettings
themeName: ThemeName | null
onSettingsChange: (updated: GlobalSettings) => void
Expand All @@ -26,6 +28,8 @@ type Props = {
}

export function EditorSidebar({
aspectRatio,
onAspectRatioChange,
settings,
themeName,
onSettingsChange,
Expand All @@ -51,6 +55,8 @@ export function EditorSidebar({
<AccordionTrigger className="py-3 text-sm">Settings</AccordionTrigger>
<AccordionContent>
<GlobalSettingsPanel
aspectRatio={aspectRatio}
onAspectRatioChange={onAspectRatioChange}
settings={settings}
themeName={themeName}
onChange={onSettingsChange}
Expand Down
Loading
Loading