import { Grid, LinearProgress, Typography, withStyles } from '@material-ui/core' import ErrorIcon from '@material-ui/icons/Error' import AsyncLock from 'async-lock' import { ipcRenderer } from 'electron' import { ipcRenderer as ipc } from 'electron-better-ipc' import electronLog from 'electron-log' import moment, { Moment } from 'moment' import * as React from 'react' import styled from 'styled-components' import { DownloadedThumbnailIpc, DownloadThumbnailIpcRequest } from '../../../shared' import { DOWNLOAD_THUMBNAIL_CHANNEL, GET_VIEW_DOWNLOAD_TIME, VIEW_DOWNLOAD_PROGRESS, VISIBILITY_CHANGE_ALERT_CHANNEL } from '../../../shared/IpcDefinitions' import ReloadButton from './ReloadButton' import StatusIconAndDialog from './StatusIcon' ipcRenderer.setMaxListeners(30) const log = electronLog.scope('thumbnail-component') interface IsSelectedStyleProps { readonly isSelected: boolean } /** * Styling for the image itself. */ const Image = styled.img` max-width: 100%; max-height: 100%; pointer-events: none; position: absolute; left: 0; right: 0; margin-left: auto; margin-right: auto; ` /** * Clickable container which the image is in. */ const ImageContainer = styled.div<IsSelectedStyleProps>` --width: 200px; --height: calc((var(--width) * 3) / 5); width: var(--width); height: var(--height); background-color: black; border-radius: var(--image-border-radius); box-shadow: ${props => (!props.isSelected ? '0 3px 10px rgba(0, 0, 0, 0.3)' : 'none')}; transition: box-shadow var(--transition-time); overflow: hidden; position: relative; ` /** * Background of the image container which acts as a border. */ const ImageContainerBackground = styled.div<IsSelectedStyleProps>` background: ${props => (props.isSelected ? props.theme.colors.borderHighlight : 'transparent')}; border-radius: var(--image-border-radius); padding: 4px; box-shadow: ${props => (props.isSelected ? '0 3px 20px rgba(0, 0, 0, 0.5)' : 'none')}; transition: box-shadow var(--transition-time), background-color var(--transition-time); cursor: ${props => (props.isSelected ? 'default' : 'pointer')}; // Hide reload icon when not hovering #reload-icon { display: none; } &:hover #reload-icon { display: block; } ` /** * Name describing the thumbnail. */ const ThumbnailName = styled.p<IsSelectedStyleProps>` font-family: Roboto, sans-serif; font-size: 16px; font-weight: normal; letter-spacing: 0.15px; margin-bottom: 8px; text-shadow: ${props => !props.isSelected ? '0 3px 10px rgba(0, 0, 0, 0.4)' : '0 3px 20px rgba(0, 0, 0, 1)'}; ` /** * Container for the image and name. */ const ThumbnailContainer = styled.div<IsSelectedStyleProps>` --transition-time: 200ms; --image-border-radius: 10px; padding: 10px; transform: ${props => (props.isSelected ? 'scale(1.03)' : '')}; transition: transform var(--transition-time); user-select: none; ` enum ThumbnailLoadingState { loading, loaded, failed } const BottomProgress = withStyles({ root: { marginTop: 'calc(var(--height) - 6.62px)', height: '6.5px' }, colorPrimary: { backgroundColor: '#567d2e' }, barColorPrimary: { backgroundColor: '#96c267' } })(LinearProgress) const VerticalCenterContainer = styled.div` display: flex; justify-content: center; align-items: center; height: 100%; ` interface ImageSwitcherProps { src: string loadingState: ThumbnailLoadingState downloadingPercentage?: number } // eslint-disable-next-line consistent-return const ImageSwitcher: React.FC<ImageSwitcherProps> = props => { const { src, loadingState, downloadingPercentage } = props // eslint-disable-next-line default-case switch (loadingState) { case ThumbnailLoadingState.loading: return <BottomProgress /> case ThumbnailLoadingState.loaded: return ( <> <Image src={src} /> {downloadingPercentage !== undefined && ( <BottomProgress variant={downloadingPercentage === -1 ? 'indeterminate' : 'determinate'} value={downloadingPercentage} /> )} </> ) case ThumbnailLoadingState.failed: return ( <VerticalCenterContainer> <Grid container direction="column" alignItems="center"> <ErrorIcon color="disabled" fontSize="large" /> <Typography variant="body2" color="textSecondary"> Error downloading </Typography> </Grid> </VerticalCenterContainer> ) } } interface CachedImage { dataUrl: string expiration: Moment isBackup?: boolean timeTaken?: Moment etag?: string } const lock = new AsyncLock() interface ThumbnailProps { id: number src: string name: string description?: string updateInterval: number isSelected: (id: number) => boolean onClick: (id: number) => void onReloadView: () => void } interface ThumbnailState { b64Image?: string isBackup?: boolean timeTaken?: Moment timeDownloaded?: Moment loadingState: ThumbnailLoadingState cancelVisibilityChangeSub?: () => void cancelProgressChangeSub?: () => void downloadPercentage?: number } const thumbnailCache: { [key: number]: CachedImage | undefined } = {} export default class Thumbnail extends React.Component<ThumbnailProps, ThumbnailState> { constructor(props: ThumbnailProps) { super(props) this.state = { loadingState: ThumbnailLoadingState.loading } this.updateUnsafe = this.updateUnsafe.bind(this) this.update = this.update.bind(this) this.updateTimeDownloaded = this.updateTimeDownloaded.bind(this) } async componentDidMount(): Promise<void> { const cancelVisChange = ipc.answerMain<boolean>( VISIBILITY_CHANGE_ALERT_CHANNEL, visible => { if (visible) { this.update() } } ) const cancelProgressChange = ipc.answerMain<number | undefined>( `${VIEW_DOWNLOAD_PROGRESS}_${this.props.id}`, percentage => { // Delay before resetting to give time for background to change if (percentage === undefined) { setTimeout(() => { this.setState({ downloadPercentage: percentage }) }, 400) } else { this.setState({ downloadPercentage: percentage }) } } ) this.setState({ cancelVisibilityChangeSub: cancelVisChange, cancelProgressChangeSub: cancelProgressChange }) await this.update() } async componentWillUnmount(): Promise<void> { if (this.state.cancelVisibilityChangeSub !== undefined) { this.state.cancelVisibilityChangeSub() } if (this.state.cancelProgressChangeSub !== undefined) { this.state.cancelProgressChangeSub() } } /** * Update the time the image was last downloaded from main. */ private async updateTimeDownloaded(): Promise<void> { const timeDownloaded = await ipc.callMain<number, number | undefined>( GET_VIEW_DOWNLOAD_TIME, this.props.id ) if (timeDownloaded !== undefined) { this.setState({ timeDownloaded: moment(timeDownloaded) }) } } /** * Update the thumbnail with no concurrent locking mechanism. */ private async updateUnsafe(): Promise<void> { const cachedImage = thumbnailCache[this.props.id] ?? undefined // If no image in state, try to get from cache if (this.state.b64Image === undefined) { if (cachedImage !== undefined) { // Set the cached image this.setState({ loadingState: ThumbnailLoadingState.loaded, b64Image: cachedImage.dataUrl, isBackup: cachedImage.isBackup, timeTaken: cachedImage.timeTaken }) } else { // Set the loading animation this.setState({ loadingState: ThumbnailLoadingState.loading }) } } // If there's a cached image that's in date, it has already been set; nothing to do if (cachedImage !== undefined && moment.utc().diff(cachedImage.expiration, 'seconds') < 0) { return } // Fetch a new image const response = await ipc.callMain<DownloadThumbnailIpcRequest, DownloadedThumbnailIpc>( DOWNLOAD_THUMBNAIL_CHANNEL, { url: this.props.src, etag: cachedImage?.etag } ) const newExpiration = moment(moment.utc().add(5, 'minutes')) // If not modified, update expiration and exit if (response.isModified === false) { ;(cachedImage as CachedImage).expiration = newExpiration return } // If download failed, report and exit if (response.dataUrl === undefined) { this.setState({ loadingState: ThumbnailLoadingState.failed }) return } // Update cache and state const momentTaken = response.timeTaken !== undefined ? moment(response.timeTaken) : undefined thumbnailCache[this.props.id] = { dataUrl: response.dataUrl, expiration: newExpiration, etag: response.etag, isBackup: response.isBackup, timeTaken: momentTaken } this.setState({ loadingState: ThumbnailLoadingState.loaded, b64Image: response.dataUrl, isBackup: response.isBackup, timeTaken: momentTaken }) } /** * Update the thumbnail with a locking mechanism. */ async update(): Promise<void> { await lock.acquire(this.props.id.toString(), async () => { await this.updateUnsafe() }) } public render(): React.ReactNode { const { id, name, description, isSelected, onClick, onReloadView } = this.props const isSelectedValue = isSelected(id) return ( <ThumbnailContainer isSelected={isSelectedValue}> <ImageContainerBackground isSelected={isSelectedValue}> <ImageContainer isSelected={isSelectedValue} onClick={() => onClick(id)}> <StatusIconAndDialog viewTitle={name} viewDescription={description} updateInterval={this.props.updateInterval} downloaded={this.state.timeDownloaded} imageTaken={this.state.timeTaken} isBackup={this.state.isBackup} failed={this.state.loadingState === ThumbnailLoadingState.failed} onHover={this.updateTimeDownloaded} /> <ReloadButton onClick={() => { if (isSelectedValue) { onReloadView() this.update() } else { onClick(id) } }} /> <ImageSwitcher src={this.state.b64Image ?? ''} loadingState={this.state.loadingState} downloadingPercentage={ isSelectedValue ? this.state.downloadPercentage : undefined } /> </ImageContainer> </ImageContainerBackground> <ThumbnailName isSelected={isSelectedValue}>{name}</ThumbnailName> </ThumbnailContainer> ) } }