diff --git a/README.md b/README.md index 10eb82f..11f04c9 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,13 @@ when the app is loaded through Qortium Home. - Browse QDN services, names, and resources through deep-linkable routes. - Search public QDN metadata with an optional service filter. - Inspect resource metadata, status, and properties. -- Open apps, websites, media, and documents through the appropriate Home - action, with safe internal viewers for text, Markdown, code, CSV, JSON, and - images. +- Feature-detect Home's generic QDN resource viewer and use it for public + non-browser resources, while keeping APP/WEBSITE/GAME on Home's navigation + path and retaining the older media/document actions as compatibility + fallbacks. +- Use Home-provided ranged URLs for lazy inline image, audio, and video + previews. On older Home versions, bounded image/text previews and the + existing Open actions continue to work. - Browse the individual files of a multi-file resource, with a deep-linkable route per file (`#/detail/{service}/{name}/{identifier}/file/{path}`). - View a published Git repository (bare or worktree layout) as a repository: diff --git a/package-lock.json b/package-lock.json index b953096..bde0a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qortium-explore", - "version": "1.4.9", + "version": "1.4.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qortium-explore", - "version": "1.4.9", + "version": "1.4.10", "hasInstallScript": true, "license": "0BSD", "dependencies": { diff --git a/package.json b/package.json index d73a665..2aaa967 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qortium-explore", - "version": "1.4.9", + "version": "1.4.10", "private": true, "license": "0BSD", "description": "Browse, search, inspect, and open public Qortium QDN resources.", diff --git a/src/App.tsx b/src/App.tsx index 825785a..0b42286 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,10 @@ import { createTranslator } from './i18n'; import { detectGitRepositoryLayout } from './qdnGitRepository'; import { NameOwnerIdentity } from './NameOwnerIdentity'; import { hasHomeBridge, qdnRequest } from './qdnRequest'; +import { + resourceBridgeCapabilities, + type ResourceBridgeCapabilities, +} from './resourceBridge'; import { loadResourceDetails } from './resourceDetails'; import { resourceFetchRequest, resourceFiles } from './resourceFiles'; import { isBrowserArchiveService, PUBLIC_QDN_SERVICES } from './services'; @@ -41,6 +45,8 @@ export function App() { const [gitFallback, setGitFallback] = useState(''); const [previewing, setPreviewing] = useState(false); const [previewMessage, setPreviewMessage] = useState(''); const [previewFailure, setPreviewFailure] = useState(''); const [sourcePreviewSupported, setSourcePreviewSupported] = useState(false); + const [resourceBridge, setResourceBridge] = useState({ resourceViewer: false, streamUrl: false }); + const [resourceBridgeReady, setResourceBridgeReady] = useState(false); const t = useMemo(() => createTranslator(display.language), [display.language]); const navigate = (next: ExploreRoute) => { window.location.hash = hashForRoute(next); }; const replace = (next: ExploreRoute) => { @@ -49,7 +55,21 @@ export function App() { }; useEffect(() => { const onHash = () => setRoute(routeFromHash(window.location.hash)); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); useEffect(() => { applyDisplaySettings(display); const onMessage = (event: MessageEvent) => setDisplay(current => updateFromHostMessage(event.data, current) ?? current); window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [display]); - useEffect(() => { if (!hasHomeBridge()) { setSourcePreviewSupported(false); return; } let active = true; void qdnRequest({ action: 'SHOW_ACTIONS' }).then(actions => { if (active) setSourcePreviewSupported(supportsSourcePreview(actions)); }).catch(() => { if (active) setSourcePreviewSupported(false); }); return () => { active = false; }; }, []); + useEffect(() => { + let active = true; + void qdnRequest({ action: 'SHOW_ACTIONS' }).then(actions => { + if (!active) return; + setSourcePreviewSupported(hasHomeBridge() && supportsSourcePreview(actions)); + setResourceBridge(resourceBridgeCapabilities(actions)); + setResourceBridgeReady(true); + }).catch(() => { + if (!active) return; + setSourcePreviewSupported(false); + setResourceBridge({ resourceViewer: false, streamUrl: false }); + setResourceBridgeReady(true); + }); + return () => { active = false; }; + }, []); useEffect(() => { const request = resourceQuery(route); if (!request) return; let active = true; setLoading(true); setFailure(''); void qdnRequest(request).then(value => { if (!active) return; const nextResources = asResources(value); const detail = singleResourceDetailRoute(route, nextResources); if (detail) { replace(detail); return; } setResources(nextResources); }).catch(error => { if (active) setFailure(errorText(error)); }).finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [route, refresh]); useEffect(() => { setGitFallback(''); if (route.kind !== 'detail') { setDetails(null); return; } let active = true; setDetails(null); setFailure(''); const resource = { service: route.service, name: route.name, identifier: route.identifier }; void loadResourceDetails(qdnRequest, resource).then(value => { if (active) setDetails(value); }).catch(error => { if (active) setFailure(errorText(error)); }); return () => { active = false; }; }, [route]); const folders = useMemo(() => route.kind === 'services' ? groupBy(resources, item => item.service) : route.kind === 'service' ? groupBy(resources, item => item.name) : route.kind === 'name-services' ? groupBy(resources, item => item.service) : [], [resources, route]); @@ -61,16 +81,20 @@ export function App() { const previewLocalFile = () => { setPreviewing(true); setPreviewMessage(''); setPreviewFailure(''); void previewQdnPublishSource(qdnRequest).then(result => setPreviewMessage(result.kind === 'canceled' ? t('preview.canceled') : t('preview.opened'))).catch(error => setPreviewFailure(errorText(error))).finally(() => setPreviewing(false)); }; const detailResource: QdnResource | null = route.kind === 'detail' ? { service: route.service, name: route.name, identifier: route.identifier } : null; if (route.kind === 'detail' && detailResource) { - const file = typeof details?.properties?.filename === 'string' ? details.properties.filename : undefined; - const mime = typeof details?.properties?.mimeType === 'string' ? details.properties.mimeType : undefined; - const open = dispatchOpen(detailResource, { filename: file, mimeType: mime }); - const opensInHomeViewer = open.action === 'OPEN_QDN_DOCUMENT_VIEWER' || open.action === 'OPEN_QDN_MEDIA_PLAYER'; const files = resourceFiles(details?.metadata); const selected = files.includes(route.path ?? '') ? route.path : undefined; const viewed = { ...detailResource, path: selected }; + const file = selected || (typeof details?.properties?.filename === 'string' ? details.properties.filename : undefined); + const mime = selected ? undefined : typeof details?.properties?.mimeType === 'string' ? details.properties.mimeType : undefined; + const open = dispatchOpen(viewed, { + filename: file, + mimeType: mime, + resourceViewer: resourceBridge.resourceViewer, + }); + const opensInHomeViewer = open.action === 'OPEN_QDN_DOCUMENT_VIEWER' || open.action === 'OPEN_QDN_MEDIA_PLAYER' || open.action === 'OPEN_QDN_RESOURCE_VIEWER'; const showFiles = files.length > 1; const showGit = !selected && !gitFallback && !!detectGitRepositoryLayout(files); - return

{t('app.title')}

{detailResource.service} / {detailResource.name} / {detailResource.identifier || 'default'}{selected ? ` / ${selected}` : ''}

{open.action === 'INTERNAL_VIEWER' ? null : }{isBrowserArchiveService(detailResource.service) ? : null}
{failure ?

{failure}

: null}

{t('label.details')}

{t('label.title')}
{String(details?.metadata?.title || '—')}
{t('label.description')}
{String(details?.metadata?.description || '—')}
{t('label.status')}
{String(details?.status?.status || '—')}
{t('column.size')}
{bytes(details?.status?.size)}
{t('column.updated')}
{date(details?.status?.updated)}
{showFiles ? <>

{t('label.files')} {files.length.toLocaleString()}

{files.map(path => )}
: null}

{t('label.properties')}

{JSON.stringify(details?.properties || {}, null, 2)}

{showGit ? t('git.title') : selected || (opensInHomeViewer ? t('viewer.preview') : t('viewer.source'))}

{selected ? : null}{gitFallback ?

{gitFallback}

: null}{showGit ? : showFiles && !selected ?

{t('viewer.selectFile')}

: }
; + return

{t('app.title')}

{detailResource.service} / {detailResource.name} / {detailResource.identifier || 'default'}{selected ? ` / ${selected}` : ''}

{open.action === 'INTERNAL_VIEWER' ? null : }{isBrowserArchiveService(detailResource.service) ? : null}
{failure ?

{failure}

: null}

{t('label.details')}

{t('label.title')}
{String(details?.metadata?.title || '—')}
{t('label.description')}
{String(details?.metadata?.description || '—')}
{t('label.status')}
{String(details?.status?.status || '—')}
{t('column.size')}
{bytes(details?.status?.size)}
{t('column.updated')}
{date(details?.status?.updated)}
{showFiles ? <>

{t('label.files')} {files.length.toLocaleString()}

{files.map(path => )}
: null}

{t('label.properties')}

{JSON.stringify(details?.properties || {}, null, 2)}

{showGit ? t('git.title') : selected || (opensInHomeViewer ? t('viewer.preview') : t('viewer.source'))}

{selected ? : null}{gitFallback ?

{gitFallback}

: null}{showGit ? : showFiles && !selected ?

{t('viewer.selectFile')}

: }
; } return

{t('app.title')}

{t('app.subtitle')} {__APP_VERSION__}

{sourcePreviewSupported ? : null}
{previewMessage ?

{previewMessage}

: null}{previewFailure ?

{previewFailure}

: null}
setSearch(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') doSearch(); }} />{searchResults ? : null}

{route.kind === 'services' ? 'QDN' : route.kind === 'service' ? route.service : route.kind === 'name-services' ? route.name : `${route.service} / ${route.name}`}

{failure ?
{t('error.coreOffline')}

{failure}

: null}{loading && !resources.length ?

{t('loading')}

: null}{!searchResults && folders.length > 0 ?
{t('label.name')} toggle('count')}>{t('column.count')} toggle('updated')}>{t('column.updated')}
{sortedFolders.map(row => )}
: null}{(searchResults || route.kind === 'resources') &&
{sortedResources.map(resource => )}
}{!loading && !failure && ((searchResults && !searchResults.length) || (!searchResults && !folders.length && route.kind !== 'resources') || (route.kind === 'resources' && !resources.length)) ?

{searchResults ? t('empty.search') : t('empty.resources')}

: null}
; } diff --git a/src/contentViewer.test.ts b/src/contentViewer.test.ts new file mode 100644 index 0000000..8398010 --- /dev/null +++ b/src/contentViewer.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { classifyContent } from './contentViewer'; + +const resource = (service: string, path?: string) => ({ + service, + name: 'Alice', + identifier: 'one', + path, +}); + +describe('content viewer media classification', () => { + it.each([ + ['AUDIO', 'audio'], + ['VOICE', 'audio'], + ['PODCAST', 'audio'], + ['VIDEO', 'video'], + ['IMAGE', 'image'], + ] as const)('classifies %s by service as %s', (service, kind) => { + expect(classifyContent(resource(service))).toBe(kind); + }); + + it('classifies media inside generic file services by selected filename', () => { + expect(classifyContent(resource('FILES', 'media/song.opus'))).toBe('audio'); + expect(classifyContent(resource('ATTACHMENT', 'media/movie.webm'))).toBe('video'); + expect(classifyContent(resource('FILE'), { filename: 'cover.avif' })).toBe('image'); + }); + + it('does not apply container MIME hints to an individually selected file', () => { + expect(classifyContent(resource('FILES', 'README.md'), { mimeType: 'video/mp4' })).toBe('markdown'); + }); +}); diff --git a/src/contentViewer.tsx b/src/contentViewer.tsx index df23be2..7600f44 100644 --- a/src/contentViewer.tsx +++ b/src/contentViewer.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from 'react'; import { previewCache, previewCacheKey } from './previewCache'; import { qdnRequest } from './qdnRequest'; +import { canStreamResource, resourceStreamRequest, safeQdnStreamUrl } from './resourceBridge'; import { resourceFetchRequest } from './resourceFiles'; import type { QdnResource } from './types'; export const CONTENT_MAX_BYTES = 2 * 1024 * 1024; const IMAGE_MIME_TYPES: Record = { avif: 'image/avif', bmp: 'image/bmp', gif: 'image/gif', ico: 'image/x-icon', jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', svg: 'image/svg+xml', webp: 'image/webp' }; -export type ContentKind = 'binary' | 'csv' | 'image' | 'json' | 'markdown' | 'text'; +export type ContentKind = 'audio' | 'binary' | 'csv' | 'image' | 'json' | 'markdown' | 'text' | 'video'; function filename(resource: QdnResource, properties?: Record) { return resource.path || (typeof properties?.filename === 'string' ? properties.filename : '') || resource.identifier || 'resource'; } function extension(name: string) { return name.toLowerCase().split('/').pop()?.split('.').slice(1).pop() ?? ''; } @@ -23,6 +24,8 @@ export function classifyContent(resource: QdnResource, properties?: Record; binaryMessage?: string }) { - if (kind === 'binary') return

{binaryMessage || 'This resource cannot be rendered safely in Explore. Use Download to save its original bytes.'}

; + if (kind === 'binary' || kind === 'audio' || kind === 'video') return

{binaryMessage || 'This resource cannot be rendered safely in Explore. Use Download to save its original bytes.'}

; if (kind === 'image') return {filename(resource,; if (kind === 'json') { try { return
{JSON.stringify(JSON.parse(data), null, 2)}
; } catch { return
{data}
; } } if (kind === 'csv') { const rows = csvRows(data); return
{rows.map((row, i) => {row.map((cell, j) => i === 0 ? : )})}
{cell}{cell}
; } @@ -53,12 +56,64 @@ export function ContentPreview({ kind, data, resource, properties, binaryMessage return
{data}
; } -export function ContentViewer({ resource, properties, binaryMessage }: { resource: QdnResource; properties?: Record; binaryMessage?: string }) { +function StreamedContent({ kind, resource, properties }: { kind: 'audio' | 'image' | 'video'; resource: QdnResource; properties?: Record }) { + const [state, setState] = useState<{ error?: string; loading: boolean; url?: string }>({ loading: true }); + const knownFilename = filename(resource, properties); + const knownMimeType = resource.path ? undefined : String(properties?.mimeType || properties?.mimetype || '') || undefined; + + useEffect(() => { + let active = true; + let objectUrl: string | undefined; + const abortController = new AbortController(); + setState({ loading: true }); + const load = async () => { + const value = await qdnRequest(resourceStreamRequest(resource, { + filename: knownFilename, + mimeType: knownMimeType, + })); + const safeStreamUrl = safeQdnStreamUrl(value); + + if (kind === 'image') { + const response = await fetch(safeStreamUrl, { signal: abortController.signal }); + if (!response.ok) throw new Error(`Image request failed with HTTP ${response.status}.`); + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) throw new Error('Image response did not contain an image.'); + objectUrl = URL.createObjectURL(blob); + if (active) setState({ loading: false, url: objectUrl }); + return; + } + + if (active) setState({ loading: false, url: safeStreamUrl }); + }; + void load().catch((error) => { + if (active && !abortController.signal.aborted) setState({ + error: error instanceof Error ? error.message : 'Unable to open the media stream.', + loading: false, + }); + }); + return () => { + active = false; + abortController.abort(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [knownFilename, knownMimeType, resource.identifier, resource.name, resource.path, resource.service]); + + if (state.loading) return

Loading preview…

; + if (state.error || !state.url) return

{state.error || 'Unable to open the media stream.'}

; + if (kind === 'image') return {knownFilename}; + if (kind === 'audio') return