complete e2e asset support in browser app

This commit is contained in:
Roland Osborne 2023-05-02 23:46:03 -07:00
parent 5f20b22250
commit d486986ebd
12 changed files with 276 additions and 88 deletions

View File

@ -7,6 +7,7 @@ const Colors = {
formHover: '#efefef',
grey: '#888888',
white: '#ffffff',
black: '#000000',
divider: '#dddddd',
mask: '#dddddd',
encircle: '#cccccc',

View File

@ -217,7 +217,7 @@ async function upload(entry, update, complete) {
entry.active = {};
try {
if (file.encrypted) {
const { size, getEncryptedBlock, position, image, video, audio } = file;
const { size, getEncryptedBlock, position, label, image, video, audio } = file;
const { data, type } = image ? { data: image, type: 'image' } : video ? { data: video, type: 'video' } : audio ? { data: audio, type: 'audio' } : {}
const thumb = await getThumb(data, type, position);
const parts = [];
@ -234,12 +234,10 @@ async function upload(entry, update, complete) {
update();
}
});
console.log("PART?", part.data);
parts.push({ blockIv, partId: part.data.assetId });
}
entry.assets.push({
encrypted: { type, thumb, parts }
encrypted: { type, thumb, label, parts }
});
}
else if (file.image) {

View File

@ -1,37 +1,21 @@
import React, { useEffect, useState, useRef } from 'react';
import { Spin } from 'antd';
import { Modal, Spin } from 'antd';
import ReactResizeDetector from 'react-resize-detector';
import { PlayCircleOutlined, MinusCircleOutlined, SoundOutlined } from '@ant-design/icons';
import { AudioAssetWrapper } from './AudioAsset.styled';
import { AudioAssetWrapper, AudioModalWrapper } from './AudioAsset.styled';
import { useAudioAsset } from './useAudioAsset.hook';
import background from 'images/audio.png';
export function AudioAsset({ label, audioUrl }) {
export function AudioAsset({ asset }) {
const [active, setActive] = useState(false);
const [width, setWidth] = useState(0);
const [ready, setReady] = useState(false);
const [playing, setPlaying] = useState(true);
const [url, setUrl] = useState(null);
const { actions, state } = useAudioAsset(asset);
const audio = useRef(null);
useEffect(() => {
setActive(false);
setReady(false);
setPlaying(true);
setUrl(null);
}, [label, audioUrl]);
const onActivate = () => {
setUrl(audioUrl);
setActive(true);
}
const onReady = () => {
setReady(true);
}
const play = (on) => {
setPlaying(on);
if (on) {
@ -54,32 +38,44 @@ export function AudioAsset({ label, audioUrl }) {
</ReactResizeDetector>
<div class="player" style={{ width: width, height: width }}>
<img class="background" src={background} alt="audio background" />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{ !active && (
<div class="control" onClick={() => onActivate()}>
<SoundOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
{ active && !ready && (
<div class="control">
<Spin />
</div>
)}
{ active && ready && playing && (
<div class="control" onClick={() => play(false)}>
<MinusCircleOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
{ active && ready && !playing && (
<div class="control" onClick={() => play(true)}>
<PlayCircleOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
<audio style={{ position: 'absolute', top: 0, visibility: 'hidden' }} autoplay="true"
src={url} type="audio/mpeg" ref={audio} onPlay={onReady} />
<div class="control" onClick={actions.setActive}>
<SoundOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
</div>
<div class="label">{ asset.label }</div>
</div>
<div class="label">{ label }</div>
<Modal centered={true} visible={state.active} width={256 + 12} bodyStyle={{ width: '100%', height: 'auto', paddingBottom: 6, paddingTop: 6, paddingLeft: 6, paddingRight: 6, backgroundColor: '#dddddd' }} footer={null} destroyOnClose={true} closable={false} onCancel={actions.clearActive}>
<audio style={{ position: 'absolute', top: 0, visibility: 'hidden' }} autoplay="true"
src={state.url} type="audio/mpeg" ref={audio} onPlay={actions.ready} />
<AudioModalWrapper>
<img class="background" src={background} alt="audio background" />
{ state.loading && state.error && (
<div class="failed">
<Spin size="large" delay={250} />
</div>
)}
{ state.loading && !state.error && (
<div class="loading">
<Spin size="large" delay={250} />
</div>
)}
{ !state.ready && !state.loading && (
<div class="loading">
<Spin size="large" delay={250} />
</div>
)}
{ state.ready && !state.loading && playing && (
<div class="control" onClick={() => play(false)}>
<MinusCircleOutlined style={{ fontSize: 64, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
{ state.ready && !state.loading && !playing && (
<div class="control" onClick={() => play(true)}>
<PlayCircleOutlined style={{ fontSize: 64, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
<div class="label">{ asset.label }</div>
</AudioModalWrapper>
</Modal>
</AudioAssetWrapper>
)
}

View File

@ -1,4 +1,5 @@
import styled from 'styled-components';
import Colors from 'constants/Colors';
export const AudioAssetWrapper = styled.div`
position: relative;
@ -41,3 +42,48 @@ export const AudioAssetWrapper = styled.div`
`;
export const AudioModalWrapper = styled.div`
width: 256px;
height: 256px;
display: flex;
align-items: center;
justify-content: center;
background-color: #aaaaaa;
.background {
width: 256px;
height: 256px;
}
.control {
position: absolute;
}
.label {
padding-top: 8px;
position: absolute;
top: 0;
font-size: 20px;
}
.failed {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.alert};
}
}
.loading {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.white};
}
}
`;

View File

@ -0,0 +1,55 @@
import { useState, useRef } from 'react';
export function useAudioAsset(asset) {
const revoke = useRef();
const index = useRef(0);
const [state, setState] = useState({
active: false,
loading: false,
error: false,
ready: false,
url: null,
});
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
}
const actions = {
setActive: async () => {
if (asset.encrypted) {
try {
const view = index.current;
updateState({ active: true, ready: false, error: false, loading: true, url: null });
const blob = await asset.getDecryptedBlob(() => view != index.current);
const url = URL.createObjectURL(blob);
revoke.current = url;
updateState({ loading: false, url });
}
catch (err) {
console.log(err);
updateState({ error: true });
}
}
else {
updateState({ active: true, loading: false, url: asset.full });
}
},
clearActive: () => {
index.current += 1;
updateState({ active: false, url: null });
if (revoke.current) {
URL.revokeObjectURL(revoke.current);
revoke.current = null;
}
},
ready: () => {
updateState({ ready: true });
}
};
return { state, actions };
}

View File

@ -37,17 +37,22 @@ export function ImageAsset({ asset }) {
onClick={popout} />
<Modal centered={true} visible={state.popout} width={state.width + 12} bodyStyle={{ width: '100%', height: 'auto', paddingBottom: 6, paddingTop: 6, paddingLeft: 6, paddingRight: 6, backgroundColor: '#dddddd' }} footer={null} destroyOnClose={true} closable={false} onCancel={actions.clearPopout}>
<ImageModalWrapper onClick={actions.clearPopout}>
{ state.loading && (
<div class="frame">
<img style={{ width: '100%', objectFit: 'contain' }} src={asset.thumb} alt="topic asset" />
<div class="spinner">
<Spin color={'white'} size="large" delay={250} />
<div class="frame">
<img class="thumb" src={asset.thumb} alt="topic asset" />
{ !state.error && (
<div class="loading">
<Spin size="large" delay={250} />
</div>
</div>
)}
{ !state.loading && (
<img style={{ width: '100%', objectFit: 'contain' }} src={state.url} alt="topic asset" />
)}
)}
{ state.error && (
<div class="failed">
<Spin size="large" delay={250} />
</div>
)}
{ !state.loading && (
<img class="full" src={state.url} alt="topic asset" />
)}
</div>
</ImageModalWrapper>
</Modal>
</div>

View File

@ -1,4 +1,5 @@
import styled from 'styled-components';
import Colors from 'constants/Colors';
export const ImageAssetWrapper = styled.div`
position: relative;
@ -45,16 +46,39 @@ export const ImageModalWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
background-color: ${Colors.black};
position: relative;
}
.thumb {
opacity: 0.5;
width: 100%;
object-fit: contain;
}
.ant-spin-dot-item {
background-color: white;
.full {
width: 100%;
object-fit: contain;
position: absolute;
}
.spinner {
.failed {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.alert};
}
}
.loading {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.white};
}
}
`;

View File

@ -1,7 +1,10 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
export function useImageAsset(asset) {
const revoke = useRef();
const index = useRef(0);
const [state, setState] = useState({
popout: false,
width: 0,
@ -18,17 +21,30 @@ export function useImageAsset(asset) {
const actions = {
setPopout: async (width, height) => {
if (asset.encrypted) {
updateState({ popout: true, width, height, loading: true, url: null });
const blob = await asset.getDecryptedBlob();
const url = URL.createObjectURL(blob);
updateState({ loading: false, url });
try {
const view = index.current;
updateState({ popout: true, width, height, error: false, loading: true, url: null });
const blob = await asset.getDecryptedBlob(() => view != index.current);
const url = URL.createObjectURL(blob);
updateState({ loading: false, url });
revoke.current = url;
}
catch(err) {
console.log(err);
updateState({ error: true });
}
}
else {
updateState({ popout: true, width, height, loading: false, url: asset.full });
}
},
clearPopout: () => {
index.current += 1;
updateState({ popout: false });
if (revoke.current) {
URL.revokeObjectURL(revoke.current);
revoke.current = null;
}
},
};

View File

@ -10,9 +10,6 @@ export function useTopicItem(topic, contentKey) {
assets: [],
});
console.log(topic);
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
}
@ -32,13 +29,16 @@ export function useTopicItem(topic, contentKey) {
topic.assets.forEach(asset => {
if (asset.encrypted) {
const encrypted = true;
const { type, thumb, parts } = asset.encrypted;
const getDecryptedBlob = async () => {
const { type, thumb, label, parts } = asset.encrypted;
const getDecryptedBlob = async (abort) => {
let pos = 0;
let len = 0;
const slices = []
for (let i = 0; i < parts.length; i++) {
if (abort()) {
throw new Error("asset unseal aborted");
}
const part = parts[i];
const url = topic.assetUrl(part.partId, topic.id);
const response = await fetchWithTimeout(url, { method: 'GET' });
@ -57,7 +57,7 @@ export function useTopicItem(topic, contentKey) {
}
return new Blob([data]);
}
assets.push({ type, thumb, encrypted, getDecryptedBlob });
assets.push({ type, thumb, label, encrypted, getDecryptedBlob });
}
else {
const encrypted = false

View File

@ -40,10 +40,19 @@ export function VideoAsset({ asset }) {
<Modal centered={true} style={{ backgroundColor: '#aacc00', padding: 0 }} visible={state.active} width={state.width + 12} bodyStyle={{ paddingBottom: 0, paddingTop: 6, paddingLeft: 6, paddingRight: 6, backgroundColor: '#dddddd', margin: 0 }} footer={null} destroyOnClose={true} closable={false} onCancel={actions.clearActive}>
<VideoModalWrapper>
{ state.loading && (
<div class="frame">
<img style={{ width: '100%', objectFit: 'contain' }} src={asset.thumb} alt="topic asset" />
<div class="spinner">
<Spin color={'white'} size="large" delay={250} />
<div class="wrapper">
<div class="frame">
<img class="thumb" src={asset.thumb} alt="topic asset" />
{ state.error && (
<div class="failed">
<Spin size="large" delay={250} />
</div>
)}
{ !state.error && (
<div class="loading">
<Spin size="large" delay={250} />
</div>
)}
</div>
</div>
)}

View File

@ -1,4 +1,5 @@
import styled from 'styled-components';
import Colors from 'constants/Colors';
export const VideoAssetWrapper = styled.div`
position: relative;
@ -15,20 +16,41 @@ export const VideoAssetWrapper = styled.div`
`;
export const VideoModalWrapper = styled.div`
.wrapper {
padding-bottom: 6px;
}
.frame {
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
background-color: black;
}
.ant-spin-dot-item {
background-color: white;
.thumb {
opacity: 0.3;
width: 100%;
object-fit: contain;
}
.spinner {
.failed {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.alert};
}
}
.loading {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.white};
}
}
`;

View File

@ -1,7 +1,10 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
export function useVideoAsset(asset) {
const revoke = useRef();
const index = useRef(0);
const [state, setState] = useState({
width: 0,
height: 0,
@ -19,17 +22,30 @@ export function useVideoAsset(asset) {
const actions = {
setActive: async (width, height) => {
if (asset.encrypted) {
updateState({ active: true, width, height, loading: true, url: null });
const blob = await asset.getDecryptedBlob();
const url = URL.createObjectURL(blob);
updateState({ loading: false, url });
try {
const view = index.current;
updateState({ active: true, width, height, error: false, loading: true, url: null });
const blob = await asset.getDecryptedBlob(() => view != index.current);
const url = URL.createObjectURL(blob);
revoke.current = url;
updateState({ loading: false, url });
}
catch (err) {
console.log(err);
updateState({ error: true });
}
}
else {
updateState({ popout: true, width, height, loading: false, url: asset.hd });
updateState({ active: true, width, height, loading: false, url: asset.hd });
}
},
clearActive: () => {
index.current += 1;
updateState({ active: false });
if (revoke.current) {
URL.revokeObjectURL(revoke.current);
revoke.current = null;
}
},
setDimension: (dimension) => {
updateState({ dimension });