import React, { useEffect, useState } from 'react';
import { type ImageErrorEventData, type NativeSyntheticEvent, Platform, type StyleProp, type ViewStyle } from 'react-native';
import { Image, View } from 'react-native';
import type { TextTrackCue } from 'react-native-theoplayer';
import { isThumbnailTrack, TextTrack } from 'react-native-theoplayer';
import { StaticTimeLabel } from '@theoplayer/react-native-ui';
import type { Thumbnail } from './Thumbnail';
import { isTileMapThumbnail } from './Thumbnail';
import { URL as URLPolyfill } from './Urlpolyfill';
const SPRITE_REGEX = /^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)\s*$/;
const TAG = 'ThumbnailView';
export interface ThumbnailViewProps {
/**
* Thumbnail track. A valid thumbnail track should have properties:
*
- `'kind'` equals `'metadata'`.
*
- `'label'` equals `'thumbnails'`.
*/
thumbnailTrack: TextTrack;
/**
* Current time.
*/
time: number;
/**
* Stream duration
*/
duration: number;
/**
* Whether to show a time label.
*
* @default false.
*/
showTimeLabel?: boolean;
/**
* Used to set the width of the rendered thumbnail. The height will be calculated according to the image's aspect ratio.
*/
size: number;
/**
* Optional style applied to the time label.
*/
timeLabelStyle?: StyleProp;
}
export const DEFAULT_THUMBNAIL_VIEW_STYLE: ThumbnailStyle = {
containerThumbnail: {
alignItems: 'center',
flexDirection: 'row',
},
thumbnail: {
overflow: 'hidden',
backgroundColor: 'transparent',
},
};
export interface ThumbnailStyle {
containerThumbnail: ViewStyle;
thumbnail: ViewStyle;
}
function getCueIndexAtTime(thumbnailTrack: TextTrack, time: number): number | undefined {
// Ignore if it's an invalid track or not a thumbnail track.
if (!isThumbnailTrack(thumbnailTrack)) {
console.warn(TAG, 'Invalid thumbnail track');
return undefined;
}
// Ignore if the track does not have cues
if (thumbnailTrack.cues == null || thumbnailTrack.cues.length == 0) {
return undefined;
}
const cues = thumbnailTrack.cues;
let cueIndex = 0;
for (const [index, cue] of cues.entries()) {
if (cue.startTime <= time) {
cueIndex = index;
} else if (time >= cue.endTime) {
return cueIndex;
}
}
return cueIndex;
}
function resolveThumbnailUrl(thumbnailTrack: TextTrack, thumbnail: string): string {
if (thumbnailTrack && thumbnailTrack.src) {
return new URLPolyfill(thumbnail, thumbnailTrack.src).href;
} else {
return thumbnail;
}
}
function getThumbnailImageForCue(thumbnailTrack: TextTrack, cue: TextTrackCue): Thumbnail | null {
const thumbnailContent = cue && cue.content;
if (!thumbnailContent) {
// Cue does not contain any thumbnail info.
return null;
}
const spriteMatch = thumbnailContent.match(SPRITE_REGEX);
if (spriteMatch) {
// The thumbnail is part of a tile.
const [, url, x, y, w, h] = spriteMatch;
return {
tileX: +x,
tileY: +y,
tileWidth: +w,
tileHeight: +h,
url: resolveThumbnailUrl(thumbnailTrack, url),
};
} else {
// The thumbnail is a separate image.
return {
url: resolveThumbnailUrl(thumbnailTrack, thumbnailContent),
};
}
}
/**
* Calculate the dimensions of the thumbnail tile map based on the W3C Media Fragments URIs for all cues.
*
* @param thumbnailTrack
*/
function maxThumbnailSize(thumbnailTrack: TextTrack) {
let maxWidth = 0,
maxHeight = 0;
thumbnailTrack.cues?.forEach((cue) => {
if (cue && cue.content) {
const spriteMatch = cue.content.match(SPRITE_REGEX);
if (spriteMatch) {
const [, , tileX, tileY, tileWidth, tileHeight] = spriteMatch;
maxWidth = Math.max(maxWidth, +tileX + +tileWidth);
maxHeight = Math.max(maxHeight, +tileY + +tileHeight);
}
}
});
return { maxTileWidth: maxWidth, maxTileHeight: maxHeight };
}
export const ThumbnailView = (props: ThumbnailViewProps) => {
const [mounted, setMounted] = useState(false);
const [imageWidth, setImageWidth] = useState(props.size);
const [imageHeight, setImageHeight] = useState(props.size);
const [renderWidth, setRenderWidth] = useState(1);
const [renderHeight, setRenderHeight] = useState(1);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
return () => {
setMounted(false);
};
}, []);
const onTileImageLoad = (thumbnail: Thumbnail) => () => {
if (!mounted) {
return;
}
const { size } = props;
const { tileWidth, tileHeight } = thumbnail;
if (tileWidth && tileHeight) {
/**
* On Android, Fresco can scale the React Native `` component internally if it is considered to be
* 'huge' (larger than 2048px width). There is no way to know the original size without using another
* image package.
* This work-around calculates the maximum tile size based on all cue W3C Media Fragments URIs.
*
* {@link https://github.com/facebook/react-native/issues/22145}
*/
const { maxTileWidth, maxTileHeight } =
Platform.OS === 'android' ? maxThumbnailSize(props.thumbnailTrack) : { maxTileWidth: 0, maxTileHeight: 0 };
Image.getSize(thumbnail.url, (width: number, height: number) => {
setImageWidth(Math.max(width, maxTileWidth));
setImageHeight(Math.max(height, maxTileHeight));
setRenderWidth(size);
setRenderHeight((tileHeight * size) / tileWidth);
});
}
};
const onImageLoadError = (event: NativeSyntheticEvent) => {
console.error(TAG, 'Failed to load thumbnail url:', event.nativeEvent.error);
};
const onImageLoad = (thumbnail: Thumbnail) => () => {
if (!mounted) {
return;
}
const { size } = props;
Image.getSize(thumbnail.url, (width: number, height: number) => {
setImageWidth(width);
setImageHeight(height);
setRenderWidth(size);
setRenderHeight((height * size) / width);
});
};
const renderThumbnail = (thumbnail: Thumbnail, index: number) => {
const { size } = props;
const scale = 1.0;
if (isTileMapThumbnail(thumbnail)) {
const ratio = thumbnail.tileWidth == 0 ? 0 : (scale * size) / thumbnail.tileWidth;
return (
);
} else {
return (
);
}
};
const { time, duration, thumbnailTrack, showTimeLabel, timeLabelStyle } = props;
if (!thumbnailTrack || !thumbnailTrack.cues || thumbnailTrack.cues.length === 0) {
// No thumbnails to render.
return <>>;
}
const nowCueIndex = getCueIndexAtTime(thumbnailTrack, time);
if (nowCueIndex === undefined) {
// No thumbnail for current time
return <>>;
}
const current = getThumbnailImageForCue(thumbnailTrack, thumbnailTrack.cues[nowCueIndex]);
if (current === null) {
// No thumbnail for current time
return <>>;
}
return (
{showTimeLabel && (
)}
{renderThumbnail(current, 0)}
);
};