diff --git a/index.html b/index.html
index dfb7022..2a00bbb 100644
--- a/index.html
+++ b/index.html
@@ -3,6 +3,8 @@
+
+
Slideshow Maker
diff --git a/issue-19-pwa-aspect-ratio.png b/issue-19-pwa-aspect-ratio.png
new file mode 100644
index 0000000..313b638
Binary files /dev/null and b/issue-19-pwa-aspect-ratio.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..21c70e6
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..c27a420
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..81e7014
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -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" }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..cba1b2c
--- /dev/null
+++ b/public/sw.js
@@ -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))
+})
diff --git a/scripts/generate-pwa-icons.mjs b/scripts/generate-pwa-icons.mjs
new file mode 100644
index 0000000..d5c0bb8
--- /dev/null
+++ b/scripts/generate-pwa-icons.mjs
@@ -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(`
+
+ ${svg.replace('
+
+
+ {
+ if (value) onAspectRatioChange(value as AspectRatio)
+ }}
+ >
+ {ASPECT_RATIOS.map(ratio => (
+
+ {ratio}
+
+ ))}
+
+
+
diff --git a/src/editor-shell/PlayerPane.tsx b/src/editor-shell/PlayerPane.tsx
index 040951a..780a1cd 100644
--- a/src/editor-shell/PlayerPane.tsx
+++ b/src/editor-shell/PlayerPane.tsx
@@ -8,17 +8,17 @@ import type { RenderPlan } from '../sequence-planner'
import { SlideshowComposition } from '../composition'
export const FPS = 30
-export const COMP_WIDTH = 1920
-export const COMP_HEIGHT = 1080
type Props = {
+ compositionHeight: number
+ compositionWidth: number
onFrameChange: (frame: number) => void
playerRef?: React.RefObject
renderPlan: RenderPlan
totalFrames: number
}
-export function PlayerPane({ onFrameChange, playerRef, renderPlan, totalFrames }: Props) {
+export function PlayerPane({ compositionHeight, compositionWidth, onFrameChange, playerRef, renderPlan, totalFrames }: Props) {
const embeddedHostRef = useRef(null)
const presentationHostRef = useRef(null)
const fallbackPlayerRef = useRef(null)
@@ -93,8 +93,8 @@ export function PlayerPane({ onFrameChange, playerRef, renderPlan, totalFrames }
component={SlideshowComposition}
controls
durationInFrames={totalFrames}
- compositionWidth={COMP_WIDTH}
- compositionHeight={COMP_HEIGHT}
+ compositionWidth={compositionWidth}
+ compositionHeight={compositionHeight}
fps={FPS}
initialFrame={initialFrame}
inputProps={{ plan: renderPlan }}
diff --git a/src/editor-shell/slidePersistence.test.ts b/src/editor-shell/slidePersistence.test.ts
index 696c495..b0cc483 100644
--- a/src/editor-shell/slidePersistence.test.ts
+++ b/src/editor-shell/slidePersistence.test.ts
@@ -53,4 +53,14 @@ describe('slidesToJson', () => {
const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], 'track.mp3', null, null)
expect(json.soundtrackAttribution).toBeUndefined()
})
+
+ it('includes aspectRatio when provided', () => {
+ const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], null, null, null, '9:16')
+ expect(json.aspectRatio).toBe('9:16')
+ })
+
+ it('omits aspectRatio when not provided', () => {
+ const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], null, null, null)
+ expect(json.aspectRatio).toBeUndefined()
+ })
})
diff --git a/src/editor-shell/slidePersistence.ts b/src/editor-shell/slidePersistence.ts
index b2a3989..9ca5ae2 100644
--- a/src/editor-shell/slidePersistence.ts
+++ b/src/editor-shell/slidePersistence.ts
@@ -1,6 +1,6 @@
import type { MediaSlide, Slide } from '../timeline-core/types'
import { isTitleSlide } from '../timeline-core/types'
-import type { GlobalSettings, ThemeName } from '../timeline-core'
+import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core'
import { SCHEMA_VERSION, type SlideshowJson } from '../project-store'
import type { JamendoAttribution } from '../jamendo/types'
@@ -34,10 +34,12 @@ export function slidesToJson(
soundtrackFilename?: string | null,
themeName?: ThemeName | null,
soundtrackAttribution?: JamendoAttribution | null,
+ aspectRatio?: AspectRatio | null,
): SlideshowJson {
return {
globalSettings,
schemaVersion: SCHEMA_VERSION,
+ ...(aspectRatio ? { aspectRatio } : {}),
...(soundtrackFilename ? { soundtrackFilename } : {}),
...(themeName ? { themeName } : {}),
...(soundtrackAttribution ? { soundtrackAttribution } : {}),
diff --git a/src/editor-shell/useProject.ts b/src/editor-shell/useProject.ts
index 133898b..7c24fa8 100644
--- a/src/editor-shell/useProject.ts
+++ b/src/editor-shell/useProject.ts
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Slide } from '../timeline-core/types'
import { isTitleSlide } from '../timeline-core/types'
-import { DEFAULT_GLOBAL_SETTINGS } from '../timeline-core'
-import type { GlobalSettings, ThemeName } from '../timeline-core'
+import { DEFAULT_ASPECT_RATIO, DEFAULT_GLOBAL_SETTINGS, isAspectRatio } from '../timeline-core'
+import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core'
import {
addRecentProject,
enumerateAudioTracks,
@@ -37,6 +37,7 @@ type Options = {
}
export function useProject({ onFolderLoaded }: Options = {}) {
+ const [aspectRatio, setAspectRatio] = useState(DEFAULT_ASPECT_RATIO)
const [audioTracks, setAudioTracks] = useState([])
const [globalSettings, setGlobalSettings] = useState(DEFAULT_GLOBAL_SETTINGS)
const [slides, setSlides] = useState([])
@@ -87,9 +88,9 @@ export function useProject({ onFolderLoaded }: Options = {}) {
if (!handle) return
if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current)
autosaveTimerRef.current = setTimeout(() => {
- saveProject(handle, slidesToJson(globalSettings, slides, soundtrackFilename, themeName, soundtrackAttribution)).catch(console.error)
+ saveProject(handle, slidesToJson(globalSettings, slides, soundtrackFilename, themeName, soundtrackAttribution, aspectRatio)).catch(console.error)
}, AUTOSAVE_DELAY)
- }, [globalSettings, slides, soundtrackFilename, soundtrackAttribution, themeName])
+ }, [aspectRatio, globalSettings, slides, soundtrackFilename, soundtrackAttribution, themeName])
const loadFolder = useCallback(
async (handle: FileSystemDirectoryHandle, savedData?: SlideshowJson) => {
@@ -119,6 +120,7 @@ export function useProject({ onFolderLoaded }: Options = {}) {
})
pendingAudioRevokeRef.current = audioTracks
pendingRevokeRef.current = latestSlidesRef.current
+ setAspectRatio(isAspectRatio(savedData?.aspectRatio) ? savedData.aspectRatio : DEFAULT_ASPECT_RATIO)
setAudioTracks(nextAudioTracks)
setGlobalSettings(restoredSettings)
setThemeName(restoredThemeName)
@@ -249,6 +251,8 @@ export function useProject({ onFolderLoaded }: Options = {}) {
}, [globalSettings])
return {
+ aspectRatio,
+ setAspectRatio,
audioTracks,
globalSettings,
setGlobalSettings,
diff --git a/src/main.tsx b/src/main.tsx
index 6029a4d..7354f94 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -8,3 +8,10 @@ createRoot(document.getElementById('root')!).render(
,
)
+
+// App-shell caching only (PROD builds; dev would cache Vite's transient module URLs).
+if (import.meta.env.PROD && 'serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js').catch(console.error)
+ })
+}
diff --git a/src/project-store/schema.ts b/src/project-store/schema.ts
index 3129af6..0c0e03f 100644
--- a/src/project-store/schema.ts
+++ b/src/project-store/schema.ts
@@ -1,3 +1,4 @@
+import type { AspectRatio } from '../timeline-core/aspect'
import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core/settings'
import type { BeatGrid } from '../beat-grid/types'
import type { JamendoAttribution } from '../jamendo/types'
@@ -28,6 +29,7 @@ export type SerializedTitleSlide = {
export type SerializedSlide = SerializedMediaSlide | SerializedTitleSlide
export type SlideshowJson = {
+ aspectRatio?: AspectRatio
globalSettings?: GlobalSettings
schemaVersion: number
slides: SerializedSlide[]
diff --git a/src/timeline-core/aspect.test.ts b/src/timeline-core/aspect.test.ts
new file mode 100644
index 0000000..d67bc9e
--- /dev/null
+++ b/src/timeline-core/aspect.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest'
+import { DEFAULT_ASPECT_RATIO, dimensionsForAspectRatio, isAspectRatio } from './aspect'
+
+describe('dimensionsForAspectRatio', () => {
+ it('16:9 is a 1920×1080 landscape canvas', () => {
+ expect(dimensionsForAspectRatio('16:9')).toEqual({ width: 1920, height: 1080 })
+ })
+
+ it('9:16 is a 1080×1920 portrait canvas', () => {
+ expect(dimensionsForAspectRatio('9:16')).toEqual({ width: 1080, height: 1920 })
+ })
+
+ it('1:1 is a 1080×1080 square canvas', () => {
+ expect(dimensionsForAspectRatio('1:1')).toEqual({ width: 1080, height: 1080 })
+ })
+})
+
+describe('isAspectRatio', () => {
+ it('accepts every supported ratio', () => {
+ expect(isAspectRatio('16:9')).toBe(true)
+ expect(isAspectRatio('9:16')).toBe(true)
+ expect(isAspectRatio('1:1')).toBe(true)
+ })
+
+ it('rejects unsupported values from untrusted slideshow.json', () => {
+ expect(isAspectRatio('4:3')).toBe(false)
+ expect(isAspectRatio(123)).toBe(false)
+ expect(isAspectRatio(undefined)).toBe(false)
+ expect(isAspectRatio(null)).toBe(false)
+ })
+})
+
+describe('DEFAULT_ASPECT_RATIO', () => {
+ it('defaults to a 16:9 1080p canvas', () => {
+ expect(DEFAULT_ASPECT_RATIO).toBe('16:9')
+ expect(dimensionsForAspectRatio(DEFAULT_ASPECT_RATIO)).toEqual({ width: 1920, height: 1080 })
+ })
+})
diff --git a/src/timeline-core/aspect.ts b/src/timeline-core/aspect.ts
new file mode 100644
index 0000000..75dbd0f
--- /dev/null
+++ b/src/timeline-core/aspect.ts
@@ -0,0 +1,26 @@
+export const ASPECT_RATIOS = ['16:9', '9:16', '1:1'] as const
+
+export type AspectRatio = (typeof ASPECT_RATIOS)[number]
+
+export const DEFAULT_ASPECT_RATIO: AspectRatio = '16:9'
+
+export type CanvasDimensions = {
+ width: number
+ height: number
+}
+
+/** Boundary guard: slideshow.json is untrusted input, so validate before use. */
+export function isAspectRatio(value: unknown): value is AspectRatio {
+ return typeof value === 'string' && (ASPECT_RATIOS as readonly string[]).includes(value)
+}
+
+export function dimensionsForAspectRatio(ratio: AspectRatio): CanvasDimensions {
+ switch (ratio) {
+ case '16:9':
+ return { width: 1920, height: 1080 }
+ case '9:16':
+ return { width: 1080, height: 1920 }
+ case '1:1':
+ return { width: 1080, height: 1080 }
+ }
+}
diff --git a/src/timeline-core/index.ts b/src/timeline-core/index.ts
index e3642fd..0a14e88 100644
--- a/src/timeline-core/index.ts
+++ b/src/timeline-core/index.ts
@@ -1,3 +1,5 @@
+export { ASPECT_RATIOS, DEFAULT_ASPECT_RATIO, dimensionsForAspectRatio, isAspectRatio } from './aspect'
+export type { AspectRatio, CanvasDimensions } from './aspect'
export { getMediaType, isSupportedAudio, isSupportedMedia, sortByFilename } from './media'
export { moveSlide, toggleExcluded, filterIncluded, createTitleSlide } from './timeline'
export { resolve, applyImageDuration, DEFAULT_GLOBAL_SETTINGS, THEMES, applyTheme } from './settings'