ente Cast (#1460)
This commit is contained in:
commit
08d5f318a9
94 changed files with 5658 additions and 11 deletions
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
@ -13,5 +13,8 @@
|
|||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
||||
|
|
13
apps/cast/.eslintrc.js
Normal file
13
apps/cast/.eslintrc.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
module.exports = {
|
||||
// When root is set to true, ESLint will stop looking for configuration files in parent directories.
|
||||
// This is required here to ensure desktop picks the right eslint config, where this app is
|
||||
// packaged as a submodule.
|
||||
root: true,
|
||||
extends: ['@ente/eslint-config'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
};
|
36
apps/cast/.gitignore
vendored
Normal file
36
apps/cast/.gitignore
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
40
apps/cast/README.md
Normal file
40
apps/cast/README.md
Normal file
|
@ -0,0 +1,40 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
53
apps/cast/configUtil.js
Normal file
53
apps/cast/configUtil.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
const cp = require('child_process');
|
||||
const { getIsSentryEnabled } = require('./sentryConfigUtil');
|
||||
|
||||
module.exports = {
|
||||
WEB_SECURITY_HEADERS: {
|
||||
'Strict-Transport-Security': ' max-age=63072000',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Download-Options': 'noopen',
|
||||
'X-Frame-Options': 'deny',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Referrer-Policy': 'same-origin',
|
||||
},
|
||||
|
||||
CSP_DIRECTIVES: {
|
||||
// self is safe enough
|
||||
'default-src': "'self'",
|
||||
// data to allow two factor qr code
|
||||
'img-src': "'self' blob: data: https://*.openstreetmap.org",
|
||||
'media-src': "'self' blob:",
|
||||
'manifest-src': "'self'",
|
||||
'style-src': "'self' 'unsafe-inline'",
|
||||
'font-src ': "'self'; script-src 'self' 'unsafe-eval' blob:",
|
||||
'connect-src':
|
||||
"'self' https://*.ente.io http://localhost:8080 data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com https://ente-prod-v3.s3.eu-central-2.wasabisys.com/ https://ente-staging-eu.s3.eu-central-003.backblazeb2.com/ ws://localhost:3000/",
|
||||
'base-uri ': "'self'",
|
||||
// to allow worker
|
||||
'child-src': "'self' blob:",
|
||||
'object-src': "'none'",
|
||||
'frame-ancestors': " 'none'",
|
||||
'form-action': "'none'",
|
||||
'report-uri': ' https://csp-reporter.ente.io/local',
|
||||
'report-to': ' https://csp-reporter.ente.io/local',
|
||||
'script-src-elem': "'self' https://www.gstatic.com",
|
||||
},
|
||||
|
||||
ALL_ROUTES: '/(.*)',
|
||||
|
||||
buildCSPHeader: (directives) => ({
|
||||
'Content-Security-Policy-Report-Only': Object.entries(
|
||||
directives
|
||||
).reduce((acc, [key, value]) => acc + `${key} ${value};`, ''),
|
||||
}),
|
||||
|
||||
convertToNextHeaderFormat: (headers) =>
|
||||
Object.entries(headers).map(([key, value]) => ({ key, value })),
|
||||
|
||||
getGitSha: () =>
|
||||
cp.execSync('git rev-parse --short HEAD', {
|
||||
cwd: __dirname,
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
getIsSentryEnabled: getIsSentryEnabled,
|
||||
};
|
3
apps/cast/next.config.js
Normal file
3
apps/cast/next.config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
const nextConfigBase = require('@ente/shared/next/next.config.base.js');
|
||||
|
||||
module.exports = nextConfigBase;
|
19
apps/cast/package.json
Normal file
19
apps/cast/package.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "cast",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"export": "next export"
|
||||
},
|
||||
"dependencies": {
|
||||
"mime-types": "^2.1.35",
|
||||
"jszip": "3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass": "^1.69.5"
|
||||
}
|
||||
}
|
BIN
apps/cast/public/favicon.ico
Normal file
BIN
apps/cast/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
3
apps/cast/public/images/ente.svg
Normal file
3
apps/cast/public/images/ente.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="43" height="13" viewBox="0 0 43 13" fill="#fff" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.102 12.144C4.998 12.144 4.026 11.928 3.186 11.496C2.358 11.064 1.716 10.476 1.26 9.732C0.804 8.976 0.576 8.118 0.576 7.158C0.576 6.186 0.798 5.328 1.242 4.584C1.698 3.828 2.316 3.24 3.096 2.82C3.876 2.388 4.758 2.172 5.742 2.172C6.69 2.172 7.542 2.376 8.298 2.784C9.066 3.18 9.672 3.756 10.116 4.512C10.56 5.256 10.782 6.15 10.782 7.194C10.782 7.302 10.776 7.428 10.764 7.572C10.752 7.704 10.74 7.83 10.728 7.95H2.862V6.312H9.252L8.172 6.798C8.172 6.294 8.07 5.856 7.866 5.484C7.662 5.112 7.38 4.824 7.02 4.62C6.66 4.404 6.24 4.296 5.76 4.296C5.28 4.296 4.854 4.404 4.482 4.62C4.122 4.824 3.84 5.118 3.636 5.502C3.432 5.874 3.33 6.318 3.33 6.834V7.266C3.33 7.794 3.444 8.262 3.672 8.67C3.912 9.066 4.242 9.372 4.662 9.588C5.094 9.792 5.598 9.894 6.174 9.894C6.69 9.894 7.14 9.816 7.524 9.66C7.92 9.504 8.28 9.27 8.604 8.958L10.098 10.578C9.654 11.082 9.096 11.472 8.424 11.748C7.752 12.012 6.978 12.144 6.102 12.144ZM18.5375 2.172C19.3055 2.172 19.9895 2.328 20.5895 2.64C21.2015 2.94 21.6815 3.408 22.0295 4.044C22.3775 4.668 22.5515 5.472 22.5515 6.456V12H19.7435V6.888C19.7435 6.108 19.5695 5.532 19.2215 5.16C18.8855 4.788 18.4055 4.602 17.7815 4.602C17.3375 4.602 16.9355 4.698 16.5755 4.89C16.2275 5.07 15.9515 5.352 15.7475 5.736C15.5555 6.12 15.4595 6.612 15.4595 7.212V12H12.6515V2.316H15.3335V4.998L14.8295 4.188C15.1775 3.54 15.6755 3.042 16.3235 2.694C16.9715 2.346 17.7095 2.172 18.5375 2.172ZM29.0568 12.144C27.9168 12.144 27.0288 11.856 26.3928 11.28C25.7568 10.692 25.4388 9.822 25.4388 8.67V0.174H28.2468V8.634C28.2468 9.042 28.3548 9.36 28.5708 9.588C28.7868 9.804 29.0808 9.912 29.4528 9.912C29.8968 9.912 30.2748 9.792 30.5868 9.552L31.3428 11.532C31.0548 11.736 30.7068 11.892 30.2988 12C29.9028 12.096 29.4888 12.144 29.0568 12.144ZM23.9448 4.692V2.532H30.6588V4.692H23.9448ZM37.4262 12.144C36.3222 12.144 35.3502 11.928 34.5102 11.496C33.6822 11.064 33.0402 10.476 32.5842 9.732C32.1282 8.976 31.9002 8.118 31.9002 7.158C31.9002 6.186 32.1222 5.328 32.5662 4.584C33.0222 3.828 33.6402 3.24 34.4202 2.82C35.2002 2.388 36.0822 2.172 37.0662 2.172C38.0142 2.172 38.8662 2.376 39.6222 2.784C40.3902 3.18 40.9962 3.756 41.4402 4.512C41.8842 5.256 42.1062 6.15 42.1062 7.194C42.1062 7.302 42.1002 7.428 42.0882 7.572C42.0762 7.704 42.0642 7.83 42.0522 7.95H34.1862V6.312H40.5762L39.4962 6.798C39.4962 6.294 39.3942 5.856 39.1902 5.484C38.9862 5.112 38.7042 4.824 38.3442 4.62C37.9842 4.404 37.5642 4.296 37.0842 4.296C36.6042 4.296 36.1782 4.404 35.8062 4.62C35.4462 4.824 35.1642 5.118 34.9602 5.502C34.7562 5.874 34.6542 6.318 34.6542 6.834V7.266C34.6542 7.794 34.7682 8.262 34.9962 8.67C35.2362 9.066 35.5662 9.372 35.9862 9.588C36.4182 9.792 36.9222 9.894 37.4982 9.894C38.0142 9.894 38.4642 9.816 38.8482 9.66C39.2442 9.504 39.6042 9.27 39.9282 8.958L41.4222 10.578C40.9782 11.082 40.4202 11.472 39.7482 11.748C39.0762 12.012 38.3022 12.144 37.4262 12.144Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 2.9 KiB |
BIN
apps/cast/public/images/help-qrcode.webp
Normal file
BIN
apps/cast/public/images/help-qrcode.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 164 B |
2
apps/cast/sentry.client.config.js
Normal file
2
apps/cast/sentry.client.config.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
import { setupSentry } from '@ente/shared/sentry/config/sentry.config.base';
|
||||
setupSentry('https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2');
|
3
apps/cast/sentry.properties
Normal file
3
apps/cast/sentry.properties
Normal file
|
@ -0,0 +1,3 @@
|
|||
defaults.url=https://sentry.ente.io/
|
||||
defaults.org=ente
|
||||
defaults.project=photos-web
|
0
apps/cast/sentry.server.config.js
Normal file
0
apps/cast/sentry.server.config.js
Normal file
11
apps/cast/sentryConfigUtil.js
Normal file
11
apps/cast/sentryConfigUtil.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
const ENV_DEVELOPMENT = 'development';
|
||||
|
||||
module.exports.getIsSentryEnabled = () => {
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_ENV === ENV_DEVELOPMENT) {
|
||||
return false;
|
||||
} else if (process.env.NEXT_PUBLIC_DISABLE_SENTRY === 'true') {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
.circle {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
&.animate {
|
||||
animation: scaleIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
|
||||
&__circle {
|
||||
fill: green;
|
||||
}
|
||||
|
||||
&__check {
|
||||
transform-origin: 50% 50%;
|
||||
stroke-dasharray: 48;
|
||||
stroke-dashoffset: 48;
|
||||
animation: strokeCheck 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
|
||||
stroke: white;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes strokeCheck {
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
34
apps/cast/src/components/FilledCircleCheck/index.tsx
Normal file
34
apps/cast/src/components/FilledCircleCheck/index.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import styles from './FilledCircleCheck.module.scss'; // Import our CSS module
|
||||
|
||||
const FilledCircleCheck = () => {
|
||||
const [animate, setAnimate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAnimate(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${styles.circle} ${animate ? styles.animate : ''}`}>
|
||||
<svg
|
||||
className={styles.checkmark}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 52 52">
|
||||
<circle
|
||||
className={styles.checkmark__circle}
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="25"
|
||||
fill="green"
|
||||
/>
|
||||
<path
|
||||
className={styles.checkmark__check}
|
||||
fill="none"
|
||||
d="M14.1 27.2l7.1 7.2 16.7-16.8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilledCircleCheck;
|
62
apps/cast/src/components/LargeType.tsx
Normal file
62
apps/cast/src/components/LargeType.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
const colourPool = [
|
||||
'#87CEFA', // Light Blue
|
||||
'#90EE90', // Light Green
|
||||
'#F08080', // Light Coral
|
||||
'#FFFFE0', // Light Yellow
|
||||
'#FFB6C1', // Light Pink
|
||||
'#E0FFFF', // Light Cyan
|
||||
'#FAFAD2', // Light Goldenrod
|
||||
'#87CEFA', // Light Sky Blue
|
||||
'#D3D3D3', // Light Gray
|
||||
'#B0C4DE', // Light Steel Blue
|
||||
'#FFA07A', // Light Salmon
|
||||
'#20B2AA', // Light Sea Green
|
||||
'#778899', // Light Slate Gray
|
||||
'#AFEEEE', // Light Turquoise
|
||||
'#7A58C1', // Light Violet
|
||||
'#FFA500', // Light Orange
|
||||
'#A0522D', // Light Brown
|
||||
'#9370DB', // Light Purple
|
||||
'#008080', // Light Teal
|
||||
'#808000', // Light Olive
|
||||
];
|
||||
|
||||
export default function LargeType({ chars }: { chars: string[] }) {
|
||||
return (
|
||||
<table
|
||||
style={{
|
||||
fontSize: '4rem',
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'monospace',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{chars.map((char, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '0.5rem',
|
||||
// alternating background
|
||||
backgroundColor: i % 2 === 0 ? '#2e2e2e' : '#5e5e5e',
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
color: colourPool[i % colourPool.length],
|
||||
lineHeight: 1.2,
|
||||
}}>
|
||||
{char}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '1rem',
|
||||
}}>
|
||||
{i + 1}
|
||||
</span>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
42
apps/cast/src/components/PairedSuccessfullyOverlay.tsx
Normal file
42
apps/cast/src/components/PairedSuccessfullyOverlay.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import FilledCircleCheck from './FilledCircleCheck';
|
||||
|
||||
export default function PairedSuccessfullyOverlay() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
backgroundColor: 'black',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<FilledCircleCheck />
|
||||
<h2
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
}}>
|
||||
Pairing Complete
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
lineHeight: '1.5rem',
|
||||
}}>
|
||||
We're preparing your album.
|
||||
<br /> This should only take a few seconds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
93
apps/cast/src/components/Theatre/PhotoAuditorium.tsx
Normal file
93
apps/cast/src/components/Theatre/PhotoAuditorium.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { SlideshowContext } from 'pages/slideshow';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
export default function PhotoAuditorium({
|
||||
url,
|
||||
nextSlideUrl,
|
||||
}: {
|
||||
url: string;
|
||||
nextSlideUrl: string;
|
||||
}) {
|
||||
const { showNextSlide } = useContext(SlideshowContext);
|
||||
|
||||
const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false);
|
||||
const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false);
|
||||
const [prerenderTime, setPrerenderTime] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
let timeout2: NodeJS.Timeout;
|
||||
|
||||
if (nextSlidePrerendered) {
|
||||
const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0;
|
||||
const delayTime = Math.max(5000 - elapsedTime, 0);
|
||||
|
||||
if (elapsedTime >= 5000) {
|
||||
setShowPreloadedNextSlide(true);
|
||||
} else {
|
||||
timeout = setTimeout(() => {
|
||||
setShowPreloadedNextSlide(true);
|
||||
}, delayTime);
|
||||
}
|
||||
|
||||
if (showNextSlide) {
|
||||
timeout2 = setTimeout(() => {
|
||||
showNextSlide();
|
||||
setNextSlidePrerendered(false);
|
||||
setPrerenderTime(null);
|
||||
setShowPreloadedNextSlide(false);
|
||||
}, delayTime);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (timeout2) clearTimeout(timeout2);
|
||||
};
|
||||
}, [nextSlidePrerendered, showNextSlide, prerenderTime]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundBlendMode: 'multiply',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}>
|
||||
<img
|
||||
src={url}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
display: showPreloadedNextSlide ? 'none' : 'block',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={nextSlideUrl}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
display: showPreloadedNextSlide ? 'block' : 'none',
|
||||
}}
|
||||
onLoad={() => {
|
||||
setNextSlidePrerendered(true);
|
||||
setPrerenderTime(Date.now());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
53
apps/cast/src/components/Theatre/VideoAuditorium.tsx
Normal file
53
apps/cast/src/components/Theatre/VideoAuditorium.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import mime from 'mime-types';
|
||||
import { SlideshowContext } from 'pages/slideshow';
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
|
||||
export default function VideoAuditorium({
|
||||
name,
|
||||
url,
|
||||
}: {
|
||||
name: string;
|
||||
url: string;
|
||||
}) {
|
||||
const { showNextSlide } = useContext(SlideshowContext);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
attemptPlay();
|
||||
}, [url, videoRef]);
|
||||
|
||||
const attemptPlay = async () => {
|
||||
if (videoRef.current) {
|
||||
try {
|
||||
await videoRef.current.play();
|
||||
} catch {
|
||||
showNextSlide();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
controls
|
||||
style={{
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
}}
|
||||
onError={showNextSlide}
|
||||
onEnded={showNextSlide}>
|
||||
<source src={url} type={mime.lookup(name)} />
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
30
apps/cast/src/components/Theatre/index.tsx
Normal file
30
apps/cast/src/components/Theatre/index.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { FILE_TYPE } from 'constants/file';
|
||||
import PhotoAuditorium from './PhotoAuditorium';
|
||||
// import VideoAuditorium from './VideoAuditorium';
|
||||
|
||||
interface fileProp {
|
||||
fileName: string;
|
||||
fileURL: string;
|
||||
type: FILE_TYPE;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
file1: fileProp;
|
||||
file2: fileProp;
|
||||
}
|
||||
|
||||
export default function Theatre(props: IProps) {
|
||||
switch (props.file1.type && props.file2.type) {
|
||||
case FILE_TYPE.IMAGE:
|
||||
return (
|
||||
<PhotoAuditorium
|
||||
url={props.file1.fileURL}
|
||||
nextSlideUrl={props.file2.fileURL}
|
||||
/>
|
||||
);
|
||||
// case FILE_TYPE.VIDEO:
|
||||
// return (
|
||||
// <VideoAuditorium name={props.fileName} url={props.fileURL} />
|
||||
// );
|
||||
}
|
||||
}
|
30
apps/cast/src/components/TimerBar.tsx
Normal file
30
apps/cast/src/components/TimerBar.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function TimerBar({ percentage }: { percentage: number }) {
|
||||
const okColor = '#75C157';
|
||||
const warningColor = '#FFC000';
|
||||
const lateColor = '#FF0000';
|
||||
|
||||
const [backgroundColor, setBackgroundColor] = useState(okColor);
|
||||
|
||||
useEffect(() => {
|
||||
if (percentage >= 40) {
|
||||
setBackgroundColor(okColor);
|
||||
} else if (percentage >= 20) {
|
||||
setBackgroundColor(warningColor);
|
||||
} else {
|
||||
setBackgroundColor(lateColor);
|
||||
}
|
||||
}, [percentage]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${percentage}%`, // Set the width based on the time left
|
||||
height: '10px', // Same as the border thickness
|
||||
backgroundColor, // The color of the moving border
|
||||
transition: 'width 1s linear', // Smooth transition for the width change
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
1
apps/cast/src/constants/api.ts
Normal file
1
apps/cast/src/constants/api.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const REQUEST_BATCH_SIZE = 1000;
|
56
apps/cast/src/constants/apps.ts
Normal file
56
apps/cast/src/constants/apps.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { getAlbumsURL } from '@ente/shared/network/api';
|
||||
import { runningInBrowser } from '@ente/shared/platform';
|
||||
import { PAGES } from 'constants/pages';
|
||||
|
||||
export enum APPS {
|
||||
PHOTOS = 'PHOTOS',
|
||||
AUTH = 'AUTH',
|
||||
ALBUMS = 'ALBUMS',
|
||||
}
|
||||
|
||||
export const ALLOWED_APP_PAGES = new Map([
|
||||
[APPS.ALBUMS, [PAGES.SHARED_ALBUMS, PAGES.ROOT]],
|
||||
[
|
||||
APPS.AUTH,
|
||||
[
|
||||
PAGES.ROOT,
|
||||
PAGES.LOGIN,
|
||||
PAGES.SIGNUP,
|
||||
PAGES.VERIFY,
|
||||
PAGES.CREDENTIALS,
|
||||
PAGES.RECOVER,
|
||||
PAGES.CHANGE_PASSWORD,
|
||||
PAGES.GENERATE,
|
||||
PAGES.AUTH,
|
||||
PAGES.TWO_FACTOR_VERIFY,
|
||||
PAGES.TWO_FACTOR_RECOVER,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
export const CLIENT_PACKAGE_NAMES = new Map([
|
||||
[APPS.ALBUMS, 'io.ente.albums.web'],
|
||||
[APPS.PHOTOS, 'io.ente.photos.web'],
|
||||
[APPS.AUTH, 'io.ente.auth.web'],
|
||||
]);
|
||||
|
||||
export const getAppNameAndTitle = () => {
|
||||
if (!runningInBrowser()) {
|
||||
return {};
|
||||
}
|
||||
const currentURL = new URL(window.location.href);
|
||||
const albumsURL = new URL(getAlbumsURL());
|
||||
if (currentURL.origin === albumsURL.origin) {
|
||||
return { name: APPS.ALBUMS, title: 'ente Photos' };
|
||||
} else {
|
||||
return { name: APPS.PHOTOS, title: 'ente Photos' };
|
||||
}
|
||||
};
|
||||
|
||||
export const getAppTitle = () => {
|
||||
return getAppNameAndTitle().title;
|
||||
};
|
||||
|
||||
export const getAppName = () => {
|
||||
return getAppNameAndTitle().name;
|
||||
};
|
5
apps/cast/src/constants/cache.ts
Normal file
5
apps/cast/src/constants/cache.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export enum CACHES {
|
||||
THUMBS = 'thumbs',
|
||||
FACE_CROPS = 'face-crops',
|
||||
FILES = 'files',
|
||||
}
|
100
apps/cast/src/constants/collection.ts
Normal file
100
apps/cast/src/constants/collection.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
export const ARCHIVE_SECTION = -1;
|
||||
export const TRASH_SECTION = -2;
|
||||
export const DUMMY_UNCATEGORIZED_COLLECTION = -3;
|
||||
export const HIDDEN_ITEMS_SECTION = -4;
|
||||
export const ALL_SECTION = 0;
|
||||
export const DEFAULT_HIDDEN_COLLECTION_USER_FACING_NAME = 'Hidden';
|
||||
|
||||
export enum CollectionType {
|
||||
folder = 'folder',
|
||||
favorites = 'favorites',
|
||||
album = 'album',
|
||||
uncategorized = 'uncategorized',
|
||||
}
|
||||
|
||||
export enum CollectionSummaryType {
|
||||
folder = 'folder',
|
||||
favorites = 'favorites',
|
||||
album = 'album',
|
||||
archive = 'archive',
|
||||
trash = 'trash',
|
||||
uncategorized = 'uncategorized',
|
||||
all = 'all',
|
||||
outgoingShare = 'outgoingShare',
|
||||
incomingShareViewer = 'incomingShareViewer',
|
||||
incomingShareCollaborator = 'incomingShareCollaborator',
|
||||
sharedOnlyViaLink = 'sharedOnlyViaLink',
|
||||
archived = 'archived',
|
||||
defaultHidden = 'defaultHidden',
|
||||
hiddenItems = 'hiddenItems',
|
||||
pinned = 'pinned',
|
||||
}
|
||||
export enum COLLECTION_LIST_SORT_BY {
|
||||
NAME,
|
||||
CREATION_TIME_ASCENDING,
|
||||
UPDATION_TIME_DESCENDING,
|
||||
}
|
||||
|
||||
export const COLLECTION_SHARE_DEFAULT_VALID_DURATION =
|
||||
10 * 24 * 60 * 60 * 1000 * 1000;
|
||||
export const COLLECTION_SHARE_DEFAULT_DEVICE_LIMIT = 4;
|
||||
|
||||
export const COLLECTION_SORT_ORDER = new Map([
|
||||
[CollectionSummaryType.all, 0],
|
||||
[CollectionSummaryType.hiddenItems, 0],
|
||||
[CollectionSummaryType.uncategorized, 1],
|
||||
[CollectionSummaryType.favorites, 2],
|
||||
[CollectionSummaryType.pinned, 3],
|
||||
[CollectionSummaryType.album, 4],
|
||||
[CollectionSummaryType.folder, 4],
|
||||
[CollectionSummaryType.incomingShareViewer, 4],
|
||||
[CollectionSummaryType.incomingShareCollaborator, 4],
|
||||
[CollectionSummaryType.outgoingShare, 4],
|
||||
[CollectionSummaryType.sharedOnlyViaLink, 4],
|
||||
[CollectionSummaryType.archived, 4],
|
||||
[CollectionSummaryType.archive, 5],
|
||||
[CollectionSummaryType.trash, 6],
|
||||
[CollectionSummaryType.defaultHidden, 7],
|
||||
]);
|
||||
|
||||
export const SYSTEM_COLLECTION_TYPES = new Set([
|
||||
CollectionSummaryType.all,
|
||||
CollectionSummaryType.archive,
|
||||
CollectionSummaryType.trash,
|
||||
CollectionSummaryType.uncategorized,
|
||||
CollectionSummaryType.hiddenItems,
|
||||
CollectionSummaryType.defaultHidden,
|
||||
]);
|
||||
|
||||
export const ADD_TO_NOT_ALLOWED_COLLECTION = new Set([
|
||||
CollectionSummaryType.all,
|
||||
CollectionSummaryType.archive,
|
||||
CollectionSummaryType.incomingShareViewer,
|
||||
CollectionSummaryType.trash,
|
||||
CollectionSummaryType.uncategorized,
|
||||
CollectionSummaryType.defaultHidden,
|
||||
CollectionSummaryType.hiddenItems,
|
||||
]);
|
||||
|
||||
export const MOVE_TO_NOT_ALLOWED_COLLECTION = new Set([
|
||||
CollectionSummaryType.all,
|
||||
CollectionSummaryType.archive,
|
||||
CollectionSummaryType.incomingShareViewer,
|
||||
CollectionSummaryType.incomingShareCollaborator,
|
||||
CollectionSummaryType.trash,
|
||||
CollectionSummaryType.uncategorized,
|
||||
CollectionSummaryType.defaultHidden,
|
||||
CollectionSummaryType.hiddenItems,
|
||||
]);
|
||||
|
||||
export const OPTIONS_NOT_HAVING_COLLECTION_TYPES = new Set([
|
||||
CollectionSummaryType.all,
|
||||
CollectionSummaryType.archive,
|
||||
]);
|
||||
|
||||
export const HIDE_FROM_COLLECTION_BAR_TYPES = new Set([
|
||||
CollectionSummaryType.trash,
|
||||
CollectionSummaryType.archive,
|
||||
CollectionSummaryType.uncategorized,
|
||||
CollectionSummaryType.defaultHidden,
|
||||
]);
|
3
apps/cast/src/constants/ffmpeg.ts
Normal file
3
apps/cast/src/constants/ffmpeg.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const INPUT_PATH_PLACEHOLDER = 'INPUT';
|
||||
export const FFMPEG_PLACEHOLDER = 'FFMPEG';
|
||||
export const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
|
43
apps/cast/src/constants/file.ts
Normal file
43
apps/cast/src/constants/file.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
export const MIN_EDITED_CREATION_TIME = new Date(1800, 0, 1);
|
||||
export const MAX_EDITED_CREATION_TIME = new Date();
|
||||
|
||||
export const MAX_EDITED_FILE_NAME_LENGTH = 100;
|
||||
export const MAX_CAPTION_SIZE = 5000;
|
||||
|
||||
export const TYPE_HEIC = 'heic';
|
||||
export const TYPE_HEIF = 'heif';
|
||||
export const TYPE_JPEG = 'jpeg';
|
||||
export const TYPE_JPG = 'jpg';
|
||||
|
||||
export enum FILE_TYPE {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
LIVE_PHOTO,
|
||||
OTHERS,
|
||||
}
|
||||
|
||||
export const RAW_FORMATS = [
|
||||
'heic',
|
||||
'rw2',
|
||||
'tiff',
|
||||
'arw',
|
||||
'cr3',
|
||||
'cr2',
|
||||
'raf',
|
||||
'nef',
|
||||
'psd',
|
||||
'dng',
|
||||
'tif',
|
||||
];
|
||||
export const SUPPORTED_RAW_FORMATS = [
|
||||
'heic',
|
||||
'rw2',
|
||||
'tiff',
|
||||
'arw',
|
||||
'cr3',
|
||||
'cr2',
|
||||
'nef',
|
||||
'psd',
|
||||
'dng',
|
||||
'tif',
|
||||
];
|
15
apps/cast/src/constants/gallery.ts
Normal file
15
apps/cast/src/constants/gallery.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export const GAP_BTW_TILES = 4;
|
||||
export const DATE_CONTAINER_HEIGHT = 48;
|
||||
export const SIZE_AND_COUNT_CONTAINER_HEIGHT = 72;
|
||||
export const IMAGE_CONTAINER_MAX_HEIGHT = 180;
|
||||
export const IMAGE_CONTAINER_MAX_WIDTH = 180;
|
||||
export const MIN_COLUMNS = 4;
|
||||
export const SPACE_BTW_DATES = 44;
|
||||
export const SPACE_BTW_DATES_TO_IMAGE_CONTAINER_WIDTH_RATIO = 0.244;
|
||||
|
||||
export enum PLAN_PERIOD {
|
||||
MONTH = 'month',
|
||||
YEAR = 'year',
|
||||
}
|
||||
|
||||
export const SYNC_INTERVAL_IN_MICROSECONDS = 1000 * 60 * 5; // 5 minutes
|
20
apps/cast/src/constants/pages.ts
Normal file
20
apps/cast/src/constants/pages.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
export enum PAGES {
|
||||
CHANGE_EMAIL = '/change-email',
|
||||
CHANGE_PASSWORD = '/change-password',
|
||||
CREDENTIALS = '/credentials',
|
||||
GALLERY = '/gallery',
|
||||
GENERATE = '/generate',
|
||||
LOGIN = '/login',
|
||||
RECOVER = '/recover',
|
||||
SIGNUP = '/signup',
|
||||
TWO_FACTOR_SETUP = '/two-factor/setup',
|
||||
TWO_FACTOR_VERIFY = '/two-factor/verify',
|
||||
TWO_FACTOR_RECOVER = '/two-factor/recover',
|
||||
VERIFY = '/verify',
|
||||
ROOT = '/',
|
||||
SHARED_ALBUMS = '/shared-albums',
|
||||
// ML_DEBUG = '/ml-debug',
|
||||
DEDUPLICATE = '/deduplicate',
|
||||
// AUTH page is used to show (auth)enticator codes
|
||||
AUTH = '/auth',
|
||||
}
|
15
apps/cast/src/constants/sentry.ts
Normal file
15
apps/cast/src/constants/sentry.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export const ENV_DEVELOPMENT = 'development';
|
||||
export const ENV_PRODUCTION = 'production';
|
||||
|
||||
export const getSentryDSN = () =>
|
||||
process.env.NEXT_PUBLIC_SENTRY_DSN ??
|
||||
'https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2';
|
||||
|
||||
export const getSentryENV = () =>
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENV ?? ENV_PRODUCTION;
|
||||
|
||||
export const getSentryRelease = () => process.env.SENTRY_RELEASE;
|
||||
|
||||
export { getIsSentryEnabled } from '../../sentryConfigUtil';
|
||||
|
||||
export const isDEVSentryENV = () => getSentryENV() === ENV_DEVELOPMENT;
|
142
apps/cast/src/constants/upload.ts
Normal file
142
apps/cast/src/constants/upload.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { ENCRYPTION_CHUNK_SIZE } from '@ente/shared/crypto/constants';
|
||||
import { FILE_TYPE } from 'constants/file';
|
||||
import {
|
||||
FileTypeInfo,
|
||||
ImportSuggestion,
|
||||
Location,
|
||||
ParsedExtractedMetadata,
|
||||
} from 'types/upload';
|
||||
|
||||
// list of format that were missed by type-detection for some files.
|
||||
export const WHITELISTED_FILE_FORMATS: FileTypeInfo[] = [
|
||||
{ fileType: FILE_TYPE.IMAGE, exactType: 'jpeg', mimeType: 'image/jpeg' },
|
||||
{ fileType: FILE_TYPE.IMAGE, exactType: 'jpg', mimeType: 'image/jpeg' },
|
||||
{ fileType: FILE_TYPE.VIDEO, exactType: 'webm', mimeType: 'video/webm' },
|
||||
{ fileType: FILE_TYPE.VIDEO, exactType: 'mod', mimeType: 'video/mpeg' },
|
||||
{ fileType: FILE_TYPE.VIDEO, exactType: 'mp4', mimeType: 'video/mp4' },
|
||||
{ fileType: FILE_TYPE.IMAGE, exactType: 'gif', mimeType: 'image/gif' },
|
||||
{ fileType: FILE_TYPE.VIDEO, exactType: 'dv', mimeType: 'video/x-dv' },
|
||||
{
|
||||
fileType: FILE_TYPE.VIDEO,
|
||||
exactType: 'wmv',
|
||||
mimeType: 'video/x-ms-asf',
|
||||
},
|
||||
{
|
||||
fileType: FILE_TYPE.VIDEO,
|
||||
exactType: 'hevc',
|
||||
mimeType: 'video/hevc',
|
||||
},
|
||||
{
|
||||
fileType: FILE_TYPE.IMAGE,
|
||||
exactType: 'raf',
|
||||
mimeType: 'image/x-fuji-raf',
|
||||
},
|
||||
{
|
||||
fileType: FILE_TYPE.IMAGE,
|
||||
exactType: 'orf',
|
||||
mimeType: 'image/x-olympus-orf',
|
||||
},
|
||||
|
||||
{
|
||||
fileType: FILE_TYPE.IMAGE,
|
||||
exactType: 'crw',
|
||||
mimeType: 'image/x-canon-crw',
|
||||
},
|
||||
];
|
||||
|
||||
export const KNOWN_NON_MEDIA_FORMATS = ['xmp', 'html', 'txt'];
|
||||
|
||||
export const EXIFLESS_FORMATS = ['gif', 'bmp'];
|
||||
|
||||
// this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part.
|
||||
export const MULTIPART_PART_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
export const FILE_READER_CHUNK_SIZE = ENCRYPTION_CHUNK_SIZE;
|
||||
|
||||
export const FILE_CHUNKS_COMBINED_FOR_A_UPLOAD_PART = Math.floor(
|
||||
MULTIPART_PART_SIZE / FILE_READER_CHUNK_SIZE
|
||||
);
|
||||
|
||||
export const RANDOM_PERCENTAGE_PROGRESS_FOR_PUT = () => 90 + 10 * Math.random();
|
||||
|
||||
export const NULL_LOCATION: Location = { latitude: null, longitude: null };
|
||||
|
||||
export enum UPLOAD_STAGES {
|
||||
START,
|
||||
READING_GOOGLE_METADATA_FILES,
|
||||
EXTRACTING_METADATA,
|
||||
UPLOADING,
|
||||
CANCELLING,
|
||||
FINISH,
|
||||
}
|
||||
|
||||
export enum UPLOAD_STRATEGY {
|
||||
SINGLE_COLLECTION,
|
||||
COLLECTION_PER_FOLDER,
|
||||
}
|
||||
|
||||
export enum UPLOAD_RESULT {
|
||||
FAILED,
|
||||
ALREADY_UPLOADED,
|
||||
UNSUPPORTED,
|
||||
BLOCKED,
|
||||
TOO_LARGE,
|
||||
LARGER_THAN_AVAILABLE_STORAGE,
|
||||
UPLOADED,
|
||||
UPLOADED_WITH_STATIC_THUMBNAIL,
|
||||
ADDED_SYMLINK,
|
||||
}
|
||||
|
||||
export enum PICKED_UPLOAD_TYPE {
|
||||
FILES = 'files',
|
||||
FOLDERS = 'folders',
|
||||
ZIPS = 'zips',
|
||||
}
|
||||
|
||||
export const MAX_FILE_SIZE_SUPPORTED = 4 * 1024 * 1024 * 1024; // 4 GB
|
||||
|
||||
export const LIVE_PHOTO_ASSET_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB
|
||||
|
||||
export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = {
|
||||
location: NULL_LOCATION,
|
||||
creationTime: null,
|
||||
width: null,
|
||||
height: null,
|
||||
};
|
||||
|
||||
export const A_SEC_IN_MICROSECONDS = 1e6;
|
||||
|
||||
export const DEFAULT_IMPORT_SUGGESTION: ImportSuggestion = {
|
||||
rootFolderName: '',
|
||||
hasNestedFolders: false,
|
||||
hasRootLevelFileWithFolder: false,
|
||||
};
|
||||
|
||||
export const BLACK_THUMBNAIL_BASE64 =
|
||||
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB' +
|
||||
'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ' +
|
||||
'EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARC' +
|
||||
'ACWASwDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF' +
|
||||
'BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk' +
|
||||
'6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztL' +
|
||||
'W2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAA' +
|
||||
'AAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVY' +
|
||||
'nLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImK' +
|
||||
'kpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oAD' +
|
||||
'AMBAAIRAxEAPwD/AD/6ACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
|
||||
'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
|
||||
'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAC' +
|
||||
'gAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
|
||||
'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
|
||||
'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
|
||||
'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
|
||||
'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
|
||||
'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA' +
|
||||
'KACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg' +
|
||||
'AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
|
||||
'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA' +
|
||||
'CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAK' +
|
||||
'ACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA' +
|
||||
'KACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
|
||||
'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo' +
|
||||
'AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD/9k=';
|
19
apps/cast/src/constants/urls.ts
Normal file
19
apps/cast/src/constants/urls.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export const ENTE_WEBSITE_LINK = 'https://ente.io';
|
||||
|
||||
export const ML_BLOG_LINK = 'https://ente.io/blog/desktop-ml-beta';
|
||||
|
||||
export const FACE_SEARCH_PRIVACY_POLICY_LINK =
|
||||
'https://ente.io/privacy#8-biometric-information-privacy-policy';
|
||||
|
||||
export const SUPPORT_EMAIL = 'support@ente.io';
|
||||
|
||||
export const APP_DOWNLOAD_URL = 'https://ente.io/download/desktop';
|
||||
|
||||
export const FEEDBACK_EMAIL = 'feedback@ente.io';
|
||||
|
||||
export const DELETE_ACCOUNT_EMAIL = 'account-deletion@ente.io';
|
||||
|
||||
export const WEB_ROADMAP_URL = 'https://github.com/ente-io/photos-web/issues';
|
||||
|
||||
export const DESKTOP_ROADMAP_URL =
|
||||
'https://github.com/ente-io/photos-desktop/issues';
|
21
apps/cast/src/pages/_app.tsx
Normal file
21
apps/cast/src/pages/_app.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type { AppProps } from 'next/app';
|
||||
import 'styles/global.css';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { getTheme } from '@ente/shared/themes';
|
||||
import { THEME_COLOR } from '@ente/shared/themes/constants';
|
||||
import { APPS } from '@ente/shared/apps/constants';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<ThemeProvider theme={getTheme(THEME_COLOR.DARK, APPS.PHOTOS)}>
|
||||
<CssBaseline enableColorScheme />
|
||||
|
||||
<main
|
||||
style={{
|
||||
display: 'contents',
|
||||
}}>
|
||||
<Component {...pageProps} />
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
25
apps/cast/src/pages/_document.tsx
Normal file
25
apps/cast/src/pages/_document.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Html, Head, Main, NextScript } from 'next/document';
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html
|
||||
lang="en"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}>
|
||||
<Head />
|
||||
<body
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
}}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
242
apps/cast/src/pages/index.tsx
Normal file
242
apps/cast/src/pages/index.tsx
Normal file
|
@ -0,0 +1,242 @@
|
|||
import EnteSpinner from '@ente/shared/components/EnteSpinner';
|
||||
import { boxSealOpen, toB64 } from '@ente/shared/crypto/internal/libsodium';
|
||||
import { useCastReceiver } from '@ente/shared/hooks/useCastReceiver';
|
||||
import { addLogLine } from '@ente/shared/logging';
|
||||
import castGateway from '@ente/shared/network/cast';
|
||||
import LargeType from 'components/LargeType';
|
||||
import _sodium from 'libsodium-wrappers';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { storeCastData } from 'services/cast/castService';
|
||||
|
||||
// Function to generate cryptographically secure digits
|
||||
const generateSecureData = (length: number): Uint8Array => {
|
||||
const array = new Uint8Array(length);
|
||||
window.crypto.getRandomValues(array);
|
||||
// Modulo operation to ensure each byte is a single digit
|
||||
for (let i = 0; i < length; i++) {
|
||||
array[i] = array[i] % 10;
|
||||
}
|
||||
return array;
|
||||
};
|
||||
|
||||
const convertDataToDecimalString = (data: Uint8Array): string => {
|
||||
let decimalString = '';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
decimalString += data[i].toString(); // No need to pad, as each value is a single digit
|
||||
}
|
||||
return decimalString;
|
||||
};
|
||||
|
||||
export default function PairingMode() {
|
||||
const [digits, setDigits] = useState<string[]>([]);
|
||||
const [publicKeyB64, setPublicKeyB64] = useState('');
|
||||
const [privateKeyB64, setPrivateKeyB64] = useState('');
|
||||
const [codePending, setCodePending] = useState(true);
|
||||
const [isCastReady, setIsCastReady] = useState(false);
|
||||
|
||||
const { cast } = useCastReceiver();
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cast) return;
|
||||
if (isCastReady) return;
|
||||
const context = cast.framework.CastReceiverContext.getInstance();
|
||||
|
||||
try {
|
||||
const options = new cast.framework.CastReceiverOptions();
|
||||
options.customNamespaces = Object.assign({});
|
||||
options.customNamespaces['urn:x-cast:pair-request'] =
|
||||
cast.framework.system.MessageType.JSON;
|
||||
|
||||
options.disableIdleTimeout = true;
|
||||
|
||||
context.addCustomMessageListener(
|
||||
'urn:x-cast:pair-request',
|
||||
messageReceiveHandler
|
||||
);
|
||||
context.start(options);
|
||||
} catch (e) {
|
||||
addLogLine(e, 'failed to create cast context');
|
||||
}
|
||||
setIsCastReady(true);
|
||||
return () => {
|
||||
context.stop();
|
||||
};
|
||||
}, [cast, isCastReady]);
|
||||
|
||||
const messageReceiveHandler = (message: {
|
||||
type: string;
|
||||
senderId: string;
|
||||
data: any;
|
||||
}) => {
|
||||
cast.framework.CastReceiverContext.getInstance().sendCustomMessage(
|
||||
'urn:x-cast:pair-request',
|
||||
message.senderId,
|
||||
{
|
||||
code: digits.join(''),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
const data = generateSecureData(6);
|
||||
setDigits(convertDataToDecimalString(data).split(''));
|
||||
const keypair = await generateKeyPair();
|
||||
setPublicKeyB64(await toB64(keypair.publicKey));
|
||||
setPrivateKeyB64(await toB64(keypair.privateKey));
|
||||
};
|
||||
|
||||
const generateKeyPair = async () => {
|
||||
await _sodium.ready;
|
||||
|
||||
const keypair = _sodium.crypto_box_keypair();
|
||||
|
||||
return keypair;
|
||||
};
|
||||
|
||||
const pollForCastData = async () => {
|
||||
if (codePending) {
|
||||
return;
|
||||
}
|
||||
// see if we were acknowledged on the client.
|
||||
// the client will send us the encrypted payload using our public key that we advertised.
|
||||
// then, we can decrypt this and store all the necessary info locally so we can play the collection slideshow.
|
||||
let devicePayload = '';
|
||||
try {
|
||||
const encDastData = await castGateway.getCastData(
|
||||
`${digits.join('')}`
|
||||
);
|
||||
if (!encDastData) return;
|
||||
devicePayload = encDastData;
|
||||
} catch (e) {
|
||||
setCodePending(true);
|
||||
init();
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptedPayload = await boxSealOpen(
|
||||
devicePayload,
|
||||
publicKeyB64,
|
||||
privateKeyB64
|
||||
);
|
||||
|
||||
const decryptedPayloadObj = JSON.parse(atob(decryptedPayload));
|
||||
|
||||
return decryptedPayloadObj;
|
||||
};
|
||||
|
||||
const advertisePublicKey = async (publicKeyB64: string) => {
|
||||
// hey client, we exist!
|
||||
try {
|
||||
await castGateway.registerDevice(
|
||||
`${digits.join('')}`,
|
||||
publicKeyB64
|
||||
);
|
||||
setCodePending(false);
|
||||
} catch (e) {
|
||||
// schedule re-try after 5 seconds
|
||||
setTimeout(() => {
|
||||
init();
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (digits.length < 1 || !publicKeyB64 || !privateKeyB64) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const data = await pollForCastData();
|
||||
if (!data) return;
|
||||
storeCastData(data);
|
||||
await router.push('/slideshow');
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [digits, publicKeyB64, privateKeyB64, codePending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicKeyB64) return;
|
||||
advertisePublicKey(publicKeyB64);
|
||||
}, [publicKeyB64]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<img width={150} src="/images/ente.svg" />
|
||||
<h1
|
||||
style={{
|
||||
fontWeight: 'normal',
|
||||
}}>
|
||||
Enter this code on <b>ente</b> to pair this TV
|
||||
</h1>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{codePending ? (
|
||||
<EnteSpinner />
|
||||
) : (
|
||||
<>
|
||||
<LargeType chars={digits} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '1.2rem',
|
||||
}}>
|
||||
Visit{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: '#87CEFA',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
href="https://ente.io/cast"
|
||||
target="_blank">
|
||||
ente.io/cast
|
||||
</a>{' '}
|
||||
for help
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
backgroundColor: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '10px',
|
||||
borderRadius: '10px',
|
||||
}}>
|
||||
<img src="/images/help-qrcode.webp" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
186
apps/cast/src/pages/slideshow.tsx
Normal file
186
apps/cast/src/pages/slideshow.tsx
Normal file
|
@ -0,0 +1,186 @@
|
|||
import { logError } from '@ente/shared/sentry';
|
||||
import PairedSuccessfullyOverlay from 'components/PairedSuccessfullyOverlay';
|
||||
import Theatre from 'components/Theatre';
|
||||
import { FILE_TYPE } from 'constants/file';
|
||||
import { useRouter } from 'next/router';
|
||||
import { createContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
getCastCollection,
|
||||
getLocalFiles,
|
||||
syncPublicFiles,
|
||||
} from 'services/cast/castService';
|
||||
import { Collection } from 'types/collection';
|
||||
import { EnteFile } from 'types/file';
|
||||
import { getPreviewableImage, isRawFileFromFileName } from 'utils/file';
|
||||
|
||||
export const SlideshowContext = createContext<{
|
||||
showNextSlide: () => void;
|
||||
}>(null);
|
||||
|
||||
const renderableFileURLCache = new Map<number, string>();
|
||||
|
||||
export default function Slideshow() {
|
||||
const [collectionFiles, setCollectionFiles] = useState<EnteFile[]>([]);
|
||||
|
||||
const [currentFile, setCurrentFile] = useState<EnteFile | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [nextFile, setNextFile] = useState<EnteFile | undefined>(undefined);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [castToken, setCastToken] = useState<string>('');
|
||||
const [castCollection, setCastCollection] = useState<
|
||||
Collection | undefined
|
||||
>(undefined);
|
||||
|
||||
const syncCastFiles = async (token: string) => {
|
||||
try {
|
||||
const castToken = window.localStorage.getItem('castToken');
|
||||
const requestedCollectionKey =
|
||||
window.localStorage.getItem('collectionKey');
|
||||
const collection = await getCastCollection(
|
||||
castToken,
|
||||
requestedCollectionKey
|
||||
);
|
||||
if (
|
||||
castCollection === undefined ||
|
||||
castCollection.updationTime !== collection.updationTime
|
||||
) {
|
||||
setCastCollection(collection);
|
||||
await syncPublicFiles(token, collection, () => { });
|
||||
const files = await getLocalFiles(String(collection.id));
|
||||
setCollectionFiles(
|
||||
files.filter((file) => isFileEligibleForCast(file))
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, 'error during sync');
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const castToken = window.localStorage.getItem('castToken');
|
||||
setCastToken(castToken);
|
||||
} catch (e) {
|
||||
logError(e, 'error during sync');
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (castToken) {
|
||||
const intervalId = setInterval(() => {
|
||||
syncCastFiles(castToken);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, [castToken]);
|
||||
|
||||
const isFileEligibleForCast = (file: EnteFile) => {
|
||||
const fileType = file.metadata.fileType;
|
||||
if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileSizeLimit = 100 * 1024 * 1024;
|
||||
|
||||
if (file.info.fileSize > fileSizeLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = file.metadata.title;
|
||||
|
||||
if (fileType === FILE_TYPE.IMAGE) {
|
||||
if (isRawFileFromFileName(name)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (collectionFiles.length < 1) return;
|
||||
showNextSlide();
|
||||
}, [collectionFiles]);
|
||||
|
||||
const showNextSlide = () => {
|
||||
const currentIndex = collectionFiles.findIndex(
|
||||
(file) => file.id === currentFile?.id
|
||||
);
|
||||
|
||||
const nextIndex = (currentIndex + 1) % collectionFiles.length;
|
||||
const nextNextIndex = (nextIndex + 1) % collectionFiles.length;
|
||||
|
||||
const nextFile = collectionFiles[nextIndex];
|
||||
const nextNextFile = collectionFiles[nextNextIndex];
|
||||
|
||||
setCurrentFile(nextFile);
|
||||
setNextFile(nextNextFile);
|
||||
};
|
||||
|
||||
const [renderableFileURL, setRenderableFileURL] = useState<string>('');
|
||||
|
||||
const getRenderableFileURL = async () => {
|
||||
if (!currentFile) return;
|
||||
|
||||
const cacheValue = renderableFileURLCache.get(currentFile.id);
|
||||
if (cacheValue) {
|
||||
setRenderableFileURL(cacheValue);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await getPreviewableImage(
|
||||
currentFile as EnteFile,
|
||||
castToken
|
||||
);
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
renderableFileURLCache.set(currentFile?.id, url);
|
||||
|
||||
setRenderableFileURL(url);
|
||||
} catch (e) {
|
||||
return;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFile) {
|
||||
getRenderableFileURL();
|
||||
}
|
||||
}, [currentFile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlideshowContext.Provider value={{ showNextSlide }}>
|
||||
<Theatre
|
||||
file1={{
|
||||
fileName: currentFile?.metadata.title,
|
||||
fileURL: renderableFileURL,
|
||||
type: currentFile?.metadata.fileType,
|
||||
}}
|
||||
file2={{
|
||||
fileName: nextFile?.metadata.title,
|
||||
fileURL: renderableFileURL,
|
||||
type: nextFile?.metadata.fileType,
|
||||
}}
|
||||
/>
|
||||
</SlideshowContext.Provider>
|
||||
{loading && <PairedSuccessfullyOverlay />}
|
||||
</>
|
||||
);
|
||||
}
|
32
apps/cast/src/services/InMemoryStore.ts
Normal file
32
apps/cast/src/services/InMemoryStore.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
export enum MS_KEYS {
|
||||
OPT_OUT_OF_CRASH_REPORTS = 'optOutOfCrashReports',
|
||||
SRP_CONFIGURE_IN_PROGRESS = 'srpConfigureInProgress',
|
||||
REDIRECT_URL = 'redirectUrl',
|
||||
}
|
||||
|
||||
type StoreType = Map<Partial<MS_KEYS>, any>;
|
||||
|
||||
class InMemoryStore {
|
||||
private store: StoreType = new Map();
|
||||
|
||||
get(key: MS_KEYS) {
|
||||
return this.store.get(key);
|
||||
}
|
||||
|
||||
set(key: MS_KEYS, value: any) {
|
||||
this.store.set(key, value);
|
||||
}
|
||||
|
||||
delete(key: MS_KEYS) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
has(key: MS_KEYS) {
|
||||
return this.store.has(key);
|
||||
}
|
||||
clear() {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default new InMemoryStore();
|
41
apps/cast/src/services/cache/cacheStorageFactory.ts
vendored
Normal file
41
apps/cast/src/services/cache/cacheStorageFactory.ts
vendored
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { LimitedCacheStorage } from 'types/cache/index';
|
||||
// import { ElectronCacheStorage } from 'services/electron/cache';
|
||||
// import { runningInElectron, runningInWorker } from 'utils/common';
|
||||
// import { WorkerElectronCacheStorageService } from 'services/workerElectronCache/service';
|
||||
|
||||
class cacheStorageFactory {
|
||||
// workerElectronCacheStorageServiceInstance: WorkerElectronCacheStorageService;
|
||||
getCacheStorage(): LimitedCacheStorage {
|
||||
// if (runningInElectron()) {
|
||||
// if (runningInWorker()) {
|
||||
// if (!this.workerElectronCacheStorageServiceInstance) {
|
||||
// // this.workerElectronCacheStorageServiceInstance =
|
||||
// // new WorkerElectronCacheStorageService();
|
||||
// }
|
||||
// return this.workerElectronCacheStorageServiceInstance;
|
||||
// } else {
|
||||
// // return ElectronCacheStorage;
|
||||
// }
|
||||
// } else {
|
||||
return transformBrowserCacheStorageToLimitedCacheStorage(caches);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
export const CacheStorageFactory = new cacheStorageFactory();
|
||||
|
||||
function transformBrowserCacheStorageToLimitedCacheStorage(
|
||||
caches: CacheStorage
|
||||
): LimitedCacheStorage {
|
||||
return {
|
||||
async open(cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
return {
|
||||
match: cache.match.bind(cache),
|
||||
put: cache.put.bind(cache),
|
||||
delete: cache.delete.bind(cache),
|
||||
};
|
||||
},
|
||||
delete: caches.delete.bind(caches),
|
||||
};
|
||||
}
|
33
apps/cast/src/services/cache/cacheStorageService.ts
vendored
Normal file
33
apps/cast/src/services/cache/cacheStorageService.ts
vendored
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { logError } from '@ente/shared/sentry';
|
||||
import { CacheStorageFactory } from './cacheStorageFactory';
|
||||
|
||||
const SecurityError = 'SecurityError';
|
||||
const INSECURE_OPERATION = 'The operation is insecure.';
|
||||
async function openCache(cacheName: string) {
|
||||
try {
|
||||
return await CacheStorageFactory.getCacheStorage().open(cacheName);
|
||||
} catch (e) {
|
||||
// ignoring insecure operation error, as it is thrown in incognito mode in firefox
|
||||
if (e.name === SecurityError && e.message === INSECURE_OPERATION) {
|
||||
// no-op
|
||||
} else {
|
||||
// log and ignore, we don't want to break the caller flow, when cache is not available
|
||||
logError(e, 'openCache failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
async function deleteCache(cacheName: string) {
|
||||
try {
|
||||
return await CacheStorageFactory.getCacheStorage().delete(cacheName);
|
||||
} catch (e) {
|
||||
// ignoring insecure operation error, as it is thrown in incognito mode in firefox
|
||||
if (e.name === SecurityError && e.message === INSECURE_OPERATION) {
|
||||
// no-op
|
||||
} else {
|
||||
// log and ignore, we don't want to break the caller flow, when cache is not available
|
||||
logError(e, 'deleteCache failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CacheStorageService = { open: openCache, delete: deleteCache };
|
305
apps/cast/src/services/cast/castService.ts
Normal file
305
apps/cast/src/services/cast/castService.ts
Normal file
|
@ -0,0 +1,305 @@
|
|||
import { getEndpoint } from '@ente/shared/network/api';
|
||||
import localForage from '@ente/shared/storage/localForage';
|
||||
import HTTPService from '@ente/shared/network/HTTPService';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
import { CustomError, parseSharingErrorCodes } from '@ente/shared/error';
|
||||
import ComlinkCryptoWorker from '@ente/shared/crypto';
|
||||
|
||||
import { Collection, CollectionPublicMagicMetadata } from 'types/collection';
|
||||
import { EncryptedEnteFile, EnteFile } from 'types/file';
|
||||
import { decryptFile, mergeMetadata, sortFiles } from 'utils/file';
|
||||
|
||||
export interface SavedCollectionFiles {
|
||||
collectionLocalID: string;
|
||||
files: EnteFile[];
|
||||
}
|
||||
const ENDPOINT = getEndpoint();
|
||||
const COLLECTION_FILES_TABLE = 'collection-files';
|
||||
const COLLECTIONS_TABLE = 'collections';
|
||||
|
||||
const getLastSyncKey = (collectionUID: string) => `${collectionUID}-time`;
|
||||
|
||||
export const getLocalFiles = async (
|
||||
collectionUID: string
|
||||
): Promise<EnteFile[]> => {
|
||||
const localSavedcollectionFiles =
|
||||
(await localForage.getItem<SavedCollectionFiles[]>(
|
||||
COLLECTION_FILES_TABLE
|
||||
)) || [];
|
||||
const matchedCollection = localSavedcollectionFiles.find(
|
||||
(item) => item.collectionLocalID === collectionUID
|
||||
);
|
||||
return matchedCollection?.files || [];
|
||||
};
|
||||
|
||||
const savecollectionFiles = async (
|
||||
collectionUID: string,
|
||||
files: EnteFile[]
|
||||
) => {
|
||||
const collectionFiles =
|
||||
(await localForage.getItem<SavedCollectionFiles[]>(
|
||||
COLLECTION_FILES_TABLE
|
||||
)) || [];
|
||||
await localForage.setItem(
|
||||
COLLECTION_FILES_TABLE,
|
||||
dedupeCollectionFiles([
|
||||
{ collectionLocalID: collectionUID, files },
|
||||
...collectionFiles,
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
export const getLocalCollections = async (collectionKey: string) => {
|
||||
const localCollections =
|
||||
(await localForage.getItem<Collection[]>(COLLECTIONS_TABLE)) || [];
|
||||
const collection =
|
||||
localCollections.find(
|
||||
(localSavedPublicCollection) =>
|
||||
localSavedPublicCollection.key === collectionKey
|
||||
) || null;
|
||||
return collection;
|
||||
};
|
||||
|
||||
const saveCollection = async (collection: Collection) => {
|
||||
const collections =
|
||||
(await localForage.getItem<Collection[]>(COLLECTIONS_TABLE)) ?? [];
|
||||
await localForage.setItem(
|
||||
COLLECTIONS_TABLE,
|
||||
dedupeCollections([collection, ...collections])
|
||||
);
|
||||
};
|
||||
|
||||
const dedupeCollections = (collections: Collection[]) => {
|
||||
const keySet = new Set([]);
|
||||
return collections.filter((collection) => {
|
||||
if (!keySet.has(collection.key)) {
|
||||
keySet.add(collection.key);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const dedupeCollectionFiles = (collectionFiles: SavedCollectionFiles[]) => {
|
||||
const keySet = new Set([]);
|
||||
return collectionFiles.filter(({ collectionLocalID: collectionUID }) => {
|
||||
if (!keySet.has(collectionUID)) {
|
||||
keySet.add(collectionUID);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
async function getSyncTime(collectionUID: string): Promise<number> {
|
||||
const lastSyncKey = getLastSyncKey(collectionUID);
|
||||
const lastSyncTime = await localForage.getItem<number>(lastSyncKey);
|
||||
return lastSyncTime ?? 0;
|
||||
}
|
||||
|
||||
const updateSyncTime = async (collectionUID: string, time: number) =>
|
||||
await localForage.setItem(getLastSyncKey(collectionUID), time);
|
||||
|
||||
export const syncPublicFiles = async (
|
||||
token: string,
|
||||
collection: Collection,
|
||||
setPublicFiles: (files: EnteFile[]) => void
|
||||
) => {
|
||||
try {
|
||||
let files: EnteFile[] = [];
|
||||
const sortAsc = collection?.pubMagicMetadata?.data.asc ?? false;
|
||||
const collectionUID = String(collection.id);
|
||||
const localFiles = await getLocalFiles(collectionUID);
|
||||
files = [...files, ...localFiles];
|
||||
try {
|
||||
const lastSyncTime = await getSyncTime(collectionUID);
|
||||
if (collection.updationTime === lastSyncTime) {
|
||||
return sortFiles(files, sortAsc);
|
||||
}
|
||||
const fetchedFiles = await fetchFiles(
|
||||
token,
|
||||
collection,
|
||||
lastSyncTime,
|
||||
files,
|
||||
setPublicFiles
|
||||
);
|
||||
|
||||
files = [...files, ...fetchedFiles];
|
||||
const latestVersionFiles = new Map<string, EnteFile>();
|
||||
files.forEach((file) => {
|
||||
const uid = `${file.collectionID}-${file.id}`;
|
||||
if (
|
||||
!latestVersionFiles.has(uid) ||
|
||||
latestVersionFiles.get(uid).updationTime < file.updationTime
|
||||
) {
|
||||
latestVersionFiles.set(uid, file);
|
||||
}
|
||||
});
|
||||
files = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for (const [_, file] of latestVersionFiles) {
|
||||
if (file.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
files.push(file);
|
||||
}
|
||||
await savecollectionFiles(collectionUID, files);
|
||||
await updateSyncTime(collectionUID, collection.updationTime);
|
||||
setPublicFiles([...sortFiles(mergeMetadata(files), sortAsc)]);
|
||||
} catch (e) {
|
||||
const parsedError = parseSharingErrorCodes(e);
|
||||
logError(e, 'failed to sync shared collection files');
|
||||
if (parsedError.message === CustomError.TOKEN_EXPIRED) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return [...sortFiles(mergeMetadata(files), sortAsc)];
|
||||
} catch (e) {
|
||||
logError(e, 'failed to get local or sync shared collection files');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFiles = async (
|
||||
castToken: string,
|
||||
collection: Collection,
|
||||
sinceTime: number,
|
||||
files: EnteFile[],
|
||||
setPublicFiles: (files: EnteFile[]) => void
|
||||
): Promise<EnteFile[]> => {
|
||||
try {
|
||||
let decryptedFiles: EnteFile[] = [];
|
||||
let time = sinceTime;
|
||||
let resp;
|
||||
const sortAsc = collection?.pubMagicMetadata?.data.asc ?? false;
|
||||
do {
|
||||
if (!castToken) {
|
||||
break;
|
||||
}
|
||||
resp = await HTTPService.get(
|
||||
`${ENDPOINT}/cast/diff`,
|
||||
{
|
||||
sinceTime: time,
|
||||
},
|
||||
{
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Cast-Access-Token': castToken,
|
||||
}
|
||||
);
|
||||
decryptedFiles = [
|
||||
...decryptedFiles,
|
||||
...(await Promise.all(
|
||||
resp.data.diff.map(async (file: EncryptedEnteFile) => {
|
||||
if (!file.isDeleted) {
|
||||
return await decryptFile(file, collection.key);
|
||||
} else {
|
||||
return file;
|
||||
}
|
||||
}) as Promise<EnteFile>[]
|
||||
)),
|
||||
];
|
||||
|
||||
if (resp.data.diff.length) {
|
||||
time = resp.data.diff.slice(-1)[0].updationTime;
|
||||
}
|
||||
setPublicFiles(
|
||||
sortFiles(
|
||||
mergeMetadata(
|
||||
[...(files || []), ...decryptedFiles].filter(
|
||||
(item) => !item.isDeleted
|
||||
)
|
||||
),
|
||||
sortAsc
|
||||
)
|
||||
);
|
||||
} while (resp.data.hasMore);
|
||||
return decryptedFiles;
|
||||
} catch (e) {
|
||||
logError(e, 'Get cast files failed');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCastCollection = async (
|
||||
castToken: string,
|
||||
collectionKey: string
|
||||
): Promise<Collection> => {
|
||||
try {
|
||||
const resp = await HTTPService.get(`${ENDPOINT}/cast/info`, null, {
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Cast-Access-Token': castToken,
|
||||
});
|
||||
const fetchedCollection = resp.data.collection;
|
||||
|
||||
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||
|
||||
const collectionName = (fetchedCollection.name =
|
||||
fetchedCollection.name ||
|
||||
(await cryptoWorker.decryptToUTF8(
|
||||
fetchedCollection.encryptedName,
|
||||
fetchedCollection.nameDecryptionNonce,
|
||||
collectionKey
|
||||
)));
|
||||
|
||||
let collectionPublicMagicMetadata: CollectionPublicMagicMetadata;
|
||||
if (fetchedCollection.pubMagicMetadata?.data) {
|
||||
collectionPublicMagicMetadata = {
|
||||
...fetchedCollection.pubMagicMetadata,
|
||||
data: await cryptoWorker.decryptMetadata(
|
||||
fetchedCollection.pubMagicMetadata.data,
|
||||
fetchedCollection.pubMagicMetadata.header,
|
||||
collectionKey
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const collection = {
|
||||
...fetchedCollection,
|
||||
name: collectionName,
|
||||
key: collectionKey,
|
||||
pubMagicMetadata: collectionPublicMagicMetadata,
|
||||
};
|
||||
await saveCollection(collection);
|
||||
return collection;
|
||||
} catch (e) {
|
||||
logError(e, 'failed to get cast collection');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeCollection = async (
|
||||
collectionUID: string,
|
||||
collectionKey: string
|
||||
) => {
|
||||
const collections =
|
||||
(await localForage.getItem<Collection[]>(COLLECTIONS_TABLE)) || [];
|
||||
await localForage.setItem(
|
||||
COLLECTIONS_TABLE,
|
||||
collections.filter((collection) => collection.key !== collectionKey)
|
||||
);
|
||||
await removeCollectionFiles(collectionUID);
|
||||
};
|
||||
|
||||
export const removeCollectionFiles = async (collectionUID: string) => {
|
||||
await localForage.removeItem(getLastSyncKey(collectionUID));
|
||||
const collectionFiles =
|
||||
(await localForage.getItem<SavedCollectionFiles[]>(
|
||||
COLLECTION_FILES_TABLE
|
||||
)) ?? [];
|
||||
await localForage.setItem(
|
||||
COLLECTION_FILES_TABLE,
|
||||
collectionFiles.filter(
|
||||
(collectionFiles) =>
|
||||
collectionFiles.collectionLocalID !== collectionUID
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const storeCastData = (payloadObj: Object) => {
|
||||
// iterate through all the keys in the payload object and set them in localStorage.
|
||||
for (const key in payloadObj) {
|
||||
window.localStorage.setItem(key, payloadObj[key]);
|
||||
}
|
||||
};
|
273
apps/cast/src/services/castDownloadManager.ts
Normal file
273
apps/cast/src/services/castDownloadManager.ts
Normal file
|
@ -0,0 +1,273 @@
|
|||
import {
|
||||
generateStreamFromArrayBuffer,
|
||||
getRenderableFileURL,
|
||||
createTypedObjectURL,
|
||||
} from 'utils/file';
|
||||
import { EnteFile } from 'types/file';
|
||||
|
||||
import { FILE_TYPE } from 'constants/file';
|
||||
import { CustomError } from '@ente/shared/error';
|
||||
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
|
||||
import { CACHES } from 'constants/cache';
|
||||
import { CacheStorageService } from './cache/cacheStorageService';
|
||||
import { LimitedCache } from 'types/cache';
|
||||
import { getCastFileURL, getCastThumbnailURL } from '@ente/shared/network/api';
|
||||
import HTTPService from '@ente/shared/network/HTTPService';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
|
||||
class CastDownloadManager {
|
||||
private fileObjectURLPromise = new Map<
|
||||
string,
|
||||
Promise<{ original: string[]; converted: string[] }>
|
||||
>();
|
||||
private thumbnailObjectURLPromise = new Map<number, Promise<string>>();
|
||||
|
||||
private fileDownloadProgress = new Map<number, number>();
|
||||
|
||||
private progressUpdater: (value: Map<number, number>) => void;
|
||||
|
||||
setProgressUpdater(progressUpdater: (value: Map<number, number>) => void) {
|
||||
this.progressUpdater = progressUpdater;
|
||||
}
|
||||
|
||||
private async getThumbnailCache() {
|
||||
try {
|
||||
const thumbnailCache = await CacheStorageService.open(
|
||||
CACHES.THUMBS
|
||||
);
|
||||
return thumbnailCache;
|
||||
} catch (e) {
|
||||
return null;
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
public async getCachedThumbnail(
|
||||
file: EnteFile,
|
||||
thumbnailCache?: LimitedCache
|
||||
) {
|
||||
try {
|
||||
if (!thumbnailCache) {
|
||||
thumbnailCache = await this.getThumbnailCache();
|
||||
}
|
||||
const cacheResp: Response = await thumbnailCache?.match(
|
||||
file.id.toString()
|
||||
);
|
||||
|
||||
if (cacheResp) {
|
||||
return URL.createObjectURL(await cacheResp.blob());
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
logError(e, 'failed to get cached thumbnail');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async getThumbnail(file: EnteFile, castToken: string) {
|
||||
try {
|
||||
if (!this.thumbnailObjectURLPromise.has(file.id)) {
|
||||
const downloadPromise = async () => {
|
||||
const thumbnailCache = await this.getThumbnailCache();
|
||||
const cachedThumb = await this.getCachedThumbnail(
|
||||
file,
|
||||
thumbnailCache
|
||||
);
|
||||
if (cachedThumb) {
|
||||
return cachedThumb;
|
||||
}
|
||||
|
||||
const thumb = await this.downloadThumb(castToken, file);
|
||||
const thumbBlob = new Blob([thumb]);
|
||||
try {
|
||||
await thumbnailCache?.put(
|
||||
file.id.toString(),
|
||||
new Response(thumbBlob)
|
||||
);
|
||||
} catch (e) {
|
||||
// TODO: handle storage full exception.
|
||||
}
|
||||
return URL.createObjectURL(thumbBlob);
|
||||
};
|
||||
this.thumbnailObjectURLPromise.set(file.id, downloadPromise());
|
||||
}
|
||||
|
||||
return await this.thumbnailObjectURLPromise.get(file.id);
|
||||
} catch (e) {
|
||||
this.thumbnailObjectURLPromise.delete(file.id);
|
||||
logError(e, 'get castDownloadManager preview Failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private downloadThumb = async (castToken: string, file: EnteFile) => {
|
||||
const resp = await HTTPService.get(
|
||||
getCastThumbnailURL(file.id),
|
||||
null,
|
||||
{
|
||||
'X-Cast-Access-Token': castToken,
|
||||
},
|
||||
{ responseType: 'arraybuffer' }
|
||||
);
|
||||
if (typeof resp.data === 'undefined') {
|
||||
throw Error(CustomError.REQUEST_FAILED);
|
||||
}
|
||||
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||
const decrypted = await cryptoWorker.decryptThumbnail(
|
||||
new Uint8Array(resp.data),
|
||||
await cryptoWorker.fromB64(file.thumbnail.decryptionHeader),
|
||||
file.key
|
||||
);
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
getFile = async (file: EnteFile, castToken: string, forPreview = false) => {
|
||||
const fileKey = forPreview ? `${file.id}_preview` : `${file.id}`;
|
||||
try {
|
||||
const getFilePromise = async () => {
|
||||
const fileStream = await this.downloadFile(castToken, file);
|
||||
const fileBlob = await new Response(fileStream).blob();
|
||||
if (forPreview) {
|
||||
return await getRenderableFileURL(file, fileBlob);
|
||||
} else {
|
||||
const fileURL = await createTypedObjectURL(
|
||||
fileBlob,
|
||||
file.metadata.title
|
||||
);
|
||||
return { converted: [fileURL], original: [fileURL] };
|
||||
}
|
||||
};
|
||||
|
||||
if (!this.fileObjectURLPromise.get(fileKey)) {
|
||||
this.fileObjectURLPromise.set(fileKey, getFilePromise());
|
||||
}
|
||||
const fileURLs = await this.fileObjectURLPromise.get(fileKey);
|
||||
return fileURLs;
|
||||
} catch (e) {
|
||||
this.fileObjectURLPromise.delete(fileKey);
|
||||
logError(e, 'castDownloadManager failed to get file');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
public async getCachedOriginalFile(file: EnteFile) {
|
||||
return await this.fileObjectURLPromise.get(file.id.toString());
|
||||
}
|
||||
|
||||
async downloadFile(castToken: string, file: EnteFile) {
|
||||
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||
const onDownloadProgress = this.trackDownloadProgress(file.id);
|
||||
|
||||
if (
|
||||
file.metadata.fileType === FILE_TYPE.IMAGE ||
|
||||
file.metadata.fileType === FILE_TYPE.LIVE_PHOTO
|
||||
) {
|
||||
const resp = await HTTPService.get(
|
||||
getCastFileURL(file.id),
|
||||
null,
|
||||
{
|
||||
'X-Cast-Access-Token': castToken,
|
||||
},
|
||||
{ responseType: 'arraybuffer' }
|
||||
);
|
||||
if (typeof resp.data === 'undefined') {
|
||||
throw Error(CustomError.REQUEST_FAILED);
|
||||
}
|
||||
const decrypted = await cryptoWorker.decryptFile(
|
||||
new Uint8Array(resp.data),
|
||||
await cryptoWorker.fromB64(file.file.decryptionHeader),
|
||||
file.key
|
||||
);
|
||||
return generateStreamFromArrayBuffer(decrypted);
|
||||
}
|
||||
const resp = await fetch(getCastFileURL(file.id), {
|
||||
headers: {
|
||||
'X-Cast-Access-Token': castToken,
|
||||
},
|
||||
});
|
||||
const reader = resp.body.getReader();
|
||||
|
||||
const contentLength = +resp.headers.get('Content-Length');
|
||||
let downloadedBytes = 0;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const decryptionHeader = await cryptoWorker.fromB64(
|
||||
file.file.decryptionHeader
|
||||
);
|
||||
const fileKey = await cryptoWorker.fromB64(file.key);
|
||||
const { pullState, decryptionChunkSize } =
|
||||
await cryptoWorker.initChunkDecryption(
|
||||
decryptionHeader,
|
||||
fileKey
|
||||
);
|
||||
let data = new Uint8Array();
|
||||
// The following function handles each data chunk
|
||||
function push() {
|
||||
// "done" is a Boolean and value a "Uint8Array"
|
||||
reader.read().then(async ({ done, value }) => {
|
||||
// Is there more data to read?
|
||||
if (!done) {
|
||||
downloadedBytes += value.byteLength;
|
||||
onDownloadProgress({
|
||||
loaded: downloadedBytes,
|
||||
total: contentLength,
|
||||
});
|
||||
const buffer = new Uint8Array(
|
||||
data.byteLength + value.byteLength
|
||||
);
|
||||
buffer.set(new Uint8Array(data), 0);
|
||||
buffer.set(new Uint8Array(value), data.byteLength);
|
||||
if (buffer.length > decryptionChunkSize) {
|
||||
const fileData = buffer.slice(
|
||||
0,
|
||||
decryptionChunkSize
|
||||
);
|
||||
const { decryptedData } =
|
||||
await cryptoWorker.decryptFileChunk(
|
||||
fileData,
|
||||
pullState
|
||||
);
|
||||
controller.enqueue(decryptedData);
|
||||
data = buffer.slice(decryptionChunkSize);
|
||||
} else {
|
||||
data = buffer;
|
||||
}
|
||||
push();
|
||||
} else {
|
||||
if (data) {
|
||||
const { decryptedData } =
|
||||
await cryptoWorker.decryptFileChunk(
|
||||
data,
|
||||
pullState
|
||||
);
|
||||
controller.enqueue(decryptedData);
|
||||
data = null;
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
push();
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
trackDownloadProgress = (fileID: number) => {
|
||||
return (event: { loaded: number; total: number }) => {
|
||||
if (event.loaded === event.total) {
|
||||
this.fileDownloadProgress.delete(fileID);
|
||||
} else {
|
||||
this.fileDownloadProgress.set(
|
||||
fileID,
|
||||
Math.round((event.loaded * 100) / event.total)
|
||||
);
|
||||
}
|
||||
this.progressUpdater(new Map(this.fileDownloadProgress));
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default new CastDownloadManager();
|
12
apps/cast/src/services/events.ts
Normal file
12
apps/cast/src/services/events.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
// When registering event handlers,
|
||||
// handle errors to avoid unhandled rejection or propagation to emit call
|
||||
|
||||
export enum Events {
|
||||
LOGOUT = 'logout',
|
||||
FILE_UPLOADED = 'fileUploaded',
|
||||
LOCAL_FILES_UPDATED = 'localFilesUpdated',
|
||||
}
|
||||
|
||||
export const eventBus = new EventEmitter<Events>();
|
29
apps/cast/src/services/ffmpeg/ffmpegFactory.ts
Normal file
29
apps/cast/src/services/ffmpeg/ffmpegFactory.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
// import isElectron from 'is-electron';
|
||||
// import { ElectronFFmpeg } from 'services/electron/ffmpeg';
|
||||
import { ElectronFile } from 'types/upload';
|
||||
import ComlinkFFmpegWorker from 'utils/comlink/ComlinkFFmpegWorker';
|
||||
|
||||
export interface IFFmpeg {
|
||||
run: (
|
||||
cmd: string[],
|
||||
inputFile: File | ElectronFile,
|
||||
outputFilename: string,
|
||||
dontTimeout?: boolean
|
||||
) => Promise<File | ElectronFile>;
|
||||
}
|
||||
|
||||
class FFmpegFactory {
|
||||
private client: IFFmpeg;
|
||||
async getFFmpegClient() {
|
||||
if (!this.client) {
|
||||
// if (isElectron()) {
|
||||
// this.client = new ElectronFFmpeg();
|
||||
// } else {
|
||||
this.client = await ComlinkFFmpegWorker.getInstance();
|
||||
// }
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
|
||||
export default new FFmpegFactory();
|
30
apps/cast/src/services/ffmpeg/ffmpegService.ts
Normal file
30
apps/cast/src/services/ffmpeg/ffmpegService.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
FFMPEG_PLACEHOLDER,
|
||||
INPUT_PATH_PLACEHOLDER,
|
||||
OUTPUT_PATH_PLACEHOLDER,
|
||||
} from 'constants/ffmpeg';
|
||||
import { ElectronFile } from 'types/upload';
|
||||
import ffmpegFactory from './ffmpegFactory';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
|
||||
export async function convertToMP4(file: File | ElectronFile) {
|
||||
try {
|
||||
const ffmpegClient = await ffmpegFactory.getFFmpegClient();
|
||||
return await ffmpegClient.run(
|
||||
[
|
||||
FFMPEG_PLACEHOLDER,
|
||||
'-i',
|
||||
INPUT_PATH_PLACEHOLDER,
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
OUTPUT_PATH_PLACEHOLDER,
|
||||
],
|
||||
file,
|
||||
'output.mp4',
|
||||
true
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, 'ffmpeg convertToMP4 failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
14
apps/cast/src/services/heicConversionService.ts
Normal file
14
apps/cast/src/services/heicConversionService.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { logError } from '@ente/shared/sentry';
|
||||
import WasmHEICConverterService from './wasmHeicConverter/wasmHEICConverterService';
|
||||
|
||||
class HeicConversionService {
|
||||
async convert(heicFileData: Blob): Promise<Blob> {
|
||||
try {
|
||||
return await WasmHEICConverterService.convert(heicFileData);
|
||||
} catch (e) {
|
||||
logError(e, 'failed to convert heic file');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
export default new HeicConversionService();
|
45
apps/cast/src/services/livePhotoService.ts
Normal file
45
apps/cast/src/services/livePhotoService.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import JSZip from 'jszip';
|
||||
import { EnteFile } from 'types/file';
|
||||
import {
|
||||
getFileExtensionWithDot,
|
||||
getFileNameWithoutExtension,
|
||||
} from 'utils/file';
|
||||
|
||||
class LivePhoto {
|
||||
image: Uint8Array;
|
||||
video: Uint8Array;
|
||||
imageNameTitle: string;
|
||||
videoNameTitle: string;
|
||||
}
|
||||
|
||||
export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => {
|
||||
const originalName = getFileNameWithoutExtension(file.metadata.title);
|
||||
const zip = await JSZip.loadAsync(zipBlob, { createFolders: true });
|
||||
|
||||
const livePhoto = new LivePhoto();
|
||||
for (const zipFilename in zip.files) {
|
||||
if (zipFilename.startsWith('image')) {
|
||||
livePhoto.imageNameTitle =
|
||||
originalName + getFileExtensionWithDot(zipFilename);
|
||||
livePhoto.image = await zip.files[zipFilename].async('uint8array');
|
||||
} else if (zipFilename.startsWith('video')) {
|
||||
livePhoto.videoNameTitle =
|
||||
originalName + getFileExtensionWithDot(zipFilename);
|
||||
livePhoto.video = await zip.files[zipFilename].async('uint8array');
|
||||
}
|
||||
}
|
||||
return livePhoto;
|
||||
};
|
||||
|
||||
export const encodeLivePhoto = async (livePhoto: LivePhoto) => {
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
'image' + getFileExtensionWithDot(livePhoto.imageNameTitle),
|
||||
livePhoto.image
|
||||
);
|
||||
zip.file(
|
||||
'video' + getFileExtensionWithDot(livePhoto.videoNameTitle),
|
||||
livePhoto.video
|
||||
);
|
||||
return await zip.generateAsync({ type: 'uint8array' });
|
||||
};
|
86
apps/cast/src/services/queueProcessor.ts
Normal file
86
apps/cast/src/services/queueProcessor.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { CustomError } from '@ente/shared/error';
|
||||
|
||||
interface RequestQueueItem {
|
||||
request: (canceller?: RequestCanceller) => Promise<any>;
|
||||
successCallback: (response: any) => void;
|
||||
failureCallback: (error: Error) => void;
|
||||
isCanceled: { status: boolean };
|
||||
canceller: { exec: () => void };
|
||||
}
|
||||
|
||||
export enum PROCESSING_STRATEGY {
|
||||
FIFO,
|
||||
LIFO,
|
||||
}
|
||||
|
||||
export interface RequestCanceller {
|
||||
exec: () => void;
|
||||
}
|
||||
|
||||
export interface CancellationStatus {
|
||||
status: boolean;
|
||||
}
|
||||
|
||||
export default class QueueProcessor<T> {
|
||||
private requestQueue: RequestQueueItem[] = [];
|
||||
|
||||
private requestInProcessing = 0;
|
||||
|
||||
constructor(
|
||||
private maxParallelProcesses: number,
|
||||
private processingStrategy = PROCESSING_STRATEGY.FIFO
|
||||
) {}
|
||||
|
||||
public queueUpRequest(
|
||||
request: (canceller?: RequestCanceller) => Promise<T>
|
||||
) {
|
||||
const isCanceled: CancellationStatus = { status: false };
|
||||
const canceller: RequestCanceller = {
|
||||
exec: () => {
|
||||
isCanceled.status = true;
|
||||
},
|
||||
};
|
||||
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
this.requestQueue.push({
|
||||
request,
|
||||
successCallback: resolve,
|
||||
failureCallback: reject,
|
||||
isCanceled,
|
||||
canceller,
|
||||
});
|
||||
this.pollQueue();
|
||||
});
|
||||
|
||||
return { promise, canceller };
|
||||
}
|
||||
|
||||
private async pollQueue() {
|
||||
if (this.requestInProcessing < this.maxParallelProcesses) {
|
||||
this.requestInProcessing++;
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
while (this.requestQueue.length > 0) {
|
||||
const queueItem =
|
||||
this.processingStrategy === PROCESSING_STRATEGY.LIFO
|
||||
? this.requestQueue.pop()
|
||||
: this.requestQueue.shift();
|
||||
let response = null;
|
||||
|
||||
if (queueItem.isCanceled.status) {
|
||||
queueItem.failureCallback(Error(CustomError.REQUEST_CANCELLED));
|
||||
} else {
|
||||
try {
|
||||
response = await queueItem.request(queueItem.canceller);
|
||||
queueItem.successCallback(response);
|
||||
} catch (e) {
|
||||
queueItem.failureCallback(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.requestInProcessing--;
|
||||
}
|
||||
}
|
93
apps/cast/src/services/readerService.ts
Normal file
93
apps/cast/src/services/readerService.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { logError } from '@ente/shared/sentry';
|
||||
import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
|
||||
import { ElectronFile } from 'types/upload';
|
||||
|
||||
export async function getUint8ArrayView(
|
||||
file: Blob | ElectronFile
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
return new Uint8Array(await file.arrayBuffer());
|
||||
} catch (e) {
|
||||
logError(e, 'reading file blob failed', {
|
||||
fileSize: convertBytesToHumanReadable(file.size),
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileStream(file: File, chunkSize: number) {
|
||||
const fileChunkReader = fileChunkReaderMaker(file, chunkSize);
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller: ReadableStreamDefaultController) {
|
||||
const chunk = await fileChunkReader.next();
|
||||
if (chunk.done) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(chunk.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
const chunkCount = Math.ceil(file.size / chunkSize);
|
||||
return {
|
||||
stream,
|
||||
chunkCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getElectronFileStream(
|
||||
file: ElectronFile,
|
||||
chunkSize: number
|
||||
) {
|
||||
const chunkCount = Math.ceil(file.size / chunkSize);
|
||||
return {
|
||||
stream: await file.stream(),
|
||||
chunkCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function* fileChunkReaderMaker(file: File, chunkSize: number) {
|
||||
let offset = 0;
|
||||
while (offset < file.size) {
|
||||
const blob = file.slice(offset, chunkSize + offset);
|
||||
const fileChunk = await getUint8ArrayView(blob);
|
||||
yield fileChunk;
|
||||
offset += chunkSize;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// depreciated
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function getUint8ArrayViewOld(
|
||||
reader: FileReader,
|
||||
file: Blob
|
||||
): Promise<Uint8Array> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
reader.onabort = () =>
|
||||
reject(
|
||||
Error(
|
||||
`file reading was aborted, file size= ${convertBytesToHumanReadable(
|
||||
file.size
|
||||
)}`
|
||||
)
|
||||
);
|
||||
reader.onerror = () =>
|
||||
reject(
|
||||
Error(
|
||||
`file reading has failed, file size= ${convertBytesToHumanReadable(
|
||||
file.size
|
||||
)} , reason= ${reader.error}`
|
||||
)
|
||||
);
|
||||
reader.onload = () => {
|
||||
// Do whatever you want with the file contents
|
||||
const result =
|
||||
typeof reader.result === 'string'
|
||||
? new TextEncoder().encode(reader.result)
|
||||
: new Uint8Array(reader.result);
|
||||
resolve(result);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
108
apps/cast/src/services/typeDetectionService.ts
Normal file
108
apps/cast/src/services/typeDetectionService.ts
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { FILE_TYPE } from 'constants/file';
|
||||
import { ElectronFile, FileTypeInfo } from 'types/upload';
|
||||
import {
|
||||
WHITELISTED_FILE_FORMATS,
|
||||
KNOWN_NON_MEDIA_FORMATS,
|
||||
} from 'constants/upload';
|
||||
import { CustomError } from '@ente/shared/error';
|
||||
import { getFileExtension } from 'utils/file';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
import { getUint8ArrayView } from './readerService';
|
||||
import FileType, { FileTypeResult } from 'file-type';
|
||||
import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
|
||||
|
||||
function getFileSize(file: File | ElectronFile) {
|
||||
return file.size;
|
||||
}
|
||||
|
||||
const TYPE_VIDEO = 'video';
|
||||
const TYPE_IMAGE = 'image';
|
||||
const CHUNK_SIZE_FOR_TYPE_DETECTION = 4100;
|
||||
|
||||
export async function getFileType(
|
||||
receivedFile: File | ElectronFile
|
||||
): Promise<FileTypeInfo> {
|
||||
try {
|
||||
let fileType: FILE_TYPE;
|
||||
let typeResult: FileTypeResult;
|
||||
|
||||
if (receivedFile instanceof File) {
|
||||
typeResult = await extractFileType(receivedFile);
|
||||
} else {
|
||||
typeResult = await extractElectronFileType(receivedFile);
|
||||
}
|
||||
|
||||
const mimTypeParts: string[] = typeResult.mime?.split('/');
|
||||
|
||||
if (mimTypeParts?.length !== 2) {
|
||||
throw Error(CustomError.INVALID_MIME_TYPE(typeResult.mime));
|
||||
}
|
||||
switch (mimTypeParts[0]) {
|
||||
case TYPE_IMAGE:
|
||||
fileType = FILE_TYPE.IMAGE;
|
||||
break;
|
||||
case TYPE_VIDEO:
|
||||
fileType = FILE_TYPE.VIDEO;
|
||||
break;
|
||||
default:
|
||||
throw Error(CustomError.NON_MEDIA_FILE);
|
||||
}
|
||||
return {
|
||||
fileType,
|
||||
exactType: typeResult.ext,
|
||||
mimeType: typeResult.mime,
|
||||
};
|
||||
} catch (e) {
|
||||
const fileFormat = getFileExtension(receivedFile.name);
|
||||
const fileSize = convertBytesToHumanReadable(getFileSize(receivedFile));
|
||||
const whiteListedFormat = WHITELISTED_FILE_FORMATS.find(
|
||||
(a) => a.exactType === fileFormat
|
||||
);
|
||||
if (whiteListedFormat) {
|
||||
return whiteListedFormat;
|
||||
}
|
||||
if (KNOWN_NON_MEDIA_FORMATS.includes(fileFormat)) {
|
||||
throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
|
||||
}
|
||||
if (e.message === CustomError.NON_MEDIA_FILE) {
|
||||
logError(e, 'unsupported file format', {
|
||||
fileFormat,
|
||||
fileSize,
|
||||
});
|
||||
throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
|
||||
}
|
||||
logError(e, 'type detection failed', {
|
||||
fileFormat,
|
||||
fileSize,
|
||||
});
|
||||
throw Error(CustomError.TYPE_DETECTION_FAILED(fileFormat));
|
||||
}
|
||||
}
|
||||
|
||||
async function extractFileType(file: File) {
|
||||
const fileBlobChunk = file.slice(0, CHUNK_SIZE_FOR_TYPE_DETECTION);
|
||||
const fileDataChunk = await getUint8ArrayView(fileBlobChunk);
|
||||
return getFileTypeFromBuffer(fileDataChunk);
|
||||
}
|
||||
|
||||
async function extractElectronFileType(file: ElectronFile) {
|
||||
const stream = await file.stream();
|
||||
const reader = stream.getReader();
|
||||
const { value: fileDataChunk } = await reader.read();
|
||||
await reader.cancel();
|
||||
return getFileTypeFromBuffer(fileDataChunk);
|
||||
}
|
||||
|
||||
async function getFileTypeFromBuffer(buffer: Uint8Array) {
|
||||
const result = await FileType.fromBuffer(buffer);
|
||||
if (!result?.mime) {
|
||||
let logableInfo = '';
|
||||
try {
|
||||
logableInfo = `result: ${JSON.stringify(result)}`;
|
||||
} catch (e) {
|
||||
logableInfo = 'failed to stringify result';
|
||||
}
|
||||
throw Error(`mimetype missing from file type result - ${logableInfo}`);
|
||||
}
|
||||
return result;
|
||||
}
|
116
apps/cast/src/services/wasm/ffmpeg.ts
Normal file
116
apps/cast/src/services/wasm/ffmpeg.ts
Normal file
|
@ -0,0 +1,116 @@
|
|||
import { promiseWithTimeout } from '@ente/shared/promise';
|
||||
import { createFFmpeg, FFmpeg } from 'ffmpeg-wasm';
|
||||
import QueueProcessor from 'services/queueProcessor';
|
||||
import { getUint8ArrayView } from 'services/readerService';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
import { generateTempName } from 'utils/temp';
|
||||
import { addLogLine } from '@ente/shared/logging';
|
||||
|
||||
const INPUT_PATH_PLACEHOLDER = 'INPUT';
|
||||
const FFMPEG_PLACEHOLDER = 'FFMPEG';
|
||||
const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
|
||||
|
||||
const FFMPEG_EXECUTION_WAIT_TIME = 30 * 1000;
|
||||
|
||||
export class WasmFFmpeg {
|
||||
private ffmpeg: FFmpeg;
|
||||
private ready: Promise<void> = null;
|
||||
private ffmpegTaskQueue = new QueueProcessor<File>(1);
|
||||
|
||||
constructor() {
|
||||
this.ffmpeg = createFFmpeg({
|
||||
corePath: '/js/ffmpeg/ffmpeg-core.js',
|
||||
mt: false,
|
||||
});
|
||||
|
||||
this.ready = this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
if (!this.ffmpeg.isLoaded()) {
|
||||
await this.ffmpeg.load();
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
cmd: string[],
|
||||
inputFile: File,
|
||||
outputFileName: string,
|
||||
dontTimeout = false
|
||||
) {
|
||||
const response = this.ffmpegTaskQueue.queueUpRequest(() => {
|
||||
if (dontTimeout) {
|
||||
return this.execute(cmd, inputFile, outputFileName);
|
||||
} else {
|
||||
return promiseWithTimeout<File>(
|
||||
this.execute(cmd, inputFile, outputFileName),
|
||||
FFMPEG_EXECUTION_WAIT_TIME
|
||||
);
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await response.promise;
|
||||
} catch (e) {
|
||||
logError(e, 'ffmpeg run failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async execute(
|
||||
cmd: string[],
|
||||
inputFile: File,
|
||||
outputFileName: string
|
||||
) {
|
||||
let tempInputFilePath: string;
|
||||
let tempOutputFilePath: string;
|
||||
try {
|
||||
await this.ready;
|
||||
const extension = getFileExtension(inputFile.name);
|
||||
const tempNameSuffix = extension ? `input.${extension}` : 'input';
|
||||
tempInputFilePath = `${generateTempName(10, tempNameSuffix)}`;
|
||||
this.ffmpeg.FS(
|
||||
'writeFile',
|
||||
tempInputFilePath,
|
||||
await getUint8ArrayView(inputFile)
|
||||
);
|
||||
tempOutputFilePath = `${generateTempName(10, outputFileName)}`;
|
||||
|
||||
cmd = cmd.map((cmdPart) => {
|
||||
if (cmdPart === FFMPEG_PLACEHOLDER) {
|
||||
return '';
|
||||
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
|
||||
return tempInputFilePath;
|
||||
} else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
|
||||
return tempOutputFilePath;
|
||||
} else {
|
||||
return cmdPart;
|
||||
}
|
||||
});
|
||||
addLogLine(`${cmd}`);
|
||||
await this.ffmpeg.run(...cmd);
|
||||
return new File(
|
||||
[this.ffmpeg.FS('readFile', tempOutputFilePath)],
|
||||
outputFileName
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
this.ffmpeg.FS('unlink', tempInputFilePath);
|
||||
} catch (e) {
|
||||
logError(e, 'unlink input file failed');
|
||||
}
|
||||
try {
|
||||
this.ffmpeg.FS('unlink', tempOutputFilePath);
|
||||
} catch (e) {
|
||||
logError(e, 'unlink output file failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFileExtension(filename: string) {
|
||||
const lastDotPosition = filename.lastIndexOf('.');
|
||||
if (lastDotPosition === -1) return null;
|
||||
else {
|
||||
return filename.slice(lastDotPosition + 1);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import * as HeicConvert from 'heic-convert';
|
||||
import { getUint8ArrayView } from 'services/readerService';
|
||||
|
||||
export async function convertHEIC(
|
||||
fileBlob: Blob,
|
||||
format: string
|
||||
): Promise<Blob> {
|
||||
const filedata = await getUint8ArrayView(fileBlob);
|
||||
const result = await HeicConvert({ buffer: filedata, format });
|
||||
const convertedFileData = new Uint8Array(result);
|
||||
const convertedFileBlob = new Blob([convertedFileData]);
|
||||
return convertedFileBlob;
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
import QueueProcessor from 'services/queueProcessor';
|
||||
import { retryAsyncFunction } from 'utils/network';
|
||||
import { DedicatedConvertWorker } from 'worker/convert.worker';
|
||||
import { ComlinkWorker } from 'utils/comlink/comlinkWorker';
|
||||
import { getDedicatedConvertWorker } from 'utils/comlink/ComlinkConvertWorker';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
import { addLogLine } from '@ente/shared/logging';
|
||||
import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
|
||||
import { CustomError } from '@ente/shared/error';
|
||||
|
||||
const WORKER_POOL_SIZE = 2;
|
||||
const MAX_CONVERSION_IN_PARALLEL = 1;
|
||||
const WAIT_TIME_BEFORE_NEXT_ATTEMPT_IN_MICROSECONDS = [100, 100];
|
||||
const WAIT_TIME_IN_MICROSECONDS = 30 * 1000;
|
||||
const BREATH_TIME_IN_MICROSECONDS = 1000;
|
||||
const CONVERT_FORMAT = 'JPEG';
|
||||
|
||||
class HEICConverter {
|
||||
private convertProcessor = new QueueProcessor<Blob>(
|
||||
MAX_CONVERSION_IN_PARALLEL
|
||||
);
|
||||
private workerPool: ComlinkWorker<typeof DedicatedConvertWorker>[] = [];
|
||||
private ready: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this.ready = this.init();
|
||||
}
|
||||
private async init() {
|
||||
this.workerPool = [];
|
||||
for (let i = 0; i < WORKER_POOL_SIZE; i++) {
|
||||
this.workerPool.push(getDedicatedConvertWorker());
|
||||
}
|
||||
}
|
||||
async convert(fileBlob: Blob): Promise<Blob> {
|
||||
await this.ready;
|
||||
const response = this.convertProcessor.queueUpRequest(() =>
|
||||
retryAsyncFunction<Blob>(async () => {
|
||||
const convertWorker = this.workerPool.shift();
|
||||
const worker = await convertWorker.remote;
|
||||
try {
|
||||
const convertedHEIC = await new Promise<Blob>(
|
||||
(resolve, reject) => {
|
||||
const main = async () => {
|
||||
try {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(Error('wait time exceeded'));
|
||||
}, WAIT_TIME_IN_MICROSECONDS);
|
||||
const startTime = Date.now();
|
||||
const convertedHEIC =
|
||||
await worker.convertHEIC(
|
||||
fileBlob,
|
||||
CONVERT_FORMAT
|
||||
);
|
||||
addLogLine(
|
||||
`originalFileSize:${convertBytesToHumanReadable(
|
||||
fileBlob?.size
|
||||
)},convertedFileSize:${convertBytesToHumanReadable(
|
||||
convertedHEIC?.size
|
||||
)}, heic conversion time: ${
|
||||
Date.now() - startTime
|
||||
}ms `
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
resolve(convertedHEIC);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
main();
|
||||
}
|
||||
);
|
||||
if (!convertedHEIC || convertedHEIC?.size === 0) {
|
||||
logError(
|
||||
Error(`converted heic fileSize is Zero`),
|
||||
'converted heic fileSize is Zero',
|
||||
{
|
||||
originalFileSize: convertBytesToHumanReadable(
|
||||
fileBlob?.size ?? 0
|
||||
),
|
||||
convertedFileSize: convertBytesToHumanReadable(
|
||||
convertedHEIC?.size ?? 0
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() => resolve(null),
|
||||
BREATH_TIME_IN_MICROSECONDS
|
||||
);
|
||||
});
|
||||
this.workerPool.push(convertWorker);
|
||||
return convertedHEIC;
|
||||
} catch (e) {
|
||||
logError(e, 'heic conversion failed');
|
||||
convertWorker.terminate();
|
||||
this.workerPool.push(getDedicatedConvertWorker());
|
||||
throw e;
|
||||
}
|
||||
}, WAIT_TIME_BEFORE_NEXT_ATTEMPT_IN_MICROSECONDS)
|
||||
);
|
||||
try {
|
||||
return await response.promise;
|
||||
} catch (e) {
|
||||
if (e.message === CustomError.REQUEST_CANCELLED) {
|
||||
// ignore
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new HEICConverter();
|
3
apps/cast/src/styles/global.css
Normal file
3
apps/cast/src/styles/global.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
#__next {
|
||||
height: 100%;
|
||||
}
|
20
apps/cast/src/types/cache/index.ts
vendored
Normal file
20
apps/cast/src/types/cache/index.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
export interface LimitedCacheStorage {
|
||||
open: (cacheName: string) => Promise<LimitedCache>;
|
||||
delete: (cacheName: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface LimitedCache {
|
||||
match: (key: string) => Promise<Response>;
|
||||
put: (key: string, data: Response) => Promise<void>;
|
||||
delete: (key: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ProxiedLimitedCacheStorage {
|
||||
open: (cacheName: string) => Promise<ProxiedWorkerLimitedCache>;
|
||||
delete: (cacheName: string) => Promise<boolean>;
|
||||
}
|
||||
export interface ProxiedWorkerLimitedCache {
|
||||
match: (key: string) => Promise<ArrayBuffer>;
|
||||
put: (key: string, data: ArrayBuffer) => Promise<void>;
|
||||
delete: (key: string) => Promise<boolean>;
|
||||
}
|
5
apps/cast/src/types/cast/index.ts
Normal file
5
apps/cast/src/types/cast/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface CastPayload {
|
||||
collectionID: number;
|
||||
collectionKey: string;
|
||||
castToken: string;
|
||||
}
|
159
apps/cast/src/types/collection/index.ts
Normal file
159
apps/cast/src/types/collection/index.ts
Normal file
|
@ -0,0 +1,159 @@
|
|||
import { EnteFile } from 'types/file';
|
||||
import { CollectionSummaryType, CollectionType } from 'constants/collection';
|
||||
import {
|
||||
EncryptedMagicMetadata,
|
||||
MagicMetadataCore,
|
||||
SUB_TYPE,
|
||||
VISIBILITY_STATE,
|
||||
} from 'types/magicMetadata';
|
||||
|
||||
export enum COLLECTION_ROLE {
|
||||
VIEWER = 'VIEWER',
|
||||
OWNER = 'OWNER',
|
||||
COLLABORATOR = 'COLLABORATOR',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export interface CollectionUser {
|
||||
id: number;
|
||||
email: string;
|
||||
role: COLLECTION_ROLE;
|
||||
}
|
||||
|
||||
export interface EncryptedCollection {
|
||||
id: number;
|
||||
owner: CollectionUser;
|
||||
// collection name was unencrypted in the past, so we need to keep it as optional
|
||||
name?: string;
|
||||
encryptedKey: string;
|
||||
keyDecryptionNonce: string;
|
||||
encryptedName: string;
|
||||
nameDecryptionNonce: string;
|
||||
type: CollectionType;
|
||||
attributes: collectionAttributes;
|
||||
sharees: CollectionUser[];
|
||||
publicURLs?: PublicURL[];
|
||||
updationTime: number;
|
||||
isDeleted: boolean;
|
||||
magicMetadata: EncryptedMagicMetadata;
|
||||
pubMagicMetadata: EncryptedMagicMetadata;
|
||||
sharedMagicMetadata: EncryptedMagicMetadata;
|
||||
}
|
||||
|
||||
export interface Collection
|
||||
extends Omit<
|
||||
EncryptedCollection,
|
||||
| 'encryptedKey'
|
||||
| 'keyDecryptionNonce'
|
||||
| 'encryptedName'
|
||||
| 'nameDecryptionNonce'
|
||||
| 'magicMetadata'
|
||||
| 'pubMagicMetadata'
|
||||
| 'sharedMagicMetadata'
|
||||
> {
|
||||
key: string;
|
||||
name: string;
|
||||
magicMetadata: CollectionMagicMetadata;
|
||||
pubMagicMetadata: CollectionPublicMagicMetadata;
|
||||
sharedMagicMetadata: CollectionShareeMagicMetadata;
|
||||
}
|
||||
|
||||
// define a method on Collection interface to return the sync key as collection.id-time
|
||||
// this is used to store the last sync time of a collection in local storage
|
||||
|
||||
export interface PublicURL {
|
||||
url: string;
|
||||
deviceLimit: number;
|
||||
validTill: number;
|
||||
enableDownload: boolean;
|
||||
enableCollect: boolean;
|
||||
passwordEnabled: boolean;
|
||||
nonce?: string;
|
||||
opsLimit?: number;
|
||||
memLimit?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePublicURL {
|
||||
collectionID: number;
|
||||
disablePassword?: boolean;
|
||||
enableDownload?: boolean;
|
||||
enableCollect?: boolean;
|
||||
validTill?: number;
|
||||
deviceLimit?: number;
|
||||
passHash?: string;
|
||||
nonce?: string;
|
||||
opsLimit?: number;
|
||||
memLimit?: number;
|
||||
}
|
||||
|
||||
export interface CreatePublicAccessTokenRequest {
|
||||
collectionID: number;
|
||||
validTill?: number;
|
||||
deviceLimit?: number;
|
||||
}
|
||||
|
||||
export interface EncryptedFileKey {
|
||||
id: number;
|
||||
encryptedKey: string;
|
||||
keyDecryptionNonce: string;
|
||||
}
|
||||
|
||||
export interface AddToCollectionRequest {
|
||||
collectionID: number;
|
||||
files: EncryptedFileKey[];
|
||||
}
|
||||
|
||||
export interface MoveToCollectionRequest {
|
||||
fromCollectionID: number;
|
||||
toCollectionID: number;
|
||||
files: EncryptedFileKey[];
|
||||
}
|
||||
|
||||
export interface collectionAttributes {
|
||||
encryptedPath?: string;
|
||||
pathDecryptionNonce?: string;
|
||||
}
|
||||
|
||||
export type CollectionToFileMap = Map<number, EnteFile>;
|
||||
|
||||
export interface RemoveFromCollectionRequest {
|
||||
collectionID: number;
|
||||
fileIDs: number[];
|
||||
}
|
||||
|
||||
export interface CollectionMagicMetadataProps {
|
||||
visibility?: VISIBILITY_STATE;
|
||||
subType?: SUB_TYPE;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type CollectionMagicMetadata =
|
||||
MagicMetadataCore<CollectionMagicMetadataProps>;
|
||||
|
||||
export interface CollectionShareeMetadataProps {
|
||||
visibility?: VISIBILITY_STATE;
|
||||
}
|
||||
export type CollectionShareeMagicMetadata =
|
||||
MagicMetadataCore<CollectionShareeMetadataProps>;
|
||||
|
||||
export interface CollectionPublicMagicMetadataProps {
|
||||
asc?: boolean;
|
||||
coverID?: number;
|
||||
}
|
||||
|
||||
export type CollectionPublicMagicMetadata =
|
||||
MagicMetadataCore<CollectionPublicMagicMetadataProps>;
|
||||
|
||||
export interface CollectionSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CollectionSummaryType;
|
||||
coverFile: EnteFile;
|
||||
latestFile: EnteFile;
|
||||
fileCount: number;
|
||||
updationTime: number;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type CollectionSummaries = Map<number, CollectionSummary>;
|
||||
export type CollectionFilesCount = Map<number, number>;
|
103
apps/cast/src/types/file/index.ts
Normal file
103
apps/cast/src/types/file/index.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
import {
|
||||
EncryptedMagicMetadata,
|
||||
MagicMetadataCore,
|
||||
VISIBILITY_STATE,
|
||||
} from 'types/magicMetadata';
|
||||
import { Metadata } from 'types/upload';
|
||||
|
||||
export interface MetadataFileAttributes {
|
||||
encryptedData: string;
|
||||
decryptionHeader: string;
|
||||
}
|
||||
export interface S3FileAttributes {
|
||||
objectKey: string;
|
||||
decryptionHeader: string;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
fileSize: number;
|
||||
thumbSize: number;
|
||||
}
|
||||
|
||||
export interface EncryptedEnteFile {
|
||||
id: number;
|
||||
collectionID: number;
|
||||
ownerID: number;
|
||||
file: S3FileAttributes;
|
||||
thumbnail: S3FileAttributes;
|
||||
metadata: MetadataFileAttributes;
|
||||
info: FileInfo;
|
||||
magicMetadata: EncryptedMagicMetadata;
|
||||
pubMagicMetadata: EncryptedMagicMetadata;
|
||||
encryptedKey: string;
|
||||
keyDecryptionNonce: string;
|
||||
isDeleted: boolean;
|
||||
updationTime: number;
|
||||
}
|
||||
|
||||
export interface EnteFile
|
||||
extends Omit<
|
||||
EncryptedEnteFile,
|
||||
| 'metadata'
|
||||
| 'pubMagicMetadata'
|
||||
| 'magicMetadata'
|
||||
| 'encryptedKey'
|
||||
| 'keyDecryptionNonce'
|
||||
> {
|
||||
metadata: Metadata;
|
||||
magicMetadata: FileMagicMetadata;
|
||||
pubMagicMetadata: FilePublicMagicMetadata;
|
||||
isTrashed?: boolean;
|
||||
key: string;
|
||||
src?: string;
|
||||
msrc?: string;
|
||||
html?: string;
|
||||
w?: number;
|
||||
h?: number;
|
||||
title?: string;
|
||||
deleteBy?: number;
|
||||
isSourceLoaded?: boolean;
|
||||
originalVideoURL?: string;
|
||||
originalImageURL?: string;
|
||||
dataIndex?: number;
|
||||
conversionFailed?: boolean;
|
||||
isConverted?: boolean;
|
||||
}
|
||||
|
||||
export interface TrashRequest {
|
||||
items: TrashRequestItems[];
|
||||
}
|
||||
|
||||
export interface TrashRequestItems {
|
||||
fileID: number;
|
||||
collectionID: number;
|
||||
}
|
||||
|
||||
export interface FileWithUpdatedMagicMetadata {
|
||||
file: EnteFile;
|
||||
updatedMagicMetadata: FileMagicMetadata;
|
||||
}
|
||||
|
||||
export interface FileWithUpdatedPublicMagicMetadata {
|
||||
file: EnteFile;
|
||||
updatedPublicMagicMetadata: FilePublicMagicMetadata;
|
||||
}
|
||||
|
||||
export interface FileMagicMetadataProps {
|
||||
visibility?: VISIBILITY_STATE;
|
||||
filePaths?: string[];
|
||||
}
|
||||
|
||||
export type FileMagicMetadata = MagicMetadataCore<FileMagicMetadataProps>;
|
||||
|
||||
export interface FilePublicMagicMetadataProps {
|
||||
editedTime?: number;
|
||||
editedName?: string;
|
||||
caption?: string;
|
||||
uploaderName?: string;
|
||||
w?: number;
|
||||
h?: number;
|
||||
}
|
||||
|
||||
export type FilePublicMagicMetadata =
|
||||
MagicMetadataCore<FilePublicMagicMetadataProps>;
|
57
apps/cast/src/types/gallery/index.ts
Normal file
57
apps/cast/src/types/gallery/index.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
// import { CollectionDownloadProgressAttributes } from 'components/Collections/CollectionDownloadProgress';
|
||||
// import { CollectionSelectorAttributes } from 'components/Collections/CollectionSelector';
|
||||
// import { TimeStampListItem } from 'components/PhotoList';
|
||||
import { User } from '@ente/shared/user/types';
|
||||
import { Collection } from 'types/collection';
|
||||
import { EnteFile } from 'types/file';
|
||||
|
||||
export type SelectedState = {
|
||||
[k: number]: boolean;
|
||||
ownCount: number;
|
||||
count: number;
|
||||
collectionID: number;
|
||||
};
|
||||
export type SetFiles = React.Dispatch<React.SetStateAction<EnteFile[]>>;
|
||||
export type SetCollections = React.Dispatch<React.SetStateAction<Collection[]>>;
|
||||
export type SetLoading = React.Dispatch<React.SetStateAction<boolean>>;
|
||||
// export type SetCollectionSelectorAttributes = React.Dispatch<
|
||||
// React.SetStateAction<CollectionSelectorAttributes>
|
||||
// >;
|
||||
// export type SetCollectionDownloadProgressAttributes = React.Dispatch<
|
||||
// React.SetStateAction<CollectionDownloadProgressAttributes>
|
||||
// >;
|
||||
|
||||
export type MergedSourceURL = {
|
||||
original: string;
|
||||
converted: string;
|
||||
};
|
||||
export enum UploadTypeSelectorIntent {
|
||||
normalUpload,
|
||||
import,
|
||||
collectPhotos,
|
||||
}
|
||||
export type GalleryContextType = {
|
||||
thumbs: Map<number, string>;
|
||||
files: Map<number, MergedSourceURL>;
|
||||
showPlanSelectorModal: () => void;
|
||||
setActiveCollectionID: (collectionID: number) => void;
|
||||
syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
|
||||
setBlockingLoad: (value: boolean) => void;
|
||||
setIsInSearchMode: (value: boolean) => void;
|
||||
// photoListHeader: TimeStampListItem;
|
||||
openExportModal: () => void;
|
||||
authenticateUser: (callback: () => void) => void;
|
||||
user: User;
|
||||
userIDToEmailMap: Map<number, string>;
|
||||
emailList: string[];
|
||||
openHiddenSection: (callback?: () => void) => void;
|
||||
isClipSearchResult: boolean;
|
||||
};
|
||||
|
||||
export enum CollectionSelectorIntent {
|
||||
upload,
|
||||
add,
|
||||
move,
|
||||
restore,
|
||||
unhide,
|
||||
}
|
29
apps/cast/src/types/magicMetadata/index.ts
Normal file
29
apps/cast/src/types/magicMetadata/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
export interface MagicMetadataCore<T> {
|
||||
version: number;
|
||||
count: number;
|
||||
header: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export type EncryptedMagicMetadata = MagicMetadataCore<string>;
|
||||
|
||||
export enum VISIBILITY_STATE {
|
||||
VISIBLE = 0,
|
||||
ARCHIVED = 1,
|
||||
HIDDEN = 2,
|
||||
}
|
||||
|
||||
export enum SUB_TYPE {
|
||||
DEFAULT = 0,
|
||||
DEFAULT_HIDDEN = 1,
|
||||
QUICK_LINK_COLLECTION = 2,
|
||||
}
|
||||
|
||||
export interface BulkUpdateMagicMetadataRequest {
|
||||
metadataList: UpdateMagicMetadataRequest[];
|
||||
}
|
||||
|
||||
export interface UpdateMagicMetadataRequest {
|
||||
id: number;
|
||||
magicMetadata: EncryptedMagicMetadata;
|
||||
}
|
170
apps/cast/src/types/upload/index.ts
Normal file
170
apps/cast/src/types/upload/index.ts
Normal file
|
@ -0,0 +1,170 @@
|
|||
import {
|
||||
B64EncryptionResult,
|
||||
LocalFileAttributes,
|
||||
} from '@ente/shared/crypto/types';
|
||||
import { FILE_TYPE } from 'constants/file';
|
||||
import { Collection } from 'types/collection';
|
||||
import {
|
||||
MetadataFileAttributes,
|
||||
S3FileAttributes,
|
||||
FilePublicMagicMetadata,
|
||||
FilePublicMagicMetadataProps,
|
||||
} from 'types/file';
|
||||
import { EncryptedMagicMetadata } from 'types/magicMetadata';
|
||||
|
||||
export interface DataStream {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
chunkCount: number;
|
||||
}
|
||||
|
||||
export function isDataStream(object: any): object is DataStream {
|
||||
return 'stream' in object;
|
||||
}
|
||||
|
||||
export type Logger = (message: string) => void;
|
||||
|
||||
export interface Metadata {
|
||||
title: string;
|
||||
creationTime: number;
|
||||
modificationTime: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
fileType: FILE_TYPE;
|
||||
hasStaticThumbnail?: boolean;
|
||||
hash?: string;
|
||||
imageHash?: string;
|
||||
videoHash?: string;
|
||||
localID?: number;
|
||||
version?: number;
|
||||
deviceFolder?: string;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface ParsedMetadataJSON {
|
||||
creationTime: number;
|
||||
modificationTime: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface MultipartUploadURLs {
|
||||
objectKey: string;
|
||||
partURLs: string[];
|
||||
completeURL: string;
|
||||
}
|
||||
|
||||
export interface FileTypeInfo {
|
||||
fileType: FILE_TYPE;
|
||||
exactType: string;
|
||||
mimeType?: string;
|
||||
imageType?: string;
|
||||
videoType?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* ElectronFile is a custom interface that is used to represent
|
||||
* any file on disk as a File-like object in the Electron desktop app.
|
||||
*
|
||||
* This was added to support the auto-resuming of failed uploads
|
||||
* which needed absolute paths to the files which the
|
||||
* normal File interface does not provide.
|
||||
*/
|
||||
export interface ElectronFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
stream: () => Promise<ReadableStream<Uint8Array>>;
|
||||
blob: () => Promise<Blob>;
|
||||
arrayBuffer: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface UploadAsset {
|
||||
isLivePhoto?: boolean;
|
||||
file?: File | ElectronFile;
|
||||
livePhotoAssets?: LivePhotoAssets;
|
||||
isElectron?: boolean;
|
||||
}
|
||||
export interface LivePhotoAssets {
|
||||
image: globalThis.File | ElectronFile;
|
||||
video: globalThis.File | ElectronFile;
|
||||
}
|
||||
|
||||
export interface FileWithCollection extends UploadAsset {
|
||||
localID: number;
|
||||
collection?: Collection;
|
||||
collectionID?: number;
|
||||
}
|
||||
|
||||
export type ParsedMetadataJSONMap = Map<string, ParsedMetadataJSON>;
|
||||
|
||||
export interface UploadURL {
|
||||
url: string;
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
export interface FileInMemory {
|
||||
filedata: Uint8Array | DataStream;
|
||||
thumbnail: Uint8Array;
|
||||
hasStaticThumbnail: boolean;
|
||||
}
|
||||
|
||||
export interface FileWithMetadata
|
||||
extends Omit<FileInMemory, 'hasStaticThumbnail'> {
|
||||
metadata: Metadata;
|
||||
localID: number;
|
||||
pubMagicMetadata: FilePublicMagicMetadata;
|
||||
}
|
||||
|
||||
export interface EncryptedFile {
|
||||
file: ProcessedFile;
|
||||
fileKey: B64EncryptionResult;
|
||||
}
|
||||
export interface ProcessedFile {
|
||||
file: LocalFileAttributes<Uint8Array | DataStream>;
|
||||
thumbnail: LocalFileAttributes<Uint8Array>;
|
||||
metadata: LocalFileAttributes<string>;
|
||||
pubMagicMetadata: EncryptedMagicMetadata;
|
||||
localID: number;
|
||||
}
|
||||
export interface BackupedFile {
|
||||
file: S3FileAttributes;
|
||||
thumbnail: S3FileAttributes;
|
||||
metadata: MetadataFileAttributes;
|
||||
pubMagicMetadata: EncryptedMagicMetadata;
|
||||
}
|
||||
|
||||
export interface UploadFile extends BackupedFile {
|
||||
collectionID: number;
|
||||
encryptedKey: string;
|
||||
keyDecryptionNonce: string;
|
||||
}
|
||||
|
||||
export interface ParsedExtractedMetadata {
|
||||
location: Location;
|
||||
creationTime: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// This is used to prompt the user the make upload strategy choice
|
||||
export interface ImportSuggestion {
|
||||
rootFolderName: string;
|
||||
hasNestedFolders: boolean;
|
||||
hasRootLevelFileWithFolder: boolean;
|
||||
}
|
||||
|
||||
export interface PublicUploadProps {
|
||||
token: string;
|
||||
passwordToken: string;
|
||||
accessedThroughSharedURL: boolean;
|
||||
}
|
||||
|
||||
export interface ExtractMetadataResult {
|
||||
metadata: Metadata;
|
||||
publicMagicMetadata: FilePublicMagicMetadataProps;
|
||||
}
|
43
apps/cast/src/types/upload/ui.ts
Normal file
43
apps/cast/src/types/upload/ui.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { UPLOAD_RESULT, UPLOAD_STAGES } from 'constants/upload';
|
||||
|
||||
export type FileID = number;
|
||||
export type FileName = string;
|
||||
|
||||
export type PercentageUploaded = number;
|
||||
export type UploadFileNames = Map<FileID, FileName>;
|
||||
|
||||
export interface UploadCounter {
|
||||
finished: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface InProgressUpload {
|
||||
localFileID: FileID;
|
||||
progress: PercentageUploaded;
|
||||
}
|
||||
|
||||
export interface FinishedUpload {
|
||||
localFileID: FileID;
|
||||
result: UPLOAD_RESULT;
|
||||
}
|
||||
|
||||
export type InProgressUploads = Map<FileID, PercentageUploaded>;
|
||||
|
||||
export type FinishedUploads = Map<FileID, UPLOAD_RESULT>;
|
||||
|
||||
export type SegregatedFinishedUploads = Map<UPLOAD_RESULT, FileID[]>;
|
||||
|
||||
export interface ProgressUpdater {
|
||||
setPercentComplete: React.Dispatch<React.SetStateAction<number>>;
|
||||
setUploadCounter: React.Dispatch<React.SetStateAction<UploadCounter>>;
|
||||
setUploadStage: React.Dispatch<React.SetStateAction<UPLOAD_STAGES>>;
|
||||
setInProgressUploads: React.Dispatch<
|
||||
React.SetStateAction<InProgressUpload[]>
|
||||
>;
|
||||
setFinishedUploads: React.Dispatch<
|
||||
React.SetStateAction<SegregatedFinishedUploads>
|
||||
>;
|
||||
setUploadFilenames: React.Dispatch<React.SetStateAction<UploadFileNames>>;
|
||||
setHasLivePhotos: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setUploadProgressView: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
147
apps/cast/src/utils/collection/index.ts
Normal file
147
apps/cast/src/utils/collection/index.ts
Normal file
|
@ -0,0 +1,147 @@
|
|||
import { COLLECTION_ROLE, Collection } from 'types/collection';
|
||||
import {
|
||||
CollectionSummaryType,
|
||||
CollectionType,
|
||||
HIDE_FROM_COLLECTION_BAR_TYPES,
|
||||
OPTIONS_NOT_HAVING_COLLECTION_TYPES,
|
||||
} from 'constants/collection';
|
||||
import { SUB_TYPE, VISIBILITY_STATE } from 'types/magicMetadata';
|
||||
import { User } from '@ente/shared/user/types';
|
||||
import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
|
||||
|
||||
export enum COLLECTION_OPS_TYPE {
|
||||
ADD,
|
||||
MOVE,
|
||||
REMOVE,
|
||||
RESTORE,
|
||||
UNHIDE,
|
||||
}
|
||||
|
||||
export function getSelectedCollection(
|
||||
collectionID: number,
|
||||
collections: Collection[]
|
||||
) {
|
||||
return collections.find((collection) => collection.id === collectionID);
|
||||
}
|
||||
|
||||
export const shouldShowOptions = (type: CollectionSummaryType) => {
|
||||
return !OPTIONS_NOT_HAVING_COLLECTION_TYPES.has(type);
|
||||
};
|
||||
export const showEmptyTrashQuickOption = (type: CollectionSummaryType) => {
|
||||
return type === CollectionSummaryType.trash;
|
||||
};
|
||||
export const showDownloadQuickOption = (type: CollectionSummaryType) => {
|
||||
return (
|
||||
type === CollectionSummaryType.folder ||
|
||||
type === CollectionSummaryType.favorites ||
|
||||
type === CollectionSummaryType.album ||
|
||||
type === CollectionSummaryType.uncategorized ||
|
||||
type === CollectionSummaryType.hiddenItems ||
|
||||
type === CollectionSummaryType.incomingShareViewer ||
|
||||
type === CollectionSummaryType.incomingShareCollaborator ||
|
||||
type === CollectionSummaryType.outgoingShare ||
|
||||
type === CollectionSummaryType.sharedOnlyViaLink ||
|
||||
type === CollectionSummaryType.archived ||
|
||||
type === CollectionSummaryType.pinned
|
||||
);
|
||||
};
|
||||
export const showShareQuickOption = (type: CollectionSummaryType) => {
|
||||
return (
|
||||
type === CollectionSummaryType.folder ||
|
||||
type === CollectionSummaryType.album ||
|
||||
type === CollectionSummaryType.outgoingShare ||
|
||||
type === CollectionSummaryType.sharedOnlyViaLink ||
|
||||
type === CollectionSummaryType.archived ||
|
||||
type === CollectionSummaryType.incomingShareViewer ||
|
||||
type === CollectionSummaryType.incomingShareCollaborator ||
|
||||
type === CollectionSummaryType.pinned
|
||||
);
|
||||
};
|
||||
export const shouldBeShownOnCollectionBar = (type: CollectionSummaryType) => {
|
||||
return !HIDE_FROM_COLLECTION_BAR_TYPES.has(type);
|
||||
};
|
||||
|
||||
export const getUserOwnedCollections = (collections: Collection[]) => {
|
||||
const user: User = getData(LS_KEYS.USER);
|
||||
if (!user?.id) {
|
||||
throw Error('user missing');
|
||||
}
|
||||
return collections.filter((collection) => collection.owner.id === user.id);
|
||||
};
|
||||
|
||||
export const isDefaultHiddenCollection = (collection: Collection) =>
|
||||
collection.magicMetadata?.data.subType === SUB_TYPE.DEFAULT_HIDDEN;
|
||||
|
||||
export const isHiddenCollection = (collection: Collection) =>
|
||||
collection.magicMetadata?.data.visibility === VISIBILITY_STATE.HIDDEN;
|
||||
|
||||
export const isQuickLinkCollection = (collection: Collection) =>
|
||||
collection.magicMetadata?.data.subType === SUB_TYPE.QUICK_LINK_COLLECTION;
|
||||
|
||||
export function isOutgoingShare(collection: Collection, user: User): boolean {
|
||||
return collection.owner.id === user.id && collection.sharees?.length > 0;
|
||||
}
|
||||
|
||||
export function isIncomingShare(collection: Collection, user: User) {
|
||||
return collection.owner.id !== user.id;
|
||||
}
|
||||
|
||||
export function isIncomingViewerShare(collection: Collection, user: User) {
|
||||
const sharee = collection.sharees?.find((sharee) => sharee.id === user.id);
|
||||
return sharee?.role === COLLECTION_ROLE.VIEWER;
|
||||
}
|
||||
|
||||
export function isIncomingCollabShare(collection: Collection, user: User) {
|
||||
const sharee = collection.sharees?.find((sharee) => sharee.id === user.id);
|
||||
return sharee?.role === COLLECTION_ROLE.COLLABORATOR;
|
||||
}
|
||||
|
||||
export function isSharedOnlyViaLink(collection: Collection) {
|
||||
return collection.publicURLs?.length && !collection.sharees?.length;
|
||||
}
|
||||
|
||||
export function isValidMoveTarget(
|
||||
sourceCollectionID: number,
|
||||
targetCollection: Collection,
|
||||
user: User
|
||||
) {
|
||||
return (
|
||||
sourceCollectionID !== targetCollection.id &&
|
||||
!isHiddenCollection(targetCollection) &&
|
||||
!isQuickLinkCollection(targetCollection) &&
|
||||
!isIncomingShare(targetCollection, user)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidReplacementAlbum(
|
||||
collection: Collection,
|
||||
user: User,
|
||||
wantedCollectionName: string
|
||||
) {
|
||||
return (
|
||||
collection.name === wantedCollectionName &&
|
||||
(collection.type === CollectionType.album ||
|
||||
collection.type === CollectionType.folder) &&
|
||||
!isHiddenCollection(collection) &&
|
||||
!isQuickLinkCollection(collection) &&
|
||||
!isIncomingShare(collection, user)
|
||||
);
|
||||
}
|
||||
|
||||
export function getCollectionNameMap(
|
||||
collections: Collection[]
|
||||
): Map<number, string> {
|
||||
return new Map<number, string>(
|
||||
collections.map((collection) => [collection.id, collection.name])
|
||||
);
|
||||
}
|
||||
|
||||
export function getNonHiddenCollections(
|
||||
collections: Collection[]
|
||||
): Collection[] {
|
||||
return collections.filter((collection) => !isHiddenCollection(collection));
|
||||
}
|
||||
|
||||
export function getHiddenCollections(collections: Collection[]): Collection[] {
|
||||
return collections.filter((collection) => isHiddenCollection(collection));
|
||||
}
|
30
apps/cast/src/utils/comlink/ComlinkConvertWorker.ts
Normal file
30
apps/cast/src/utils/comlink/ComlinkConvertWorker.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { Remote } from 'comlink';
|
||||
import { DedicatedConvertWorker } from 'worker/convert.worker';
|
||||
import { ComlinkWorker } from './comlinkWorker';
|
||||
import { runningInBrowser } from '@ente/shared/platform';
|
||||
|
||||
class ComlinkConvertWorker {
|
||||
private comlinkWorkerInstance: Remote<DedicatedConvertWorker>;
|
||||
|
||||
async getInstance() {
|
||||
if (!this.comlinkWorkerInstance) {
|
||||
this.comlinkWorkerInstance = await getDedicatedConvertWorker()
|
||||
.remote;
|
||||
}
|
||||
return this.comlinkWorkerInstance;
|
||||
}
|
||||
}
|
||||
|
||||
export const getDedicatedConvertWorker = () => {
|
||||
if (runningInBrowser()) {
|
||||
const cryptoComlinkWorker = new ComlinkWorker<
|
||||
typeof DedicatedConvertWorker
|
||||
>(
|
||||
'ente-convert-worker',
|
||||
new Worker(new URL('worker/convert.worker.ts', import.meta.url))
|
||||
);
|
||||
return cryptoComlinkWorker;
|
||||
}
|
||||
};
|
||||
|
||||
export default new ComlinkConvertWorker();
|
25
apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts
Normal file
25
apps/cast/src/utils/comlink/ComlinkCryptoWorker.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Remote } from 'comlink';
|
||||
import { DedicatedCryptoWorker } from 'worker/crypto.worker';
|
||||
import { ComlinkWorker } from './comlinkWorker';
|
||||
|
||||
class ComlinkCryptoWorker {
|
||||
private comlinkWorkerInstance: Promise<Remote<DedicatedCryptoWorker>>;
|
||||
|
||||
async getInstance() {
|
||||
if (!this.comlinkWorkerInstance) {
|
||||
const comlinkWorker = getDedicatedCryptoWorker();
|
||||
this.comlinkWorkerInstance = comlinkWorker.remote;
|
||||
}
|
||||
return this.comlinkWorkerInstance;
|
||||
}
|
||||
}
|
||||
|
||||
export const getDedicatedCryptoWorker = () => {
|
||||
const cryptoComlinkWorker = new ComlinkWorker<typeof DedicatedCryptoWorker>(
|
||||
'ente-crypto-worker',
|
||||
new Worker(new URL('worker/crypto.worker.ts', import.meta.url))
|
||||
);
|
||||
return cryptoComlinkWorker;
|
||||
};
|
||||
|
||||
export default new ComlinkCryptoWorker();
|
25
apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts
Normal file
25
apps/cast/src/utils/comlink/ComlinkFFmpegWorker.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Remote } from 'comlink';
|
||||
import { DedicatedFFmpegWorker } from 'worker/ffmpeg.worker';
|
||||
import { ComlinkWorker } from './comlinkWorker';
|
||||
|
||||
class ComlinkFFmpegWorker {
|
||||
private comlinkWorkerInstance: Promise<Remote<DedicatedFFmpegWorker>>;
|
||||
|
||||
async getInstance() {
|
||||
if (!this.comlinkWorkerInstance) {
|
||||
const comlinkWorker = getDedicatedFFmpegWorker();
|
||||
this.comlinkWorkerInstance = comlinkWorker.remote;
|
||||
}
|
||||
return this.comlinkWorkerInstance;
|
||||
}
|
||||
}
|
||||
|
||||
const getDedicatedFFmpegWorker = () => {
|
||||
const cryptoComlinkWorker = new ComlinkWorker<typeof DedicatedFFmpegWorker>(
|
||||
'ente-ffmpeg-worker',
|
||||
new Worker(new URL('worker/ffmpeg.worker.ts', import.meta.url))
|
||||
);
|
||||
return cryptoComlinkWorker;
|
||||
};
|
||||
|
||||
export default new ComlinkFFmpegWorker();
|
27
apps/cast/src/utils/comlink/comlinkWorker.ts
Normal file
27
apps/cast/src/utils/comlink/comlinkWorker.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { addLocalLog } from '@ente/shared/logging';
|
||||
import { Remote, wrap } from 'comlink';
|
||||
// import { WorkerElectronCacheStorageClient } from 'services/workerElectronCache/client';
|
||||
|
||||
export class ComlinkWorker<T extends new () => InstanceType<T>> {
|
||||
public remote: Promise<Remote<InstanceType<T>>>;
|
||||
private worker: Worker;
|
||||
private name: string;
|
||||
|
||||
constructor(name: string, worker: Worker) {
|
||||
this.name = name;
|
||||
this.worker = worker;
|
||||
|
||||
this.worker.onerror = (errorEvent) => {
|
||||
console.error('Got error event from worker', errorEvent);
|
||||
};
|
||||
addLocalLog(() => `Initiated ${this.name}`);
|
||||
const comlink = wrap<T>(this.worker);
|
||||
this.remote = new comlink() as Promise<Remote<InstanceType<T>>>;
|
||||
// expose(WorkerElectronCacheStorageClient, this.worker);
|
||||
}
|
||||
|
||||
public terminate() {
|
||||
this.worker.terminate();
|
||||
addLocalLog(() => `Terminated ${this.name}`);
|
||||
}
|
||||
}
|
15
apps/cast/src/utils/file/blob.ts
Normal file
15
apps/cast/src/utils/file/blob.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export const readAsDataURL = (blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = () => resolve(fileReader.result as string);
|
||||
fileReader.onerror = () => reject(fileReader.error);
|
||||
fileReader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
export const readAsText = (blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = () => resolve(fileReader.result as string);
|
||||
fileReader.onerror = () => reject(fileReader.error);
|
||||
fileReader.readAsText(blob);
|
||||
});
|
577
apps/cast/src/utils/file/index.ts
Normal file
577
apps/cast/src/utils/file/index.ts
Normal file
|
@ -0,0 +1,577 @@
|
|||
import { SelectedState } from 'types/gallery';
|
||||
import {
|
||||
EnteFile,
|
||||
EncryptedEnteFile,
|
||||
FileMagicMetadata,
|
||||
FilePublicMagicMetadata,
|
||||
} from 'types/file';
|
||||
import { decodeLivePhoto } from 'services/livePhotoService';
|
||||
import { getFileType } from 'services/typeDetectionService';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
import {
|
||||
TYPE_HEIC,
|
||||
TYPE_HEIF,
|
||||
FILE_TYPE,
|
||||
SUPPORTED_RAW_FORMATS,
|
||||
RAW_FORMATS,
|
||||
} from 'constants/file';
|
||||
import CastDownloadManager from 'services/castDownloadManager';
|
||||
import heicConversionService from 'services/heicConversionService';
|
||||
import * as ffmpegService from 'services/ffmpeg/ffmpegService';
|
||||
import { isArchivedFile } from 'utils/magicMetadata';
|
||||
|
||||
import { CustomError } from '@ente/shared/error';
|
||||
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
|
||||
import isElectron from 'is-electron';
|
||||
import { isPlaybackPossible } from 'utils/photoFrame';
|
||||
import { FileTypeInfo } from 'types/upload';
|
||||
import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
|
||||
import { User } from '@ente/shared/user/types';
|
||||
import { addLogLine, addLocalLog } from '@ente/shared/logging';
|
||||
import { convertBytesToHumanReadable } from '@ente/shared/utils/size';
|
||||
|
||||
const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000;
|
||||
|
||||
export enum FILE_OPS_TYPE {
|
||||
DOWNLOAD,
|
||||
FIX_TIME,
|
||||
ARCHIVE,
|
||||
UNARCHIVE,
|
||||
HIDE,
|
||||
TRASH,
|
||||
DELETE_PERMANENTLY,
|
||||
}
|
||||
|
||||
export function groupFilesBasedOnCollectionID(files: EnteFile[]) {
|
||||
const collectionWiseFiles = new Map<number, EnteFile[]>();
|
||||
for (const file of files) {
|
||||
if (!collectionWiseFiles.has(file.collectionID)) {
|
||||
collectionWiseFiles.set(file.collectionID, []);
|
||||
}
|
||||
collectionWiseFiles.get(file.collectionID).push(file);
|
||||
}
|
||||
return collectionWiseFiles;
|
||||
}
|
||||
|
||||
function getSelectedFileIds(selectedFiles: SelectedState) {
|
||||
const filesIDs: number[] = [];
|
||||
for (const [key, val] of Object.entries(selectedFiles)) {
|
||||
if (typeof val === 'boolean' && val) {
|
||||
filesIDs.push(Number(key));
|
||||
}
|
||||
}
|
||||
return new Set(filesIDs);
|
||||
}
|
||||
export function getSelectedFiles(
|
||||
selected: SelectedState,
|
||||
files: EnteFile[]
|
||||
): EnteFile[] {
|
||||
const selectedFilesIDs = getSelectedFileIds(selected);
|
||||
return files.filter((file) => selectedFilesIDs.has(file.id));
|
||||
}
|
||||
|
||||
export function sortFiles(files: EnteFile[], sortAsc = false) {
|
||||
// sort based on the time of creation time of the file,
|
||||
// for files with same creation time, sort based on the time of last modification
|
||||
const factor = sortAsc ? -1 : 1;
|
||||
return files.sort((a, b) => {
|
||||
if (a.metadata.creationTime === b.metadata.creationTime) {
|
||||
return (
|
||||
factor *
|
||||
(b.metadata.modificationTime - a.metadata.modificationTime)
|
||||
);
|
||||
}
|
||||
return factor * (b.metadata.creationTime - a.metadata.creationTime);
|
||||
});
|
||||
}
|
||||
|
||||
export function sortTrashFiles(files: EnteFile[]) {
|
||||
return files.sort((a, b) => {
|
||||
if (a.deleteBy === b.deleteBy) {
|
||||
if (a.metadata.creationTime === b.metadata.creationTime) {
|
||||
return (
|
||||
b.metadata.modificationTime - a.metadata.modificationTime
|
||||
);
|
||||
}
|
||||
return b.metadata.creationTime - a.metadata.creationTime;
|
||||
}
|
||||
return a.deleteBy - b.deleteBy;
|
||||
});
|
||||
}
|
||||
|
||||
export async function decryptFile(
|
||||
file: EncryptedEnteFile,
|
||||
collectionKey: string
|
||||
): Promise<EnteFile> {
|
||||
try {
|
||||
const worker = await ComlinkCryptoWorker.getInstance();
|
||||
const {
|
||||
encryptedKey,
|
||||
keyDecryptionNonce,
|
||||
metadata,
|
||||
magicMetadata,
|
||||
pubMagicMetadata,
|
||||
...restFileProps
|
||||
} = file;
|
||||
const fileKey = await worker.decryptB64(
|
||||
encryptedKey,
|
||||
keyDecryptionNonce,
|
||||
collectionKey
|
||||
);
|
||||
const fileMetadata = await worker.decryptMetadata(
|
||||
metadata.encryptedData,
|
||||
metadata.decryptionHeader,
|
||||
fileKey
|
||||
);
|
||||
let fileMagicMetadata: FileMagicMetadata;
|
||||
let filePubMagicMetadata: FilePublicMagicMetadata;
|
||||
if (magicMetadata?.data) {
|
||||
fileMagicMetadata = {
|
||||
...file.magicMetadata,
|
||||
data: await worker.decryptMetadata(
|
||||
magicMetadata.data,
|
||||
magicMetadata.header,
|
||||
fileKey
|
||||
),
|
||||
};
|
||||
}
|
||||
if (pubMagicMetadata?.data) {
|
||||
filePubMagicMetadata = {
|
||||
...pubMagicMetadata,
|
||||
data: await worker.decryptMetadata(
|
||||
pubMagicMetadata.data,
|
||||
pubMagicMetadata.header,
|
||||
fileKey
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...restFileProps,
|
||||
key: fileKey,
|
||||
metadata: fileMetadata,
|
||||
magicMetadata: fileMagicMetadata,
|
||||
pubMagicMetadata: filePubMagicMetadata,
|
||||
};
|
||||
} catch (e) {
|
||||
logError(e, 'file decryption failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileNameWithoutExtension(filename: string) {
|
||||
const lastDotPosition = filename.lastIndexOf('.');
|
||||
if (lastDotPosition === -1) return filename;
|
||||
else return filename.slice(0, lastDotPosition);
|
||||
}
|
||||
|
||||
export function getFileExtensionWithDot(filename: string) {
|
||||
const lastDotPosition = filename.lastIndexOf('.');
|
||||
if (lastDotPosition === -1) return '';
|
||||
else return filename.slice(lastDotPosition);
|
||||
}
|
||||
|
||||
export function splitFilenameAndExtension(filename: string): [string, string] {
|
||||
const lastDotPosition = filename.lastIndexOf('.');
|
||||
if (lastDotPosition === -1) return [filename, null];
|
||||
else
|
||||
return [
|
||||
filename.slice(0, lastDotPosition),
|
||||
filename.slice(lastDotPosition + 1),
|
||||
];
|
||||
}
|
||||
|
||||
export function getFileExtension(filename: string) {
|
||||
return splitFilenameAndExtension(filename)[1]?.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
export function generateStreamFromArrayBuffer(data: Uint8Array) {
|
||||
return new ReadableStream({
|
||||
async start(controller: ReadableStreamDefaultController) {
|
||||
controller.enqueue(data);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRenderableFileURL(file: EnteFile, fileBlob: Blob) {
|
||||
switch (file.metadata.fileType) {
|
||||
case FILE_TYPE.IMAGE: {
|
||||
const convertedBlob = await getRenderableImage(
|
||||
file.metadata.title,
|
||||
fileBlob
|
||||
);
|
||||
const { originalURL, convertedURL } = getFileObjectURLs(
|
||||
fileBlob,
|
||||
convertedBlob
|
||||
);
|
||||
return {
|
||||
converted: [convertedURL],
|
||||
original: [originalURL],
|
||||
};
|
||||
}
|
||||
case FILE_TYPE.LIVE_PHOTO: {
|
||||
return await getRenderableLivePhotoURL(file, fileBlob);
|
||||
}
|
||||
case FILE_TYPE.VIDEO: {
|
||||
const convertedBlob = await getPlayableVideo(
|
||||
file.metadata.title,
|
||||
fileBlob
|
||||
);
|
||||
const { originalURL, convertedURL } = getFileObjectURLs(
|
||||
fileBlob,
|
||||
convertedBlob
|
||||
);
|
||||
return {
|
||||
converted: [convertedURL],
|
||||
original: [originalURL],
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const previewURL = await createTypedObjectURL(
|
||||
fileBlob,
|
||||
file.metadata.title
|
||||
);
|
||||
return {
|
||||
converted: [previewURL],
|
||||
original: [previewURL],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getRenderableLivePhotoURL(
|
||||
file: EnteFile,
|
||||
fileBlob: Blob
|
||||
): Promise<{ original: string[]; converted: string[] }> {
|
||||
const livePhoto = await decodeLivePhoto(file, fileBlob);
|
||||
const imageBlob = new Blob([livePhoto.image]);
|
||||
const videoBlob = new Blob([livePhoto.video]);
|
||||
const convertedImageBlob = await getRenderableImage(
|
||||
livePhoto.imageNameTitle,
|
||||
imageBlob
|
||||
);
|
||||
const convertedVideoBlob = await getPlayableVideo(
|
||||
livePhoto.videoNameTitle,
|
||||
videoBlob,
|
||||
true
|
||||
);
|
||||
const { originalURL: originalImageURL, convertedURL: convertedImageURL } =
|
||||
getFileObjectURLs(imageBlob, convertedImageBlob);
|
||||
|
||||
const { originalURL: originalVideoURL, convertedURL: convertedVideoURL } =
|
||||
getFileObjectURLs(videoBlob, convertedVideoBlob);
|
||||
return {
|
||||
converted: [convertedImageURL, convertedVideoURL],
|
||||
original: [originalImageURL, originalVideoURL],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPlayableVideo(
|
||||
videoNameTitle: string,
|
||||
videoBlob: Blob,
|
||||
forceConvert = false
|
||||
) {
|
||||
try {
|
||||
const isPlayable = await isPlaybackPossible(
|
||||
URL.createObjectURL(videoBlob)
|
||||
);
|
||||
if (isPlayable && !forceConvert) {
|
||||
return videoBlob;
|
||||
} else {
|
||||
if (!forceConvert && !isElectron()) {
|
||||
return null;
|
||||
}
|
||||
addLogLine(
|
||||
'video format not supported, converting it name:',
|
||||
videoNameTitle
|
||||
);
|
||||
const mp4ConvertedVideo = await ffmpegService.convertToMP4(
|
||||
new File([videoBlob], videoNameTitle)
|
||||
);
|
||||
addLogLine('video successfully converted', videoNameTitle);
|
||||
return new Blob([await mp4ConvertedVideo.arrayBuffer()]);
|
||||
}
|
||||
} catch (e) {
|
||||
addLogLine('video conversion failed', videoNameTitle);
|
||||
logError(e, 'video conversion failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRenderableImage(fileName: string, imageBlob: Blob) {
|
||||
let fileTypeInfo: FileTypeInfo;
|
||||
try {
|
||||
const tempFile = new File([imageBlob], fileName);
|
||||
fileTypeInfo = await getFileType(tempFile);
|
||||
addLocalLog(() => `file type info: ${JSON.stringify(fileTypeInfo)}`);
|
||||
const { exactType } = fileTypeInfo;
|
||||
let convertedImageBlob: Blob;
|
||||
if (isRawFile(exactType)) {
|
||||
try {
|
||||
if (!isSupportedRawFormat(exactType)) {
|
||||
throw Error(CustomError.UNSUPPORTED_RAW_FORMAT);
|
||||
}
|
||||
|
||||
if (!isElectron()) {
|
||||
throw Error(CustomError.NOT_AVAILABLE_ON_WEB);
|
||||
}
|
||||
addLogLine(
|
||||
`RawConverter called for ${fileName}-${convertBytesToHumanReadable(
|
||||
imageBlob.size
|
||||
)}`
|
||||
);
|
||||
// convertedImageBlob = await imageProcessor.convertToJPEG(
|
||||
// imageBlob,
|
||||
// fileName
|
||||
// );
|
||||
addLogLine(`${fileName} successfully converted`);
|
||||
} catch (e) {
|
||||
try {
|
||||
if (!isFileHEIC(exactType)) {
|
||||
throw e;
|
||||
}
|
||||
addLogLine(
|
||||
`HEICConverter called for ${fileName}-${convertBytesToHumanReadable(
|
||||
imageBlob.size
|
||||
)}`
|
||||
);
|
||||
convertedImageBlob = await heicConversionService.convert(
|
||||
imageBlob
|
||||
);
|
||||
addLogLine(`${fileName} successfully converted`);
|
||||
} catch (e) {
|
||||
throw Error(CustomError.NON_PREVIEWABLE_FILE);
|
||||
}
|
||||
}
|
||||
return convertedImageBlob;
|
||||
} else {
|
||||
return imageBlob;
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, 'get Renderable Image failed', { fileTypeInfo });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isFileHEIC(exactType: string) {
|
||||
return (
|
||||
exactType.toLowerCase().endsWith(TYPE_HEIC) ||
|
||||
exactType.toLowerCase().endsWith(TYPE_HEIF)
|
||||
);
|
||||
}
|
||||
|
||||
export function isRawFile(exactType: string) {
|
||||
return RAW_FORMATS.includes(exactType.toLowerCase());
|
||||
}
|
||||
|
||||
export function isRawFileFromFileName(fileName: string) {
|
||||
for (const rawFormat of RAW_FORMATS) {
|
||||
if (fileName.toLowerCase().endsWith(rawFormat)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSupportedRawFormat(exactType: string) {
|
||||
return SUPPORTED_RAW_FORMATS.includes(exactType.toLowerCase());
|
||||
}
|
||||
|
||||
export function mergeMetadata(files: EnteFile[]): EnteFile[] {
|
||||
return files.map((file) => {
|
||||
if (file.pubMagicMetadata?.data.editedTime) {
|
||||
file.metadata.creationTime = file.pubMagicMetadata.data.editedTime;
|
||||
}
|
||||
if (file.pubMagicMetadata?.data.editedName) {
|
||||
file.metadata.title = file.pubMagicMetadata.data.editedName;
|
||||
}
|
||||
|
||||
return file;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFileFromURL(fileURL: string) {
|
||||
const fileBlob = await (await fetch(fileURL)).blob();
|
||||
const fileFile = new File([fileBlob], 'temp');
|
||||
return fileFile;
|
||||
}
|
||||
|
||||
export function getUniqueFiles(files: EnteFile[]) {
|
||||
const idSet = new Set<number>();
|
||||
const uniqueFiles = files.filter((file) => {
|
||||
if (!idSet.has(file.id)) {
|
||||
idSet.add(file.id);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueFiles;
|
||||
}
|
||||
|
||||
export const isImageOrVideo = (fileType: FILE_TYPE) =>
|
||||
[FILE_TYPE.IMAGE, FILE_TYPE.VIDEO].includes(fileType);
|
||||
|
||||
export const getArchivedFiles = (files: EnteFile[]) => {
|
||||
return files.filter(isArchivedFile).map((file) => file.id);
|
||||
};
|
||||
|
||||
export const createTypedObjectURL = async (blob: Blob, fileName: string) => {
|
||||
const type = await getFileType(new File([blob], fileName));
|
||||
return URL.createObjectURL(new Blob([blob], { type: type.mimeType }));
|
||||
};
|
||||
|
||||
export const getUserOwnedFiles = (files: EnteFile[]) => {
|
||||
const user: User = getData(LS_KEYS.USER);
|
||||
if (!user?.id) {
|
||||
throw Error('user missing');
|
||||
}
|
||||
return files.filter((file) => file.ownerID === user.id);
|
||||
};
|
||||
|
||||
// doesn't work on firefox
|
||||
export const copyFileToClipboard = async (fileUrl: string) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvasCTX = canvas.getContext('2d');
|
||||
const image = new Image();
|
||||
|
||||
const blobPromise = new Promise<Blob>((resolve, reject) => {
|
||||
let timeout: NodeJS.Timeout = null;
|
||||
try {
|
||||
image.setAttribute('src', fileUrl);
|
||||
image.onload = () => {
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
canvasCTX.drawImage(image, 0, 0, image.width, image.height);
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
resolve(blob);
|
||||
},
|
||||
'image/png',
|
||||
1
|
||||
);
|
||||
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
} catch (e) {
|
||||
void logError(e, 'failed to copy to clipboard');
|
||||
reject(e);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(
|
||||
() => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
|
||||
WAIT_TIME_IMAGE_CONVERSION
|
||||
);
|
||||
});
|
||||
|
||||
const { ClipboardItem } = window;
|
||||
|
||||
await navigator.clipboard
|
||||
.write([new ClipboardItem({ 'image/png': blobPromise })])
|
||||
.catch((e) => logError(e, 'failed to copy to clipboard'));
|
||||
};
|
||||
|
||||
export function getLatestVersionFiles(files: EnteFile[]) {
|
||||
const latestVersionFiles = new Map<string, EnteFile>();
|
||||
files.forEach((file) => {
|
||||
const uid = `${file.collectionID}-${file.id}`;
|
||||
if (
|
||||
!latestVersionFiles.has(uid) ||
|
||||
latestVersionFiles.get(uid).updationTime < file.updationTime
|
||||
) {
|
||||
latestVersionFiles.set(uid, file);
|
||||
}
|
||||
});
|
||||
return Array.from(latestVersionFiles.values()).filter(
|
||||
(file) => !file.isDeleted
|
||||
);
|
||||
}
|
||||
|
||||
export function getPersonalFiles(files: EnteFile[], user: User) {
|
||||
if (!user?.id) {
|
||||
throw Error('user missing');
|
||||
}
|
||||
return files.filter((file) => file.ownerID === user.id);
|
||||
}
|
||||
|
||||
export function getIDBasedSortedFiles(files: EnteFile[]) {
|
||||
return files.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
export function constructFileToCollectionMap(files: EnteFile[]) {
|
||||
const fileToCollectionsMap = new Map<number, number[]>();
|
||||
(files ?? []).forEach((file) => {
|
||||
if (!fileToCollectionsMap.get(file.id)) {
|
||||
fileToCollectionsMap.set(file.id, []);
|
||||
}
|
||||
fileToCollectionsMap.get(file.id).push(file.collectionID);
|
||||
});
|
||||
return fileToCollectionsMap;
|
||||
}
|
||||
|
||||
export const shouldShowAvatar = (file: EnteFile, user: User) => {
|
||||
if (!file || !user) {
|
||||
return false;
|
||||
}
|
||||
// is Shared file
|
||||
else if (file.ownerID !== user.id) {
|
||||
return true;
|
||||
}
|
||||
// is public collected file
|
||||
else if (
|
||||
file.ownerID === user.id &&
|
||||
file.pubMagicMetadata?.data?.uploaderName
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPreviewableImage = async (
|
||||
file: EnteFile,
|
||||
castToken: string
|
||||
): Promise<Blob> => {
|
||||
try {
|
||||
let fileBlob: Blob;
|
||||
const fileURL = await CastDownloadManager.getCachedOriginalFile(
|
||||
file
|
||||
)[0];
|
||||
if (!fileURL) {
|
||||
fileBlob = await new Response(
|
||||
await CastDownloadManager.downloadFile(castToken, file)
|
||||
).blob();
|
||||
} else {
|
||||
fileBlob = await (await fetch(fileURL)).blob();
|
||||
}
|
||||
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
|
||||
const livePhoto = await decodeLivePhoto(file, fileBlob);
|
||||
fileBlob = new Blob([livePhoto.image]);
|
||||
}
|
||||
const convertedBlob = await getRenderableImage(
|
||||
file.metadata.title,
|
||||
fileBlob
|
||||
);
|
||||
fileBlob = convertedBlob;
|
||||
const fileType = await getFileType(
|
||||
new File([fileBlob], file.metadata.title)
|
||||
);
|
||||
|
||||
fileBlob = new Blob([fileBlob], { type: fileType.mimeType });
|
||||
return fileBlob;
|
||||
} catch (e) {
|
||||
logError(e, 'failed to download file');
|
||||
}
|
||||
};
|
||||
|
||||
const getFileObjectURLs = (originalBlob: Blob, convertedBlob: Blob) => {
|
||||
const originalURL = URL.createObjectURL(originalBlob);
|
||||
const convertedURL = convertedBlob
|
||||
? convertedBlob === originalBlob
|
||||
? originalURL
|
||||
: URL.createObjectURL(convertedBlob)
|
||||
: null;
|
||||
return { originalURL, convertedURL };
|
||||
};
|
42
apps/cast/src/utils/file/livePhoto.ts
Normal file
42
apps/cast/src/utils/file/livePhoto.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { FILE_TYPE } from 'constants/file';
|
||||
import { getFileExtension } from 'utils/file';
|
||||
|
||||
const IMAGE_EXTENSIONS = [
|
||||
'heic',
|
||||
'heif',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'webp',
|
||||
];
|
||||
|
||||
const VIDEO_EXTENSIONS = [
|
||||
'mov',
|
||||
'mp4',
|
||||
'm4v',
|
||||
'avi',
|
||||
'wmv',
|
||||
'flv',
|
||||
'mkv',
|
||||
'webm',
|
||||
'3gp',
|
||||
'3g2',
|
||||
'avi',
|
||||
'ogv',
|
||||
'mpg',
|
||||
'mp',
|
||||
];
|
||||
|
||||
export function getFileTypeFromExtensionForLivePhotoClustering(
|
||||
filename: string
|
||||
) {
|
||||
const extension = getFileExtension(filename)?.toLowerCase();
|
||||
if (IMAGE_EXTENSIONS.includes(extension)) {
|
||||
return FILE_TYPE.IMAGE;
|
||||
} else if (VIDEO_EXTENSIONS.includes(extension)) {
|
||||
return FILE_TYPE.VIDEO;
|
||||
}
|
||||
}
|
97
apps/cast/src/utils/magicMetadata/index.ts
Normal file
97
apps/cast/src/utils/magicMetadata/index.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { Collection } from 'types/collection';
|
||||
import { EnteFile } from 'types/file';
|
||||
import { MagicMetadataCore, VISIBILITY_STATE } from 'types/magicMetadata';
|
||||
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
|
||||
|
||||
export function isArchivedFile(item: EnteFile): boolean {
|
||||
if (!item || !item.magicMetadata || !item.magicMetadata.data) {
|
||||
return false;
|
||||
}
|
||||
return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
|
||||
}
|
||||
|
||||
export function isArchivedCollection(item: Collection): boolean {
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.magicMetadata && item.magicMetadata.data) {
|
||||
return item.magicMetadata.data.visibility === VISIBILITY_STATE.ARCHIVED;
|
||||
}
|
||||
|
||||
if (item.sharedMagicMetadata && item.sharedMagicMetadata.data) {
|
||||
return (
|
||||
item.sharedMagicMetadata.data.visibility ===
|
||||
VISIBILITY_STATE.ARCHIVED
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPinnedCollection(item: Collection) {
|
||||
if (
|
||||
!item ||
|
||||
!item.magicMetadata ||
|
||||
!item.magicMetadata.data ||
|
||||
typeof item.magicMetadata.data === 'string' ||
|
||||
typeof item.magicMetadata.data.order === 'undefined'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return item.magicMetadata.data.order !== 0;
|
||||
}
|
||||
|
||||
export async function updateMagicMetadata<T>(
|
||||
magicMetadataUpdates: T,
|
||||
originalMagicMetadata?: MagicMetadataCore<T>,
|
||||
decryptionKey?: string
|
||||
): Promise<MagicMetadataCore<T>> {
|
||||
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||
|
||||
if (!originalMagicMetadata) {
|
||||
originalMagicMetadata = getNewMagicMetadata<T>();
|
||||
}
|
||||
|
||||
if (typeof originalMagicMetadata?.data === 'string') {
|
||||
originalMagicMetadata.data = await cryptoWorker.decryptMetadata(
|
||||
originalMagicMetadata.data,
|
||||
originalMagicMetadata.header,
|
||||
decryptionKey
|
||||
);
|
||||
}
|
||||
// copies the existing magic metadata properties of the files and updates the visibility value
|
||||
// The expected behavior while updating magic metadata is to let the existing property as it is and update/add the property you want
|
||||
const magicMetadataProps: T = {
|
||||
...originalMagicMetadata.data,
|
||||
...magicMetadataUpdates,
|
||||
};
|
||||
|
||||
const nonEmptyMagicMetadataProps =
|
||||
getNonEmptyMagicMetadataProps(magicMetadataProps);
|
||||
|
||||
const magicMetadata = {
|
||||
...originalMagicMetadata,
|
||||
data: nonEmptyMagicMetadataProps,
|
||||
count: Object.keys(nonEmptyMagicMetadataProps).length,
|
||||
};
|
||||
|
||||
return magicMetadata;
|
||||
}
|
||||
|
||||
export const getNewMagicMetadata = <T>(): MagicMetadataCore<T> => {
|
||||
return {
|
||||
version: 1,
|
||||
data: null,
|
||||
header: null,
|
||||
count: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const getNonEmptyMagicMetadataProps = <T>(magicMetadataProps: T): T => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(magicMetadataProps).filter(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
([_, v]) => v !== null && v !== undefined
|
||||
)
|
||||
) as T;
|
||||
};
|
28
apps/cast/src/utils/network/index.ts
Normal file
28
apps/cast/src/utils/network/index.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { sleep } from '@ente/shared/sleep';
|
||||
|
||||
const waitTimeBeforeNextAttemptInMilliSeconds = [2000, 5000, 10000];
|
||||
|
||||
export async function retryAsyncFunction<T>(
|
||||
request: (abort?: () => void) => Promise<T>,
|
||||
waitTimeBeforeNextTry?: number[]
|
||||
): Promise<T> {
|
||||
if (!waitTimeBeforeNextTry) {
|
||||
waitTimeBeforeNextTry = waitTimeBeforeNextAttemptInMilliSeconds;
|
||||
}
|
||||
|
||||
for (
|
||||
let attemptNumber = 0;
|
||||
attemptNumber <= waitTimeBeforeNextTry.length;
|
||||
attemptNumber++
|
||||
) {
|
||||
try {
|
||||
const resp = await request();
|
||||
return resp;
|
||||
} catch (e) {
|
||||
if (attemptNumber === waitTimeBeforeNextTry.length) {
|
||||
throw e;
|
||||
}
|
||||
await sleep(waitTimeBeforeNextTry[attemptNumber]);
|
||||
}
|
||||
}
|
||||
}
|
148
apps/cast/src/utils/photoFrame/index.ts
Normal file
148
apps/cast/src/utils/photoFrame/index.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
import { FILE_TYPE } from 'constants/file';
|
||||
import { EnteFile } from 'types/file';
|
||||
import { MergedSourceURL } from 'types/gallery';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
|
||||
const WAIT_FOR_VIDEO_PLAYBACK = 1 * 1000;
|
||||
|
||||
export async function isPlaybackPossible(url: string): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const t = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, WAIT_FOR_VIDEO_PLAYBACK);
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.addEventListener('canplay', function () {
|
||||
clearTimeout(t);
|
||||
video.remove(); // Clean up the video element
|
||||
// also check for duration > 0 to make sure it is not a broken video
|
||||
if (video.duration > 0) {
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
video.addEventListener('error', function () {
|
||||
clearTimeout(t);
|
||||
video.remove();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
video.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export async function playVideo(livePhotoVideo, livePhotoImage) {
|
||||
const videoPlaying = !livePhotoVideo.paused;
|
||||
if (videoPlaying) return;
|
||||
livePhotoVideo.style.opacity = 1;
|
||||
livePhotoImage.style.opacity = 0;
|
||||
livePhotoVideo.load();
|
||||
livePhotoVideo.play().catch(() => {
|
||||
pauseVideo(livePhotoVideo, livePhotoImage);
|
||||
});
|
||||
}
|
||||
|
||||
export async function pauseVideo(livePhotoVideo, livePhotoImage) {
|
||||
const videoPlaying = !livePhotoVideo.paused;
|
||||
if (!videoPlaying) return;
|
||||
livePhotoVideo.pause();
|
||||
livePhotoVideo.style.opacity = 0;
|
||||
livePhotoImage.style.opacity = 1;
|
||||
}
|
||||
|
||||
export function updateFileMsrcProps(file: EnteFile, url: string) {
|
||||
file.msrc = url;
|
||||
file.isSourceLoaded = false;
|
||||
file.conversionFailed = false;
|
||||
file.isConverted = false;
|
||||
if (file.metadata.fileType === FILE_TYPE.IMAGE) {
|
||||
file.src = url;
|
||||
} else {
|
||||
file.html = `
|
||||
<div class = 'pswp-item-container'>
|
||||
<img src="${url}"/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFileSrcProps(
|
||||
file: EnteFile,
|
||||
mergedURL: MergedSourceURL
|
||||
) {
|
||||
const urls = {
|
||||
original: mergedURL.original.split(','),
|
||||
converted: mergedURL.converted.split(','),
|
||||
};
|
||||
let originalImageURL;
|
||||
let originalVideoURL;
|
||||
let convertedImageURL;
|
||||
let convertedVideoURL;
|
||||
let originalURL;
|
||||
let isConverted;
|
||||
let conversionFailed;
|
||||
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
|
||||
[originalImageURL, originalVideoURL] = urls.original;
|
||||
[convertedImageURL, convertedVideoURL] = urls.converted;
|
||||
isConverted =
|
||||
originalVideoURL !== convertedVideoURL ||
|
||||
originalImageURL !== convertedImageURL;
|
||||
conversionFailed = !convertedVideoURL || !convertedImageURL;
|
||||
} else if (file.metadata.fileType === FILE_TYPE.VIDEO) {
|
||||
[originalVideoURL] = urls.original;
|
||||
[convertedVideoURL] = urls.converted;
|
||||
isConverted = originalVideoURL !== convertedVideoURL;
|
||||
conversionFailed = !convertedVideoURL;
|
||||
} else if (file.metadata.fileType === FILE_TYPE.IMAGE) {
|
||||
[originalImageURL] = urls.original;
|
||||
[convertedImageURL] = urls.converted;
|
||||
isConverted = originalImageURL !== convertedImageURL;
|
||||
conversionFailed = !convertedImageURL;
|
||||
} else {
|
||||
[originalURL] = urls.original;
|
||||
isConverted = false;
|
||||
conversionFailed = false;
|
||||
}
|
||||
|
||||
const isPlayable = !isConverted || (isConverted && !conversionFailed);
|
||||
|
||||
file.w = window.innerWidth;
|
||||
file.h = window.innerHeight;
|
||||
file.isSourceLoaded = true;
|
||||
file.originalImageURL = originalImageURL;
|
||||
file.originalVideoURL = originalVideoURL;
|
||||
file.isConverted = isConverted;
|
||||
file.conversionFailed = conversionFailed;
|
||||
|
||||
if (!isPlayable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.metadata.fileType === FILE_TYPE.VIDEO) {
|
||||
file.html = `
|
||||
<video controls onContextMenu="return false;">
|
||||
<source src="${convertedVideoURL}" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
`;
|
||||
} else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
|
||||
file.html = `
|
||||
<div class = 'pswp-item-container'>
|
||||
<img id = "live-photo-image-${file.id}" src="${convertedImageURL}" onContextMenu="return false;"/>
|
||||
<video id = "live-photo-video-${file.id}" loop muted onContextMenu="return false;">
|
||||
<source src="${convertedVideoURL}" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
`;
|
||||
} else if (file.metadata.fileType === FILE_TYPE.IMAGE) {
|
||||
file.src = convertedImageURL;
|
||||
} else {
|
||||
logError(
|
||||
Error(`unknown file type - ${file.metadata.fileType}`),
|
||||
'Unknown file type'
|
||||
);
|
||||
file.src = originalURL;
|
||||
}
|
||||
}
|
14
apps/cast/src/utils/temp/index.ts
Normal file
14
apps/cast/src/utils/temp/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
const CHARACTERS =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
export function generateTempName(length: number, suffix: string) {
|
||||
let tempName = '';
|
||||
|
||||
const charactersLength = CHARACTERS.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
tempName += CHARACTERS.charAt(
|
||||
Math.floor(Math.random() * charactersLength)
|
||||
);
|
||||
}
|
||||
return `${tempName}-${suffix}`;
|
||||
}
|
78
apps/cast/src/utils/time/format.ts
Normal file
78
apps/cast/src/utils/time/format.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import i18n, { t } from 'i18next';
|
||||
|
||||
const dateTimeFullFormatter1 = new Intl.DateTimeFormat(i18n.language, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const dateTimeFullFormatter2 = new Intl.DateTimeFormat(i18n.language, {
|
||||
year: 'numeric',
|
||||
});
|
||||
const dateTimeShortFormatter = new Intl.DateTimeFormat(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(i18n.language, {
|
||||
timeStyle: 'short',
|
||||
});
|
||||
|
||||
export function formatDateFull(date: number | Date) {
|
||||
return [dateTimeFullFormatter1, dateTimeFullFormatter2]
|
||||
.map((f) => f.format(date))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function formatDate(date: number | Date) {
|
||||
const withinYear =
|
||||
new Date().getFullYear() === new Date(date).getFullYear();
|
||||
const dateTimeFormat2 = !withinYear ? dateTimeFullFormatter2 : null;
|
||||
return [dateTimeFullFormatter1, dateTimeFormat2]
|
||||
.filter((f) => !!f)
|
||||
.map((f) => f.format(date))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function formatDateTimeShort(date: number | Date) {
|
||||
return dateTimeShortFormatter.format(date);
|
||||
}
|
||||
|
||||
export function formatTime(date: number | Date) {
|
||||
return timeFormatter.format(date).toUpperCase();
|
||||
}
|
||||
|
||||
export function formatDateTimeFull(dateTime: number | Date): string {
|
||||
return [formatDateFull(dateTime), t('at'), formatTime(dateTime)].join(' ');
|
||||
}
|
||||
|
||||
export function formatDateTime(dateTime: number | Date): string {
|
||||
return [formatDate(dateTime), t('at'), formatTime(dateTime)].join(' ');
|
||||
}
|
||||
|
||||
export function formatDateRelative(date: number) {
|
||||
const units = {
|
||||
year: 24 * 60 * 60 * 1000 * 365,
|
||||
month: (24 * 60 * 60 * 1000 * 365) / 12,
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
minute: 60 * 1000,
|
||||
second: 1000,
|
||||
};
|
||||
const relativeDateFormat = new Intl.RelativeTimeFormat(i18n.language, {
|
||||
localeMatcher: 'best fit',
|
||||
numeric: 'always',
|
||||
style: 'long',
|
||||
});
|
||||
const elapsed = date - Date.now(); // "Math.abs" accounts for both "past" & "future" scenarios
|
||||
|
||||
for (const u in units)
|
||||
if (Math.abs(elapsed) > units[u] || u === 'second')
|
||||
return relativeDateFormat.format(
|
||||
Math.round(elapsed / units[u]),
|
||||
u as Intl.RelativeTimeFormatUnit
|
||||
);
|
||||
}
|
136
apps/cast/src/utils/time/index.ts
Normal file
136
apps/cast/src/utils/time/index.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
export interface TimeDelta {
|
||||
hours?: number;
|
||||
days?: number;
|
||||
months?: number;
|
||||
years?: number;
|
||||
}
|
||||
|
||||
interface DateComponent<T = number> {
|
||||
year: T;
|
||||
month: T;
|
||||
day: T;
|
||||
hour: T;
|
||||
minute: T;
|
||||
second: T;
|
||||
}
|
||||
|
||||
export function validateAndGetCreationUnixTimeInMicroSeconds(dateTime: Date) {
|
||||
if (!dateTime || isNaN(dateTime.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const unixTime = dateTime.getTime() * 1000;
|
||||
//ignoring dateTimeString = "0000:00:00 00:00:00"
|
||||
if (unixTime === Date.UTC(0, 0, 0, 0, 0, 0, 0) || unixTime === 0) {
|
||||
return null;
|
||||
} else if (unixTime > Date.now() * 1000) {
|
||||
return null;
|
||||
} else {
|
||||
return unixTime;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
generates data component for date in format YYYYMMDD-HHMMSS
|
||||
*/
|
||||
export function parseDateFromFusedDateString(dateTime: string) {
|
||||
const dateComponent: DateComponent<number> = convertDateComponentToNumber({
|
||||
year: dateTime.slice(0, 4),
|
||||
month: dateTime.slice(4, 6),
|
||||
day: dateTime.slice(6, 8),
|
||||
hour: dateTime.slice(9, 11),
|
||||
minute: dateTime.slice(11, 13),
|
||||
second: dateTime.slice(13, 15),
|
||||
});
|
||||
return validateAndGetDateFromComponents(dateComponent);
|
||||
}
|
||||
|
||||
/* sample date format = 2018-08-19 12:34:45
|
||||
the date has six symbol separated number values
|
||||
which we would extract and use to form the date
|
||||
*/
|
||||
export function tryToParseDateTime(dateTime: string): Date {
|
||||
const dateComponent = getDateComponentsFromSymbolJoinedString(dateTime);
|
||||
if (dateComponent.year?.length === 8 && dateComponent.month?.length === 6) {
|
||||
// the filename has size 8 consecutive and then 6 consecutive digits
|
||||
// high possibility that the it is a date in format YYYYMMDD-HHMMSS
|
||||
const possibleDateTime = dateComponent.year + '-' + dateComponent.month;
|
||||
return parseDateFromFusedDateString(possibleDateTime);
|
||||
}
|
||||
return validateAndGetDateFromComponents(
|
||||
convertDateComponentToNumber(dateComponent)
|
||||
);
|
||||
}
|
||||
|
||||
function getDateComponentsFromSymbolJoinedString(
|
||||
dateTime: string
|
||||
): DateComponent<string> {
|
||||
const [year, month, day, hour, minute, second] =
|
||||
dateTime.match(/\d+/g) ?? [];
|
||||
|
||||
return { year, month, day, hour, minute, second };
|
||||
}
|
||||
|
||||
function validateAndGetDateFromComponents(
|
||||
dateComponent: DateComponent<number>
|
||||
) {
|
||||
let date = getDateFromComponents(dateComponent);
|
||||
if (hasTimeValues(dateComponent) && !isTimePartValid(date, dateComponent)) {
|
||||
// if the date has time values but they are not valid
|
||||
// then we remove the time values and try to validate the date
|
||||
date = getDateFromComponents(removeTimeValues(dateComponent));
|
||||
}
|
||||
if (!isDatePartValid(date, dateComponent)) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function isTimePartValid(date: Date, dateComponent: DateComponent<number>) {
|
||||
return (
|
||||
date.getHours() === dateComponent.hour &&
|
||||
date.getMinutes() === dateComponent.minute &&
|
||||
date.getSeconds() === dateComponent.second
|
||||
);
|
||||
}
|
||||
|
||||
function isDatePartValid(date: Date, dateComponent: DateComponent<number>) {
|
||||
return (
|
||||
date.getFullYear() === dateComponent.year &&
|
||||
date.getMonth() === dateComponent.month &&
|
||||
date.getDate() === dateComponent.day
|
||||
);
|
||||
}
|
||||
|
||||
function convertDateComponentToNumber(
|
||||
dateComponent: DateComponent<string>
|
||||
): DateComponent<number> {
|
||||
return {
|
||||
year: Number(dateComponent.year),
|
||||
// https://stackoverflow.com/questions/2552483/why-does-the-month-argument-range-from-0-to-11-in-javascripts-date-constructor
|
||||
month: Number(dateComponent.month) - 1,
|
||||
day: Number(dateComponent.day),
|
||||
hour: Number(dateComponent.hour),
|
||||
minute: Number(dateComponent.minute),
|
||||
second: Number(dateComponent.second),
|
||||
};
|
||||
}
|
||||
|
||||
function getDateFromComponents(dateComponent: DateComponent<number>) {
|
||||
const { year, month, day, hour, minute, second } = dateComponent;
|
||||
if (hasTimeValues(dateComponent)) {
|
||||
return new Date(year, month, day, hour, minute, second);
|
||||
} else {
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
}
|
||||
|
||||
function hasTimeValues(dateComponent: DateComponent<number>) {
|
||||
const { hour, minute, second } = dateComponent;
|
||||
return !isNaN(hour) && !isNaN(minute) && !isNaN(second);
|
||||
}
|
||||
|
||||
function removeTimeValues(
|
||||
dateComponent: DateComponent<number>
|
||||
): DateComponent<number> {
|
||||
return { ...dateComponent, hour: 0, minute: 0, second: 0 };
|
||||
}
|
10
apps/cast/src/worker/convert.worker.ts
Normal file
10
apps/cast/src/worker/convert.worker.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import * as Comlink from 'comlink';
|
||||
import { convertHEIC } from 'services/wasmHeicConverter/wasmHEICConverterClient';
|
||||
|
||||
export class DedicatedConvertWorker {
|
||||
async convertHEIC(fileBlob: Blob, format: string) {
|
||||
return convertHEIC(fileBlob, format);
|
||||
}
|
||||
}
|
||||
|
||||
Comlink.expose(DedicatedConvertWorker, self);
|
215
apps/cast/src/worker/crypto.worker.ts
Normal file
215
apps/cast/src/worker/crypto.worker.ts
Normal file
|
@ -0,0 +1,215 @@
|
|||
import * as Comlink from 'comlink';
|
||||
import { StateAddress } from 'libsodium-wrappers';
|
||||
import * as libsodium from '@ente/shared/crypto/internal/libsodium';
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
const textEncoder = new TextEncoder();
|
||||
export class DedicatedCryptoWorker {
|
||||
async decryptMetadata(
|
||||
encryptedMetadata: string,
|
||||
header: string,
|
||||
key: string
|
||||
) {
|
||||
const encodedMetadata = await libsodium.decryptChaChaOneShot(
|
||||
await libsodium.fromB64(encryptedMetadata),
|
||||
await libsodium.fromB64(header),
|
||||
key
|
||||
);
|
||||
return JSON.parse(textDecoder.decode(encodedMetadata));
|
||||
}
|
||||
|
||||
async decryptThumbnail(
|
||||
fileData: Uint8Array,
|
||||
header: Uint8Array,
|
||||
key: string
|
||||
) {
|
||||
return libsodium.decryptChaChaOneShot(fileData, header, key);
|
||||
}
|
||||
|
||||
async decryptEmbedding(
|
||||
encryptedEmbedding: string,
|
||||
header: string,
|
||||
key: string
|
||||
) {
|
||||
const encodedEmbedding = await libsodium.decryptChaChaOneShot(
|
||||
await libsodium.fromB64(encryptedEmbedding),
|
||||
await libsodium.fromB64(header),
|
||||
key
|
||||
);
|
||||
return Float32Array.from(
|
||||
JSON.parse(textDecoder.decode(encodedEmbedding))
|
||||
);
|
||||
}
|
||||
|
||||
async decryptFile(fileData: Uint8Array, header: Uint8Array, key: string) {
|
||||
return libsodium.decryptChaCha(fileData, header, key);
|
||||
}
|
||||
|
||||
async encryptMetadata(metadata: Object, key: string) {
|
||||
const encodedMetadata = textEncoder.encode(JSON.stringify(metadata));
|
||||
|
||||
const { file: encryptedMetadata } =
|
||||
await libsodium.encryptChaChaOneShot(encodedMetadata, key);
|
||||
const { encryptedData, ...other } = encryptedMetadata;
|
||||
return {
|
||||
file: {
|
||||
encryptedData: await libsodium.toB64(encryptedData),
|
||||
...other,
|
||||
},
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
async encryptThumbnail(fileData: Uint8Array, key: string) {
|
||||
return libsodium.encryptChaChaOneShot(fileData, key);
|
||||
}
|
||||
|
||||
async encryptEmbedding(embedding: Float32Array, key: string) {
|
||||
const encodedEmbedding = textEncoder.encode(
|
||||
JSON.stringify(Array.from(embedding))
|
||||
);
|
||||
const { file: encryptEmbedding } = await libsodium.encryptChaChaOneShot(
|
||||
encodedEmbedding,
|
||||
key
|
||||
);
|
||||
const { encryptedData, ...other } = encryptEmbedding;
|
||||
return {
|
||||
file: {
|
||||
encryptedData: await libsodium.toB64(encryptedData),
|
||||
...other,
|
||||
},
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
async encryptFile(fileData: Uint8Array) {
|
||||
return libsodium.encryptChaCha(fileData);
|
||||
}
|
||||
|
||||
async encryptFileChunk(
|
||||
data: Uint8Array,
|
||||
pushState: StateAddress,
|
||||
isFinalChunk: boolean
|
||||
) {
|
||||
return libsodium.encryptFileChunk(data, pushState, isFinalChunk);
|
||||
}
|
||||
|
||||
async initChunkEncryption() {
|
||||
return libsodium.initChunkEncryption();
|
||||
}
|
||||
|
||||
async initChunkDecryption(header: Uint8Array, key: Uint8Array) {
|
||||
return libsodium.initChunkDecryption(header, key);
|
||||
}
|
||||
|
||||
async decryptFileChunk(fileData: Uint8Array, pullState: StateAddress) {
|
||||
return libsodium.decryptFileChunk(fileData, pullState);
|
||||
}
|
||||
|
||||
async initChunkHashing() {
|
||||
return libsodium.initChunkHashing();
|
||||
}
|
||||
|
||||
async hashFileChunk(hashState: StateAddress, chunk: Uint8Array) {
|
||||
return libsodium.hashFileChunk(hashState, chunk);
|
||||
}
|
||||
|
||||
async completeChunkHashing(hashState: StateAddress) {
|
||||
return libsodium.completeChunkHashing(hashState);
|
||||
}
|
||||
|
||||
async deriveKey(
|
||||
passphrase: string,
|
||||
salt: string,
|
||||
opsLimit: number,
|
||||
memLimit: number
|
||||
) {
|
||||
return libsodium.deriveKey(passphrase, salt, opsLimit, memLimit);
|
||||
}
|
||||
|
||||
async deriveSensitiveKey(passphrase: string, salt: string) {
|
||||
return libsodium.deriveSensitiveKey(passphrase, salt);
|
||||
}
|
||||
|
||||
async deriveInteractiveKey(passphrase: string, salt: string) {
|
||||
return libsodium.deriveInteractiveKey(passphrase, salt);
|
||||
}
|
||||
|
||||
async decryptB64(data: string, nonce: string, key: string) {
|
||||
return libsodium.decryptB64(data, nonce, key);
|
||||
}
|
||||
|
||||
async decryptToUTF8(data: string, nonce: string, key: string) {
|
||||
return libsodium.decryptToUTF8(data, nonce, key);
|
||||
}
|
||||
|
||||
async encryptToB64(data: string, key: string) {
|
||||
return libsodium.encryptToB64(data, key);
|
||||
}
|
||||
|
||||
async generateKeyAndEncryptToB64(data: string) {
|
||||
return libsodium.generateKeyAndEncryptToB64(data);
|
||||
}
|
||||
|
||||
async encryptUTF8(data: string, key: string) {
|
||||
return libsodium.encryptUTF8(data, key);
|
||||
}
|
||||
|
||||
async generateEncryptionKey() {
|
||||
return libsodium.generateEncryptionKey();
|
||||
}
|
||||
|
||||
async generateSaltToDeriveKey() {
|
||||
return libsodium.generateSaltToDeriveKey();
|
||||
}
|
||||
|
||||
async generateKeyPair() {
|
||||
return libsodium.generateKeyPair();
|
||||
}
|
||||
|
||||
async boxSealOpen(input: string, publicKey: string, secretKey: string) {
|
||||
return libsodium.boxSealOpen(input, publicKey, secretKey);
|
||||
}
|
||||
|
||||
async boxSeal(input: string, publicKey: string) {
|
||||
return libsodium.boxSeal(input, publicKey);
|
||||
}
|
||||
|
||||
async generateSubKey(
|
||||
key: string,
|
||||
subKeyLength: number,
|
||||
subKeyID: number,
|
||||
context: string
|
||||
) {
|
||||
return libsodium.generateSubKey(key, subKeyLength, subKeyID, context);
|
||||
}
|
||||
|
||||
async fromUTF8(string: string) {
|
||||
return libsodium.fromUTF8(string);
|
||||
}
|
||||
async toUTF8(data: string) {
|
||||
return libsodium.toUTF8(data);
|
||||
}
|
||||
|
||||
async toB64(data: Uint8Array) {
|
||||
return libsodium.toB64(data);
|
||||
}
|
||||
|
||||
async toURLSafeB64(data: Uint8Array) {
|
||||
return libsodium.toURLSafeB64(data);
|
||||
}
|
||||
|
||||
async fromB64(string: string) {
|
||||
return libsodium.fromB64(string);
|
||||
}
|
||||
|
||||
async toHex(string: string) {
|
||||
return libsodium.toHex(string);
|
||||
}
|
||||
|
||||
async fromHex(string: string) {
|
||||
return libsodium.fromHex(string);
|
||||
}
|
||||
}
|
||||
|
||||
Comlink.expose(DedicatedCryptoWorker, self);
|
15
apps/cast/src/worker/ffmpeg.worker.ts
Normal file
15
apps/cast/src/worker/ffmpeg.worker.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import * as Comlink from 'comlink';
|
||||
import { WasmFFmpeg } from 'services/wasm/ffmpeg';
|
||||
|
||||
export class DedicatedFFmpegWorker {
|
||||
wasmFFmpeg: WasmFFmpeg;
|
||||
constructor() {
|
||||
this.wasmFFmpeg = new WasmFFmpeg();
|
||||
}
|
||||
|
||||
run(cmd, inputFile, outputFileName, dontTimeout) {
|
||||
return this.wasmFFmpeg.run(cmd, inputFile, outputFileName, dontTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
Comlink.expose(DedicatedFFmpegWorker, self);
|
25
apps/cast/tsconfig.json
Normal file
25
apps/cast/tsconfig.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"downlevelIteration": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@emotion/react",
|
||||
"lib": ["dom", "dom.iterable", "esnext", "webworker"],
|
||||
"noImplicitAny": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"strictNullChecks": false,
|
||||
"target": "es5",
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.js",
|
||||
"../../packages/shared/themes/mui-theme.d.ts",
|
||||
"../../packages/accounts/**/*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules", "out", ".next", "thirdparty"]
|
||||
}
|
|
@ -625,6 +625,17 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "Route uploads through nearby servers",
|
||||
"MAGIC_SEARCH_STATUS": "Magic Search Status",
|
||||
"INDEXED_ITEMS": "Indexed items",
|
||||
"CAST_ALBUM_TO_TV": "Play album on TV",
|
||||
"ENTER_CAST_PIN_CODE": "Enter the code you see on the TV below to pair this device.",
|
||||
"PAIR_DEVICE_TO_TV": "Pair devices",
|
||||
"TV_NOT_FOUND": "TV not found. Did you enter the PIN correctly?",
|
||||
"AUTO_CAST_PAIR": "Auto Pair",
|
||||
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "Auto Pair requires connecting to Google servers and only works with Chromecast supported devices. Google will not receive sensitive data, such as your photos.",
|
||||
"PAIR_WITH_PIN": "Pair with PIN",
|
||||
"CHOOSE_DEVICE_FROM_BROWSER": "Choose a cast-compatible device from the browser popup.",
|
||||
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "Pair with PIN works for any large screen device you want to play your album on.",
|
||||
"VISIT_CAST_ENTE_IO": "Visit cast.ente.io on the device you want to pair.",
|
||||
"CAST_AUTO_PAIR_FAILED": "Chromecast Auto Pair failed. Please try again.",
|
||||
"CACHE_DIRECTORY": "Cache folder",
|
||||
"FREEHAND": "Freehand",
|
||||
"APPLY_CROP": "Apply Crop",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { CollectionInfo } from './CollectionInfo';
|
||||
import React, { Dispatch, SetStateAction } from 'react';
|
||||
import { Collection, CollectionSummary } from 'types/collection';
|
||||
import CollectionOptions from 'components/Collections/CollectionOptions';
|
||||
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer';
|
||||
|
@ -20,6 +21,7 @@ interface Iprops {
|
|||
setFilesDownloadProgressAttributesCreator: SetFilesDownloadProgressAttributesCreator;
|
||||
isActiveCollectionDownloadInProgress: () => boolean;
|
||||
setActiveCollectionID: (collectionID: number) => void;
|
||||
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function CollectionInfoWithOptions({
|
||||
|
@ -49,6 +51,7 @@ export default function CollectionInfoWithOptions({
|
|||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CollectionInfoBarWrapper>
|
||||
<SpaceBetweenFlex>
|
||||
|
|
|
@ -0,0 +1,238 @@
|
|||
import { VerticallyCentered } from '@ente/shared/components/Container';
|
||||
import DialogBoxV2 from '@ente/shared/components/DialogBoxV2';
|
||||
import EnteButton from '@ente/shared/components/EnteButton';
|
||||
import EnteSpinner from '@ente/shared/components/EnteSpinner';
|
||||
import SingleInputForm, {
|
||||
SingleInputFormProps,
|
||||
} from '@ente/shared/components/SingleInputForm';
|
||||
import { boxSeal } from '@ente/shared/crypto/internal/libsodium';
|
||||
import { loadSender } from '@ente/shared/hooks/useCastSender';
|
||||
import { addLogLine } from '@ente/shared/logging';
|
||||
import castGateway from '@ente/shared/network/cast';
|
||||
import { logError } from '@ente/shared/sentry';
|
||||
import { Typography } from '@mui/material';
|
||||
import { t } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Collection } from 'types/collection';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
currentCollection: Collection;
|
||||
}
|
||||
|
||||
enum AlbumCastError {
|
||||
TV_NOT_FOUND = 'TV_NOT_FOUND',
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
chrome: any;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AlbumCastDialog(props: Props) {
|
||||
const [view, setView] = useState<
|
||||
'choose' | 'auto' | 'pin' | 'auto-cast-error'
|
||||
>('choose');
|
||||
|
||||
const [browserCanCast, setBrowserCanCast] = useState(false);
|
||||
// Make API call on component mount
|
||||
useEffect(() => {
|
||||
castGateway.revokeAllTokens();
|
||||
|
||||
setBrowserCanCast(!!window.chrome);
|
||||
}, []);
|
||||
|
||||
const onSubmit: SingleInputFormProps['callback'] = async (
|
||||
value,
|
||||
setFieldError
|
||||
) => {
|
||||
try {
|
||||
await doCast(value);
|
||||
props.onHide();
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
let fieldError: string;
|
||||
switch (error.message) {
|
||||
case AlbumCastError.TV_NOT_FOUND:
|
||||
fieldError = t('TV_NOT_FOUND');
|
||||
break;
|
||||
default:
|
||||
fieldError = t('UNKNOWN_ERROR');
|
||||
break;
|
||||
}
|
||||
|
||||
setFieldError(fieldError);
|
||||
}
|
||||
};
|
||||
|
||||
const doCast = async (pin: string) => {
|
||||
// does the TV exist? have they advertised their existence?
|
||||
const tvPublicKeyB64 = await castGateway.getPublicKey(pin);
|
||||
if (!tvPublicKeyB64) {
|
||||
throw new Error(AlbumCastError.TV_NOT_FOUND);
|
||||
}
|
||||
// generate random uuid string
|
||||
const castToken = uuidv4();
|
||||
|
||||
// ok, they exist. let's give them the good stuff.
|
||||
const payload = JSON.stringify({
|
||||
castToken: castToken,
|
||||
collectionID: props.currentCollection.id,
|
||||
collectionKey: props.currentCollection.key,
|
||||
});
|
||||
const encryptedPayload = await boxSeal(btoa(payload), tvPublicKeyB64);
|
||||
|
||||
// hey TV, we acknowlege you!
|
||||
await castGateway.publishCastPayload(
|
||||
pin,
|
||||
encryptedPayload,
|
||||
props.currentCollection.id,
|
||||
castToken
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'auto') {
|
||||
loadSender().then(async (sender) => {
|
||||
const { cast } = sender;
|
||||
|
||||
const instance = await cast.framework.CastContext.getInstance();
|
||||
try {
|
||||
await instance.requestSession();
|
||||
} catch (e) {
|
||||
setView('auto-cast-error');
|
||||
logError(e, 'Error requesting session');
|
||||
return;
|
||||
}
|
||||
const session = instance.getCurrentSession();
|
||||
session.addMessageListener(
|
||||
'urn:x-cast:pair-request',
|
||||
(_, message) => {
|
||||
const data = message;
|
||||
const obj = JSON.parse(data);
|
||||
const code = obj.code;
|
||||
|
||||
if (code) {
|
||||
doCast(code)
|
||||
.then(() => {
|
||||
setView('choose');
|
||||
props.onHide();
|
||||
})
|
||||
.catch((e) => {
|
||||
setView('auto-cast-error');
|
||||
logError(e, 'Error casting to TV');
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
session
|
||||
.sendMessage('urn:x-cast:pair-request', {})
|
||||
.then(() => {
|
||||
addLogLine('Message sent successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(error, 'Error sending message');
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.show) {
|
||||
castGateway.revokeAllTokens();
|
||||
}
|
||||
}, [props.show]);
|
||||
|
||||
return (
|
||||
<DialogBoxV2
|
||||
sx={{ zIndex: 1600 }}
|
||||
open={props.show}
|
||||
onClose={props.onHide}
|
||||
attributes={{
|
||||
title: t('CAST_ALBUM_TO_TV'),
|
||||
}}>
|
||||
{view === 'choose' && (
|
||||
<>
|
||||
{browserCanCast && (
|
||||
<>
|
||||
<Typography color={'text.muted'}>
|
||||
{t(
|
||||
'AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE'
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<EnteButton
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
onClick={() => {
|
||||
setView('auto');
|
||||
}}>
|
||||
{t('AUTO_CAST_PAIR')}
|
||||
</EnteButton>
|
||||
</>
|
||||
)}
|
||||
<Typography color="text.muted">
|
||||
{t('PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE')}
|
||||
</Typography>
|
||||
|
||||
<EnteButton
|
||||
onClick={() => {
|
||||
setView('pin');
|
||||
}}>
|
||||
{t('PAIR_WITH_PIN')}
|
||||
</EnteButton>
|
||||
</>
|
||||
)}
|
||||
{view === 'auto' && (
|
||||
<VerticallyCentered gap="1rem">
|
||||
<EnteSpinner />
|
||||
<Typography>{t('CHOOSE_DEVICE_FROM_BROWSER')}</Typography>
|
||||
<EnteButton
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setView('choose');
|
||||
}}>
|
||||
{t('GO_BACK')}
|
||||
</EnteButton>
|
||||
</VerticallyCentered>
|
||||
)}
|
||||
{view === 'auto-cast-error' && (
|
||||
<VerticallyCentered gap="1rem">
|
||||
<Typography>{t('CAST_AUTO_PAIR_FAILED')}</Typography>
|
||||
<EnteButton
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setView('choose');
|
||||
}}>
|
||||
{t('GO_BACK')}
|
||||
</EnteButton>
|
||||
</VerticallyCentered>
|
||||
)}
|
||||
{view === 'pin' && (
|
||||
<>
|
||||
<Typography>{t('VISIT_CAST_ENTE_IO')}</Typography>
|
||||
<Typography>{t('ENTER_CAST_PIN_CODE')}</Typography>
|
||||
<SingleInputForm
|
||||
callback={onSubmit}
|
||||
fieldType="text"
|
||||
placeholder={'123456'}
|
||||
buttonText={t('PAIR_DEVICE_TO_TV')}
|
||||
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
|
||||
/>
|
||||
<EnteButton
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setView('choose');
|
||||
}}>
|
||||
{t('GO_BACK')}
|
||||
</EnteButton>
|
||||
</>
|
||||
)}
|
||||
</DialogBoxV2>
|
||||
);
|
||||
}
|
|
@ -13,6 +13,7 @@ import PushPinOutlined from '@mui/icons-material/PushPinOutlined';
|
|||
import { UnPinIcon } from 'components/icons/UnPinIcon';
|
||||
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
|
||||
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
|
||||
import TvIcon from '@mui/icons-material/Tv';
|
||||
|
||||
interface Iprops {
|
||||
isArchived: boolean;
|
||||
|
@ -123,6 +124,14 @@ export function AlbumCollectionOption({
|
|||
startIcon={<PeopleIcon />}>
|
||||
{t('SHARE_COLLECTION')}
|
||||
</OverflowMenuOption>
|
||||
<OverflowMenuOption
|
||||
startIcon={<TvIcon />}
|
||||
onClick={handleCollectionAction(
|
||||
CollectionActions.SHOW_ALBUM_CAST_DIALOG,
|
||||
false
|
||||
)}>
|
||||
{t('CAST_ALBUM_TO_TV')}
|
||||
</OverflowMenuOption>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import { AlbumCollectionOption } from './AlbumCollectionOption';
|
||||
import React, { useContext, useRef, useState } from 'react';
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import * as CollectionAPI from 'services/collectionService';
|
||||
import * as TrashService from 'services/trashService';
|
||||
import {
|
||||
|
@ -43,6 +49,7 @@ interface CollectionOptionsProps {
|
|||
collectionSummaryType: CollectionSummaryType;
|
||||
showCollectionShareModal: () => void;
|
||||
setActiveCollectionID: (collectionID: number) => void;
|
||||
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export enum CollectionActions {
|
||||
|
@ -65,6 +72,7 @@ export enum CollectionActions {
|
|||
UNPIN,
|
||||
HIDE,
|
||||
UNHIDE,
|
||||
SHOW_ALBUM_CAST_DIALOG,
|
||||
}
|
||||
|
||||
const CollectionOptions = (props: CollectionOptionsProps) => {
|
||||
|
@ -76,6 +84,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
showCollectionShareModal,
|
||||
setFilesDownloadProgressAttributesCreator,
|
||||
isActiveCollectionDownloadInProgress,
|
||||
setShowAlbumCastDialog,
|
||||
} = props;
|
||||
|
||||
const { startLoading, finishLoading, setDialogMessage } =
|
||||
|
@ -96,7 +105,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
action: CollectionActions,
|
||||
loader = true
|
||||
) => {
|
||||
let callback;
|
||||
let callback: Function;
|
||||
switch (action) {
|
||||
case CollectionActions.SHOW_RENAME_DIALOG:
|
||||
callback = showRenameCollectionModal;
|
||||
|
@ -155,6 +164,9 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
case CollectionActions.UNHIDE:
|
||||
callback = unHideAlbum;
|
||||
break;
|
||||
case CollectionActions.SHOW_ALBUM_CAST_DIALOG:
|
||||
callback = showCastAlbumDialog;
|
||||
break;
|
||||
|
||||
default:
|
||||
logError(
|
||||
|
@ -165,7 +177,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
action;
|
||||
}
|
||||
}
|
||||
return async (...args) => {
|
||||
return async (...args: any) => {
|
||||
try {
|
||||
loader && startLoading();
|
||||
await callback(...args);
|
||||
|
@ -183,6 +195,10 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
|
|||
};
|
||||
};
|
||||
|
||||
const showCastAlbumDialog = () => {
|
||||
setShowAlbumCastDialog(true);
|
||||
};
|
||||
|
||||
const renameCollection = async (newName: string) => {
|
||||
if (activeCollection.name !== newName) {
|
||||
await CollectionAPI.renameCollection(activeCollection, newName);
|
||||
|
|
|
@ -21,6 +21,7 @@ import {
|
|||
isFilesDownloadCompleted,
|
||||
} from '../FilesDownloadProgress';
|
||||
import { SetFilesDownloadProgressAttributesCreator } from 'types/gallery';
|
||||
import AlbumCastDialog from './CollectionOptions/AlbumCastDialog';
|
||||
|
||||
interface Iprops {
|
||||
activeCollection: Collection;
|
||||
|
@ -55,6 +56,8 @@ export default function Collections(props: Iprops) {
|
|||
const [collectionShareModalView, setCollectionShareModalView] =
|
||||
useState(false);
|
||||
|
||||
const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(false);
|
||||
|
||||
const [collectionListSortBy, setCollectionListSortBy] =
|
||||
useLocalState<COLLECTION_LIST_SORT_BY>(
|
||||
LS_KEYS.COLLECTION_SORT_BY,
|
||||
|
@ -117,6 +120,7 @@ export default function Collections(props: Iprops) {
|
|||
isActiveCollectionDownloadInProgress
|
||||
}
|
||||
setActiveCollectionID={setActiveCollectionID}
|
||||
setShowAlbumCastDialog={setShowAlbumCastDialog}
|
||||
/>
|
||||
),
|
||||
itemType: ITEM_TYPE.HEADER,
|
||||
|
@ -136,6 +140,7 @@ export default function Collections(props: Iprops) {
|
|||
const closeAllCollections = () => setAllCollectionView(false);
|
||||
const openAllCollections = () => setAllCollectionView(true);
|
||||
const closeCollectionShare = () => setCollectionShareModalView(false);
|
||||
const closeAlbumCastDialog = () => setShowAlbumCastDialog(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -171,6 +176,11 @@ export default function Collections(props: Iprops) {
|
|||
onClose={closeCollectionShare}
|
||||
collection={activeCollection}
|
||||
/>
|
||||
<AlbumCastDialog
|
||||
currentCollection={activeCollection}
|
||||
show={showAlbumCastDialog}
|
||||
onHide={closeAlbumCastDialog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,10 +9,13 @@
|
|||
"scripts": {
|
||||
"build:photos": "turbo run build --filter=photos",
|
||||
"build:auth": "turbo run build --filter=auth",
|
||||
"build:cast": "turbo run build --filter=cast",
|
||||
"dev:auth": "turbo run dev --filter=auth",
|
||||
"dev:photos": "turbo run dev --filter=photos",
|
||||
"dev:cast": "turbo run dev --filter=cast",
|
||||
"export:photos": "turbo run export --filter=photos",
|
||||
"export:auth": "turbo run export --filter=auth",
|
||||
"export:cast": "turbo run export --filter=cast",
|
||||
"lint": "turbo run lint",
|
||||
"albums": "turbo run albums",
|
||||
"prepare": "husky install"
|
||||
|
|
44
packages/shared/hooks/useCastReceiver.tsx
Normal file
44
packages/shared/hooks/useCastReceiver.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
declare const cast: any;
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Receiver = {
|
||||
cast: typeof cast;
|
||||
};
|
||||
|
||||
const load = (() => {
|
||||
let promise: Promise<Receiver> | null = null;
|
||||
|
||||
return () => {
|
||||
if (promise === null) {
|
||||
promise = new Promise((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src =
|
||||
'https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js';
|
||||
|
||||
script.addEventListener('load', () => {
|
||||
resolve({
|
||||
cast,
|
||||
});
|
||||
});
|
||||
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
})();
|
||||
|
||||
export const useCastReceiver = () => {
|
||||
const [receiver, setReceiver] = useState<Receiver | null>({
|
||||
cast: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
load().then((receiver) => {
|
||||
setReceiver(receiver);
|
||||
});
|
||||
});
|
||||
|
||||
return receiver;
|
||||
};
|
63
packages/shared/hooks/useCastSender.tsx
Normal file
63
packages/shared/hooks/useCastSender.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
declare const chrome: any;
|
||||
declare const cast: any;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__onGCastApiAvailable: (isAvailable: boolean) => void;
|
||||
}
|
||||
}
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Sender = {
|
||||
chrome: typeof chrome;
|
||||
cast: typeof cast;
|
||||
};
|
||||
|
||||
export const loadSender = (() => {
|
||||
let promise: Promise<Sender> | null = null;
|
||||
|
||||
return () => {
|
||||
if (promise === null) {
|
||||
promise = new Promise((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src =
|
||||
'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
|
||||
window.__onGCastApiAvailable = (isAvailable) => {
|
||||
if (isAvailable) {
|
||||
cast.framework.CastContext.getInstance().setOptions({
|
||||
receiverApplicationId:
|
||||
process.env.NEXT_PUBLIC_CAST_APP_ID,
|
||||
autoJoinPolicy:
|
||||
chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
|
||||
resolve({
|
||||
chrome,
|
||||
cast,
|
||||
});
|
||||
}
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
})();
|
||||
|
||||
export const useCastSender = () => {
|
||||
const [sender, setSender] = useState<Sender | { chrome: null; cast: null }>(
|
||||
{
|
||||
chrome: null,
|
||||
cast: null,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSender().then((sender) => {
|
||||
setSender(sender);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return sender;
|
||||
};
|
|
@ -22,6 +22,22 @@ export const getPublicCollectionFileURL = (id: number) => {
|
|||
return `https://public-albums.ente.io/download/?fileID=${id}`;
|
||||
};
|
||||
|
||||
export const getCastFileURL = (id: number) => {
|
||||
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
|
||||
if (isDevDeployment() && endpoint) {
|
||||
return `${endpoint}/cast/files/download/${id}`;
|
||||
}
|
||||
return `https://cast-albums.ente.io/download/?fileID=${id}`;
|
||||
};
|
||||
|
||||
export const getCastThumbnailURL = (id: number) => {
|
||||
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
|
||||
if (isDevDeployment() && endpoint) {
|
||||
return `${endpoint}/cast/files/preview/${id}`;
|
||||
}
|
||||
return `https://cast-albums.ente.io/preview/?fileID=${id}`;
|
||||
};
|
||||
|
||||
export const getThumbnailURL = (id: number) => {
|
||||
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
|
||||
if (isDevDeployment() && endpoint) {
|
||||
|
|
89
packages/shared/network/cast.ts
Normal file
89
packages/shared/network/cast.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { ApiError } from '../error';
|
||||
import { logError } from '../sentry';
|
||||
import { getToken } from '../storage/localStorage/helpers';
|
||||
import HTTPService from './HTTPService';
|
||||
import { getEndpoint } from './api';
|
||||
|
||||
class CastGateway {
|
||||
constructor() {}
|
||||
|
||||
public async getCastData(code: string): Promise<string | null> {
|
||||
let resp;
|
||||
try {
|
||||
resp = await HTTPService.get(
|
||||
`${getEndpoint()}/cast/cast-data/${code}`
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, 'failed to getCastData');
|
||||
throw e;
|
||||
}
|
||||
return resp.data.encCastData;
|
||||
}
|
||||
|
||||
public async revokeAllTokens() {
|
||||
try {
|
||||
const token = getToken();
|
||||
await HTTPService.delete(
|
||||
getEndpoint() + '/cast/revoke-all-tokens/',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
'X-Auth-Token': token,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, 'removeAllTokens failed');
|
||||
// swallow error
|
||||
}
|
||||
}
|
||||
|
||||
public async getPublicKey(code: string): Promise<string> {
|
||||
let resp;
|
||||
try {
|
||||
const token = getToken();
|
||||
resp = await HTTPService.get(
|
||||
`${getEndpoint()}/cast/device-info/${code}`,
|
||||
undefined,
|
||||
{
|
||||
'X-Auth-Token': token,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.httpStatusCode === 404) {
|
||||
return '';
|
||||
}
|
||||
logError(e, 'failed to getPublicKey');
|
||||
throw e;
|
||||
}
|
||||
return resp.data.publicKey;
|
||||
}
|
||||
|
||||
public async registerDevice(code: string, publicKey: string) {
|
||||
await HTTPService.post(getEndpoint() + '/cast/device-info/', {
|
||||
deviceCode: `${code}`,
|
||||
publicKey: publicKey,
|
||||
});
|
||||
}
|
||||
|
||||
public async publishCastPayload(
|
||||
code: string,
|
||||
castPayload: string,
|
||||
collectionID: number,
|
||||
castToken: string
|
||||
) {
|
||||
const token = getToken();
|
||||
await HTTPService.post(
|
||||
getEndpoint() + '/cast/cast-data/',
|
||||
{
|
||||
deviceCode: `${code}`,
|
||||
encPayload: castPayload,
|
||||
collectionID: collectionID,
|
||||
castToken: castToken,
|
||||
},
|
||||
undefined,
|
||||
{ 'X-Auth-Token': token }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CastGateway();
|
23
packages/shared/promise/index.ts
Normal file
23
packages/shared/promise/index.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { CustomError } from '../error';
|
||||
|
||||
export const promiseWithTimeout = async <T>(
|
||||
request: Promise<T>,
|
||||
timeout: number
|
||||
): Promise<T> => {
|
||||
const timeoutRef = { current: null };
|
||||
const rejectOnTimeout = new Promise<null>((_, reject) => {
|
||||
timeoutRef.current = setTimeout(
|
||||
() => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
|
||||
timeout
|
||||
);
|
||||
});
|
||||
const requestWithTimeOutCancellation = async () => {
|
||||
const resp = await request;
|
||||
clearTimeout(timeoutRef.current);
|
||||
return resp;
|
||||
};
|
||||
return await Promise.race([
|
||||
requestWithTimeOutCancellation(),
|
||||
rejectOnTimeout,
|
||||
]);
|
||||
};
|
5
packages/shared/sleep/index.ts
Normal file
5
packages/shared/sleep/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export async function sleep(time: number) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => resolve(null), time);
|
||||
});
|
||||
}
|
70
yarn.lock
70
yarn.lock
|
@ -1170,6 +1170,14 @@ ansi-styles@^6.0.0:
|
|||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||
|
||||
anymatch@~3.1.2:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
|
||||
integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
|
||||
dependencies:
|
||||
normalize-path "^3.0.0"
|
||||
picomatch "^2.0.4"
|
||||
|
||||
argparse@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
|
||||
|
@ -1309,6 +1317,11 @@ base-x@^4.0.0:
|
|||
resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a"
|
||||
integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==
|
||||
|
||||
binary-extensions@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
|
||||
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
|
||||
|
||||
bip39@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.npmjs.org/bip39/-/bip39-3.0.4.tgz"
|
||||
|
@ -1344,7 +1357,7 @@ brace-expansion@^2.0.1:
|
|||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
|
||||
braces@^3.0.2:
|
||||
braces@^3.0.2, braces@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz"
|
||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||
|
@ -1411,6 +1424,21 @@ chalk@^4.0.0, chalk@^4.1.0:
|
|||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
"chokidar@>=3.0.0 <4.0.0":
|
||||
version "3.5.3"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
|
||||
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
|
||||
dependencies:
|
||||
anymatch "~3.1.2"
|
||||
braces "~3.0.2"
|
||||
glob-parent "~5.1.2"
|
||||
is-binary-path "~2.1.0"
|
||||
is-glob "~4.0.1"
|
||||
normalize-path "~3.0.0"
|
||||
readdirp "~3.6.0"
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
chrono-node@^2.2.6:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.npmjs.org/chrono-node/-/chrono-node-2.3.1.tgz"
|
||||
|
@ -2336,7 +2364,7 @@ get-user-locale@^2.1.3:
|
|||
"@types/lodash.memoize" "^4.1.7"
|
||||
lodash.memoize "^4.1.1"
|
||||
|
||||
glob-parent@^5.1.2:
|
||||
glob-parent@^5.1.2, glob-parent@~5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
|
||||
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
|
||||
|
@ -2606,6 +2634,11 @@ immediate@~3.0.5:
|
|||
resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz"
|
||||
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
|
||||
|
||||
immutable@^4.0.0:
|
||||
version "4.3.4"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f"
|
||||
integrity sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==
|
||||
|
||||
import-fresh@^3.0.0, import-fresh@^3.2.1:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz"
|
||||
|
@ -2687,6 +2720,13 @@ is-bigint@^1.0.1:
|
|||
dependencies:
|
||||
has-bigints "^1.0.1"
|
||||
|
||||
is-binary-path@~2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
|
||||
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
|
||||
dependencies:
|
||||
binary-extensions "^2.0.0"
|
||||
|
||||
is-boolean-object@^1.1.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz"
|
||||
|
@ -2746,7 +2786,7 @@ is-fullwidth-code-point@^4.0.0:
|
|||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88"
|
||||
integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==
|
||||
|
||||
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3:
|
||||
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
|
||||
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
||||
|
@ -3177,7 +3217,7 @@ mime-db@1.52.0:
|
|||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
|
||||
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
|
||||
|
||||
mime-types@^2.1.12:
|
||||
mime-types@^2.1.12, mime-types@^2.1.35:
|
||||
version "2.1.35"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
|
||||
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
|
||||
|
@ -3330,7 +3370,7 @@ node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@~2.6.1:
|
|||
dependencies:
|
||||
whatwg-url "^5.0.0"
|
||||
|
||||
normalize-path@^3.0.0:
|
||||
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
|
||||
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
||||
|
@ -3584,7 +3624,7 @@ picocolors@^1.0.0:
|
|||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
|
||||
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
|
||||
|
||||
picomatch@^2.3.1:
|
||||
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
|
||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||
|
@ -3893,6 +3933,13 @@ readable-web-to-node-stream@^3.0.0:
|
|||
dependencies:
|
||||
readable-stream "^3.6.0"
|
||||
|
||||
readdirp@~3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
|
||||
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
|
||||
dependencies:
|
||||
picomatch "^2.2.1"
|
||||
|
||||
regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.7:
|
||||
version "0.13.11"
|
||||
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz"
|
||||
|
@ -4015,6 +4062,15 @@ sanitize-filename@^1.6.3:
|
|||
dependencies:
|
||||
truncate-utf8-bytes "^1.0.0"
|
||||
|
||||
sass@^1.69.5:
|
||||
version "1.69.5"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.5.tgz#23e18d1c757a35f2e52cc81871060b9ad653dfde"
|
||||
integrity sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==
|
||||
dependencies:
|
||||
chokidar ">=3.0.0 <4.0.0"
|
||||
immutable "^4.0.0"
|
||||
source-map-js ">=0.6.2 <2.0.0"
|
||||
|
||||
sax@^1.2.4:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz"
|
||||
|
@ -4138,7 +4194,7 @@ slice-ansi@^5.0.0:
|
|||
ansi-styles "^6.0.0"
|
||||
is-fullwidth-code-point "^4.0.0"
|
||||
|
||||
source-map-js@^1.0.2:
|
||||
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
|
||||
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||
|
|
Loading…
Add table
Reference in a new issue