feat: vod display (#686)

* feat: preview for html5 supported videos

* fix: handle out of limit tags

* feat: support preview on the donwload screen

* refactor: rework meta and preview handling to be more general

* fix: missing meta

* fix: do not allow maybe or probably types

* fix: make the media check more strict

---------

Co-authored-by: Levente Kiss <levente.kiss@solarpunk.bzz>
This commit is contained in:
Levente Kiss
2025-02-12 11:35:46 +01:00
committed by GitHub
parent f695ac3a1c
commit bcd3d50b42
12 changed files with 170 additions and 93 deletions
+29
View File
@@ -0,0 +1,29 @@
import { createStyles, makeStyles } from '@material-ui/core'
import { ReactElement } from 'react'
const useStyles = makeStyles(() =>
createStyles({
video: {
width: '100%',
height: '100%',
objectFit: 'cover',
},
}),
)
interface VideoProps {
src: string | undefined
maxHeight?: string
maxWidth?: string
}
export function FitVideo(props: VideoProps): ReactElement {
const classes = useStyles()
const inlineStyles: Record<string, string> = {}
props.maxHeight && (inlineStyles.maxHeight = props.maxHeight)
props.maxWidth && (inlineStyles.maxWidth = props.maxWidth)
return <video className={classes.video} src={props.src} style={inlineStyles} controls />
}
+1 -2
View File
@@ -1,5 +1,4 @@
export const META_FILE_NAME = '.swarmgatewaymeta.json' export const META_FILE_NAME = 'metadata'
export const PREVIEW_FILE_NAME = '.swarmgatewaypreview.jpeg'
export const PREVIEW_DIMENSIONS = { maxWidth: 250, maxHeight: 175 } export const PREVIEW_DIMENSIONS = { maxWidth: 250, maxHeight: 175 }
export const BZZ_LINK_DOMAIN = 'bzz.link' export const BZZ_LINK_DOMAIN = 'bzz.link'
export const BLOCKCHAIN_EXPLORER_URL = 'https://blockscout.com/xdai/mainnet' export const BLOCKCHAIN_EXPLORER_URL = 'https://blockscout.com/xdai/mainnet'
+32 -15
View File
@@ -1,6 +1,6 @@
import { Box, Grid, Typography } from '@material-ui/core' import { Box, Grid, Typography } from '@material-ui/core'
import { Web } from '@material-ui/icons' import { Web } from '@material-ui/icons'
import { ReactElement } from 'react' import { ReactElement, useMemo } from 'react'
import File from 'remixicon-react/FileLineIcon' import File from 'remixicon-react/FileLineIcon'
import Folder from 'remixicon-react/FolderLineIcon' import Folder from 'remixicon-react/FolderLineIcon'
import { FitImage } from '../../components/FitImage' import { FitImage } from '../../components/FitImage'
@@ -8,35 +8,52 @@ import { shortenText } from '../../utils'
import { getHumanReadableFileSize } from '../../utils/file' import { getHumanReadableFileSize } from '../../utils/file'
import { shortenHash } from '../../utils/hash' import { shortenHash } from '../../utils/hash'
import { AssetIcon } from './AssetIcon' import { AssetIcon } from './AssetIcon'
import { FitVideo } from '../../components/FitVideo'
interface Props { interface Props {
previewUri?: string previewUri?: string
metadata?: Metadata metadata?: Metadata
} }
// TODO: add optional prop for indexDocument when it is already known (e.g. downloading a manifest) /* eslint-disable react/display-name */
const getPreviewComponent = (previewUri?: string, metadata?: Metadata) => {
if (metadata?.isVideo) {
return () => <FitVideo src={previewUri} maxWidth="250px" maxHeight="175px" />
}
export function AssetPreview({ metadata, previewUri }: Props): ReactElement | null { if (metadata?.isImage) {
let previewComponent = <File /> return () => <FitImage maxWidth="250px" maxHeight="175px" alt="Upload Preview" src={previewUri} />
let type = metadata?.type }
if (metadata?.isWebsite) { if (metadata?.isWebsite) {
previewComponent = <Web /> return () => <AssetIcon icon={<Web />} />
type = 'Website'
} else if (metadata?.type === 'folder') {
previewComponent = <Folder />
type = 'Folder'
} }
if (metadata?.type === 'folder') {
return () => <AssetIcon icon={<Folder />} />
}
return () => <AssetIcon icon={<File />} />
}
const getType = (metadata?: Metadata) => {
if (metadata?.isWebsite) return 'Website'
if (metadata?.type === 'folder') return 'Folder'
return metadata?.type
}
// TODO: add optional prop for indexDocument when it is already known (e.g. downloading a manifest)
export function AssetPreview({ metadata, previewUri }: Props): ReactElement | null {
const PreviewAssetComponent = useMemo(() => getPreviewComponent(previewUri, metadata), [metadata, previewUri])
const type = useMemo(() => getType(metadata), [metadata])
return ( return (
<Box mb={4}> <Box mb={4}>
<Box bgcolor="background.paper"> <Box bgcolor="background.paper">
<Grid container direction="row"> <Grid container direction="row">
{previewUri ? ( <PreviewAssetComponent />
<FitImage maxWidth="250px" maxHeight="175px" alt="Upload Preview" src={previewUri} />
) : (
<AssetIcon icon={previewComponent} />
)}
<Box p={2}> <Box p={2}>
{metadata?.hash && <Typography>Swarm Hash: {shortenHash(metadata.hash)}</Typography>} {metadata?.hash && <Typography>Swarm Hash: {shortenHash(metadata.hash)}</Typography>}
{metadata?.name && metadata?.name !== metadata?.hash && ( {metadata?.name && metadata?.name !== metadata?.hash && (
+14 -7
View File
@@ -3,6 +3,7 @@ import { Box } from '@material-ui/core'
import { ReactElement, useContext, useEffect, useRef, useState } from 'react' import { ReactElement, useContext, useEffect, useRef, useState } from 'react'
import { DocumentationText } from '../../components/DocumentationText' import { DocumentationText } from '../../components/DocumentationText'
import { LinearProgressWithLabel } from '../../components/ProgressBar' import { LinearProgressWithLabel } from '../../components/ProgressBar'
import { Tag } from '@ethersphere/bee-js'
interface Props { interface Props {
reference: string reference: string
@@ -16,12 +17,20 @@ export function AssetSyncing({ reference }: Props): ReactElement {
const [syncProgress, setSyncProgress] = useState<number>(0) const [syncProgress, setSyncProgress] = useState<number>(0)
const syncCheck = async () => { const syncCheck = async () => {
if (!beeApi) { if (!beeApi) return
return
}
const tags = await beeApi.getAllTags() let allTags: Tag[] = []
const tag = tags.find(t => t.address === reference) let offset = 0
const limit = 1000
let tagsBatch
do {
tagsBatch = await beeApi.getAllTags({ limit, offset })
allTags = allTags.concat(tagsBatch)
offset += limit
} while (tagsBatch.length === limit) // Continue if the batch is full, stop if fewer than the limit
const tag = allTags.find(t => t.address === reference)
if (tag) { if (tag) {
const progress = ((tag.seen + tag.synced) / tag.split) * 100 const progress = ((tag.seen + tag.synced) / tag.split) * 100
@@ -51,8 +60,6 @@ export function AssetSyncing({ reference }: Props): ReactElement {
There are instances when it seems that the content isn't synchronized, despite being already available. There are instances when it seems that the content isn't synchronized, despite being already available.
To ensure it's not due to invalid synchronization data, To ensure it's not due to invalid synchronization data,
verify availability from at least 70% using one of the stewardship endpoints. verify availability from at least 70% using one of the stewardship endpoints.
TODO: is 70 a good number?
*/ */
if (beeApi && !isRetrieveChecking && syncProgress > 10 && syncProgress < 100) { if (beeApi && !isRetrieveChecking && syncProgress > 10 && syncProgress < 100) {
// It's a long running task make sure only one run occurs at a time. // It's a long running task make sure only one run occurs at a time.
+24 -27
View File
@@ -7,7 +7,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import { HistoryHeader } from '../../components/HistoryHeader' import { HistoryHeader } from '../../components/HistoryHeader'
import { Loading } from '../../components/Loading' import { Loading } from '../../components/Loading'
import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard' import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard'
import { META_FILE_NAME, PREVIEW_FILE_NAME } from '../../constants' import { META_FILE_NAME } from '../../constants'
import { Context as BeeContext } from '../../providers/Bee' import { Context as BeeContext } from '../../providers/Bee'
import { Context as SettingsContext } from '../../providers/Settings' import { Context as SettingsContext } from '../../providers/Settings'
import { ROUTES } from '../../routes' import { ROUTES } from '../../routes'
@@ -50,38 +50,35 @@ export function Share(): ReactElement {
return return
} }
const entries = await manifestJs.getHashes(reference)
const entries = await manifestJs.getHashes(reference, { exclude: [META_FILE_NAME] })
setSwarmEntries(entries)
const indexDocument = await manifestJs.getIndexDocumentPath(reference) const indexDocument = await manifestJs.getIndexDocumentPath(reference)
setIndexDocument(indexDocument) setIndexDocument(indexDocument)
const previewFile = entries[PREVIEW_FILE_NAME]
delete entries[META_FILE_NAME]
delete entries[PREVIEW_FILE_NAME]
setSwarmEntries(entries)
const count = Object.keys(entries).length
let metadata: Metadata | undefined = {
hash,
size: 0,
type: count > 1 ? 'folder' : 'unknown',
name: reference,
isWebsite: Boolean(indexDocument) && count > 1,
count,
}
try { try {
const mtdt = await beeApi.downloadFile(reference, META_FILE_NAME) const remoteMetadata = await beeApi.downloadFile(reference, META_FILE_NAME)
const remoteMetadata = mtdt.data.text() const formattedMetadata = JSON.parse(remoteMetadata.data.text()) as Metadata
metadata = { ...metadata, ...(JSON.parse(remoteMetadata) as Metadata) }
} catch (e) {} // eslint-disable-line no-empty
if (previewFile) { if (formattedMetadata.isVideo || formattedMetadata.isImage) {
setPreview(`${apiUrl}/bzz/${reference}/${PREVIEW_FILE_NAME}`) setPreview(`${apiUrl}/bzz/${reference}`)
}
setMetadata({ ...formattedMetadata, hash })
} catch (e) {
// if metadata is not available or invalid go with the default one
const count = Object.keys(entries).length
setMetadata({
hash,
type: count > 1 ? 'folder' : 'unknown',
name: reference,
count,
isWebsite: Boolean(indexDocument && /.*\.html?$/i.test(indexDocument)),
isVideo: Boolean(indexDocument && /.*\.(mp4|webm|ogg|mp3|ogg|wav)$/i.test(indexDocument)),
isImage: Boolean(indexDocument && /.*\.(jpg|jpeg|png|gif|webp|svg)$/i.test(indexDocument)),
// naive assumption based on indexDocument, we don't want to donwload the whole manifest
})
} }
setMetadata(metadata)
} }
function onOpen() { function onOpen() {
+4 -20
View File
@@ -6,7 +6,7 @@ import { DocumentationText } from '../../components/DocumentationText'
import { HistoryHeader } from '../../components/HistoryHeader' import { HistoryHeader } from '../../components/HistoryHeader'
import { ProgressIndicator } from '../../components/ProgressIndicator' import { ProgressIndicator } from '../../components/ProgressIndicator'
import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard' import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard'
import { META_FILE_NAME, PREVIEW_FILE_NAME } from '../../constants' import { META_FILE_NAME } from '../../constants'
import { Context as BeeContext, CheckState } from '../../providers/Bee' import { Context as BeeContext, CheckState } from '../../providers/Bee'
import { Identity, Context as IdentityContext } from '../../providers/Feeds' import { Identity, Context as IdentityContext } from '../../providers/Feeds'
import { Context as FileContext } from '../../providers/File' import { Context as FileContext } from '../../providers/File'
@@ -33,7 +33,7 @@ export function Upload(): ReactElement {
const { stamps, refresh } = useContext(StampsContext) const { stamps, refresh } = useContext(StampsContext)
const { beeApi } = useContext(SettingsContext) const { beeApi } = useContext(SettingsContext)
const { files, setFiles, uploadOrigin, metadata, previewUri, previewBlob } = useContext(FileContext) const { files, setFiles, uploadOrigin, metadata, previewUri } = useContext(FileContext)
const { identities, setIdentities } = useContext(IdentityContext) const { identities, setIdentities } = useContext(IdentityContext)
const { status } = useContext(BeeContext) const { status } = useContext(BeeContext)
@@ -98,31 +98,15 @@ export function Upload(): ReactElement {
} }
} }
} }
const lastModified = files[0].lastModified const lastModified = files[0].lastModified
// We want to store only some metadata const metafile = new File([JSON.stringify(metadata)], META_FILE_NAME, {
const mtd: SwarmMetadata = {
name: metadata.name,
size: metadata.size,
}
// Type of the file only makes sense for a single file
if (files.length === 1) mtd.type = metadata.type
const metafile = new File([JSON.stringify(mtd)], META_FILE_NAME, {
type: 'application/json', type: 'application/json',
lastModified, lastModified,
}) })
fls.push(packageFile(metafile)) fls.push(packageFile(metafile))
if (previewBlob) {
const previewFile = new File([previewBlob], PREVIEW_FILE_NAME, {
type: 'image/jpeg',
lastModified,
})
fls.push(packageFile(previewFile))
}
setUploading(true) setUploading(true)
await waitUntilStampUsable(stamp.batchID, beeApi) await waitUntilStampUsable(stamp.batchID, beeApi)
+16 -6
View File
@@ -41,7 +41,8 @@ export function Provider({ children }: Props): ReactElement {
const [previewBlob, setPreviewBlob] = useState<Blob | undefined>(undefined) const [previewBlob, setPreviewBlob] = useState<Blob | undefined>(undefined)
useEffect(() => { useEffect(() => {
setMetadata(getMetadata(files)) const metadata = getMetadata(files)
setMetadata(metadata)
if (previewUri) { if (previewUri) {
URL.revokeObjectURL(previewUri) // Clear the preview from memory URL.revokeObjectURL(previewUri) // Clear the preview from memory
@@ -49,12 +50,21 @@ export function Provider({ children }: Props): ReactElement {
setPreviewBlob(undefined) setPreviewBlob(undefined)
} }
if (files.length !== 1 || !files[0].type.startsWith('image')) return if (files.length !== 1) return
resize(files[0], PREVIEW_DIMENSIONS.maxWidth, PREVIEW_DIMENSIONS.maxHeight).then(blob => { if (metadata.isVideo) {
setPreviewUri(URL.createObjectURL(blob)) // NOTE: Until it is cleared with URL.revokeObjectURL, the file stays allocated in memory const videoFile = files[0]
setPreviewBlob(blob) const videoBlob = new Blob([videoFile], { type: videoFile.type })
}) setPreviewUri(URL.createObjectURL(videoBlob))
setPreviewBlob(videoBlob)
}
if (metadata.isImage) {
resize(files[0], PREVIEW_DIMENSIONS.maxWidth, PREVIEW_DIMENSIONS.maxHeight).then(blob => {
setPreviewUri(URL.createObjectURL(blob)) // NOTE: Until it is cleared with URL.revokeObjectURL, the file stays allocated in memory
setPreviewBlob(blob)
})
}
return () => { return () => {
if (previewUri) { if (previewUri) {
+4 -2
View File
@@ -6,14 +6,16 @@ interface LatestBeeRelease {
} }
interface SwarmMetadata { interface SwarmMetadata {
size: number size?: number
name: string name: string
type?: string type?: string
} }
interface Metadata extends SwarmMetadata { interface Metadata extends SwarmMetadata {
type: string type: string
isWebsite: boolean isWebsite?: boolean
isVideo?: boolean
isImage?: boolean
count?: number count?: number
hash?: string hash?: string
} }
+7 -2
View File
@@ -1,3 +1,6 @@
import { isSupportedImageType } from './image'
import { isSupportedVideoType } from './video'
const indexHtmls = ['index.html', 'index.htm'] const indexHtmls = ['index.html', 'index.htm']
interface DetectedIndex { interface DetectedIndex {
@@ -83,12 +86,14 @@ export function getAssetNameFromFiles(files: FilePath[]): string {
export function getMetadata(files: FilePath[]): Metadata { export function getMetadata(files: FilePath[]): Metadata {
const size = files.reduce((total, item) => total + item.size, 0) const size = files.reduce((total, item) => total + item.size, 0)
const isWebsite = Boolean(detectIndexHtml(files))
const name = getAssetNameFromFiles(files) const name = getAssetNameFromFiles(files)
const type = files.length === 1 ? files[0].type : 'folder' const type = files.length === 1 ? files[0].type : 'folder'
const count = files.length const count = files.length
const isWebsite = Boolean(detectIndexHtml(files))
const isVideo = isSupportedVideoType(type)
const isImage = isSupportedImageType(type)
return { size, name, type, isWebsite, count } return { size, name, type, isWebsite, count, isVideo, isImage }
} }
export function getPath(file: FilePath): string { export function getPath(file: FilePath): string {
+23 -10
View File
@@ -25,6 +25,28 @@ export function getDimensions(imgWidth: number, imgHeight: number, maxWidth?: nu
return { width: imgWidth / ratio, height: imgHeight / ratio } return { width: imgWidth / ratio, height: imgHeight / ratio }
} }
function getAllowedTypes(): string[] {
return [
'image/bmp',
'image/gif',
'image/vnd.microsoft.icon',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/tiff',
'image/webp',
]
}
/**
* Check if the image type is supported
*
* @param type Image MIME type
*
* @returns True if the type is supported, false otherwise
*/
export const isSupportedImageType = (type: string): boolean => getAllowedTypes().includes(type)
/** /**
* Resize image passed to fit in the bounding box defined with maxWidth and maxHeight. * Resize image passed to fit in the bounding box defined with maxWidth and maxHeight.
* Note that one or both of the bounding box dimensions may be omitted * Note that one or both of the bounding box dimensions may be omitted
@@ -37,16 +59,7 @@ export function getDimensions(imgWidth: number, imgHeight: number, maxWidth?: nu
*/ */
export function resize(file: File, maxWidth?: number, maxHeight?: number): Promise<Blob> { export function resize(file: File, maxWidth?: number, maxHeight?: number): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const allowedTypes = [ const allowedTypes = getAllowedTypes()
'image/bmp',
'image/gif',
'image/vnd.microsoft.icon',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/tiff',
'image/webp',
]
if (!file.size || !file.type || !allowedTypes.includes(file.type)) return reject('File not supported!') if (!file.size || !file.type || !allowedTypes.includes(file.type)) return reject('File not supported!')
+8 -2
View File
@@ -50,14 +50,20 @@ export class ManifestJs {
/** /**
* Retrieves all paths with the associated hashes from a Swarm manifest * Retrieves all paths with the associated hashes from a Swarm manifest
*/ */
public async getHashes(hash: string): Promise<Record<string, string>> { public async getHashes(hash: string, options?: { exclude: string[] }): Promise<Record<string, string>> {
const data = await this.bee.downloadData(hash) const data = await this.bee.downloadData(hash)
const node = new MantarayNode() const node = new MantarayNode()
node.deserialize(data) node.deserialize(data)
await loadAllNodes(this.load.bind(this), node) await loadAllNodes(this.load.bind(this), node)
const result = {} const result: Record<string, string> = {}
this.extractHashes(result, node) this.extractHashes(result, node)
if (options?.exclude) {
for (const path of options.exclude) {
delete result[path]
}
}
return result return result
} }
+8
View File
@@ -0,0 +1,8 @@
export function isSupportedVideoType(type: string) {
const video = document.createElement('video')
const result = video.canPlayType(type)
const isDefinitelySupported = result && result !== 'maybe'
return Boolean(isDefinitelySupported)
}