// The pages the file preview shows images and media in. Each is written next // to the downloaded file and loaded from there, so the WebView reads the // file by a relative URL and nothing else on the device. They post 'error' // when the WebView can't decode the file, so the preview can offer another // app instead of a blank page or a dead player. export interface ViewerColors { /** The app's theme, so the WebView draws its own controls to match. */ scheme: 'light' | 'dark'; background: string; text: string; textMuted: string; surface: string; } const ESCAPES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; function escapeHtml(s: string): string { return s.replace(/[&<>"']/g, (ch) => ESCAPES[ch]); } /** A file name as a relative URL: every reserved character encoded. */ export function relativeFileUrl(fileName: string): string { return encodeURIComponent(fileName).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`); } const REPORT = `function report(m){try{window.ReactNativeWebView.postMessage(m)}catch(e){}}`; function page(title: string, colors: ViewerColors, viewport: string, style: string, body: string): string { return '' + `` + `${escapeHtml(title)}` + `` + `${body}`; } /** * An image fitted to the screen, with the WebView's own pinch zoom. Used on * Android, where React Native's Image can't zoom, and for SVG and ICO, which * it can't draw. SVG is shown through , so its scripts never run and it * loads nothing from the network. */ export function imageViewerHtml(fileName: string, colors: ViewerColors): string { // Raster icons scaled up stay crisp instead of blurring. const fillClass = /\.(ico|bmp|gif|png)$/i.test(fileName) ? 'fill pixel' : 'fill'; return page( fileName, colors, 'width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=10,user-scalable=yes', 'body{display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:16px}' + 'img{display:block;max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain}' // A vector or a tiny icon has no useful natural size; fill the screen. + 'img.fill{width:100%;height:100%}' + 'img.pixel{image-rendering:pixelated}', `${escapeHtml(fileName)}` + ``, ); } /** * The platform's own player for a video or a sound. A video fills the * screen on black, with fullscreen and picture in picture where the WebView * offers them; a sound gets a card with its name. Playback starts at once. */ export function mediaViewerHtml(kind: 'video' | 'audio', fileName: string, colors: ViewerColors): string { const src = relativeFileUrl(fileName); const script = ``; if (kind === 'video') { return page( fileName, { ...colors, scheme: 'dark', background: '#000' }, 'width=device-width,initial-scale=1', 'body{display:flex;align-items:center;justify-content:center}video{width:100%;height:100%;object-fit:contain;background:#000}', `${script}`, ); } const note = ''; return page( fileName, colors, 'width=device-width,initial-scale=1', 'body{display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:24px}' + `.card{width:100%;max-width:480px;display:flex;flex-direction:column;align-items:center;gap:20px}` + `.art{width:160px;height:160px;border-radius:24px;background:${colors.surface};color:${colors.textMuted};` + 'display:flex;align-items:center;justify-content:center}' + '.name{font-size:17px;font-weight:600;text-align:center;word-break:break-word}' + 'audio{width:100%}', `
${note}
${escapeHtml(fileName)}
` + `
${script}`, ); } /** * Android's PDF viewer: one placeholder per page at the page's proportions, * filled with an image the app renders when the page comes near the screen * (it posts `page:` and the app answers by calling * `window.__pdfPage(index, src)`). The WebView zooms the whole document, and a * badge shows the page at the middle of the screen. */ export function pdfViewerHtml( fileName: string, pages: ReadonlyArray<{ width: number; height: number }>, colors: ViewerColors, ): string { const sizes = JSON.stringify(pages.map((p) => [Math.max(1, Math.round(p.width)), Math.max(1, Math.round(p.height))])); return page( fileName, { ...colors, background: colors.surface }, 'width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=6,user-scalable=yes', 'body{height:auto;min-height:100%}' + '#doc{padding:8px 8px 56px;display:flex;flex-direction:column;align-items:center;gap:8px}' + '.page{width:100%;max-width:1000px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);position:relative}' + '.page img{position:absolute;inset:0;width:100%;height:100%;opacity:0;transition:opacity .15s}' + '.page img.on{opacity:1}' + `#badge{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);padding:4px 12px;border-radius:999px;` + `background:rgba(0,0,0,.65);color:#fff;font-size:13px;font-variant-numeric:tabular-nums;transition:opacity .3s}` + '#badge.hide{opacity:0}', '
' + ``, ); }