Compare commits
236 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
6a13ca347d | ||
|
9eb342e6d2 | ||
|
e497ea51f1 | ||
|
a8bffc4b27 | ||
|
3295032882 | ||
|
93ff9b62d6 | ||
|
5850b1ac87 | ||
|
97fee5e6d4 | ||
|
903998913f | ||
|
2197d9411e | ||
|
e6e2fea870 | ||
|
429591c445 | ||
|
95a5a8ae9b | ||
|
a5172b8fb4 | ||
|
1b0be14175 | ||
|
4a5f0aa52c | ||
|
1f0abf5169 | ||
|
1137ccfd3b | ||
|
714e71751e | ||
|
3935396709 | ||
|
7dc2683180 | ||
|
dab88f7ed8 | ||
|
187bf9d745 | ||
|
a84b21a501 | ||
|
4a1780ab7f | ||
|
6a4de1be28 | ||
|
d8b274f554 | ||
|
0bee4b1ade | ||
|
a3a273a4b1 | ||
|
158ba6f28f | ||
|
d98cb4c2d7 | ||
|
f9c0decd4c | ||
|
9225b31986 | ||
|
066a47c82d | ||
|
1f38bf822c | ||
|
e8967c33d3 | ||
|
4f92ccf813 | ||
|
7e71701e10 | ||
|
a2e08b9ccb | ||
|
bf0b9f55e5 | ||
|
698905db2e | ||
|
712318612d | ||
|
8af4c69be3 | ||
|
e61ac61e20 | ||
|
a3c9ccf5df | ||
|
6e21fc56eb | ||
|
ef7fc8781b | ||
|
0d3044c5e6 | ||
|
fd5f7c36b2 | ||
|
6b09bd4688 | ||
|
66401c6c5f | ||
|
64680e162a | ||
|
96142a3a0c | ||
|
3651b98b2d | ||
|
dc0803d292 | ||
|
8934b25c47 | ||
|
238295888c | ||
|
f5b9f59e43 | ||
|
0b631b31b3 | ||
|
b4dd9efd92 | ||
|
36de546fe2 | ||
|
78db8d5eef | ||
|
2573089378 | ||
|
c45c1d13c0 | ||
|
631f8bddd8 | ||
|
ad9fd4f601 | ||
|
20d24eca43 | ||
|
ceee059ecf | ||
|
78a4c9adbf | ||
|
0f21c9b236 | ||
|
104c9004c5 | ||
|
0ae5cad2f5 | ||
|
24a75eaf80 | ||
|
384ea412ea | ||
|
346b9084b0 | ||
|
bbc7629190 | ||
|
137fdd8c03 | ||
|
010dfff672 | ||
|
20c45823ee | ||
|
60f4009947 | ||
|
efa09d7280 | ||
|
33dd4b9fd8 | ||
|
3e2c7a3c91 | ||
|
ded23ec29a | ||
|
424a16729e | ||
|
910e889f60 | ||
|
5fa5a0e7cb | ||
|
910cbcf236 | ||
|
2e317c3abe | ||
|
969058d70b | ||
|
52528ddee8 | ||
|
b2df289894 | ||
|
8e4d0cd03d | ||
|
89fccae33d | ||
|
b463ec7a7d | ||
|
540aee6194 | ||
|
dcc99f0e62 | ||
|
3d98b4f9e4 | ||
|
dcc5b5d2fd | ||
|
bc70cf4b6b | ||
|
8d7f0d984f | ||
|
935947cafc | ||
|
553b3f9091 | ||
|
c0b671e45f | ||
|
564fc65297 | ||
|
ff62a4c2e6 | ||
|
c31c484894 | ||
|
fa267ae54b | ||
|
ad23b70e9d | ||
|
fb739f5315 | ||
|
ce510a5746 | ||
|
ca3263f1f3 | ||
|
adaf502d66 | ||
|
039ccf91be | ||
|
95d9913e3e | ||
|
dc33c07b39 | ||
|
1f79bf6e52 | ||
|
cff47da742 | ||
|
7a042e3bfa | ||
|
0ce777cbfc | ||
|
23f28acff0 | ||
|
c8ea19a69c | ||
|
4f50b44e68 | ||
|
c5d8d33870 | ||
|
62dccf7b51 | ||
|
88d4b4dc7c | ||
|
1716c1d2af | ||
|
6c18f1d460 | ||
|
161b3a7e3c | ||
|
de5a2d10ca | ||
|
12ea601e6d | ||
|
c8ecf41b10 | ||
|
945f87d93b | ||
|
19a342457b | ||
|
61efa619a2 | ||
|
50df95b212 | ||
|
5464574a3e | ||
|
0a8323be54 | ||
|
ee459e8694 | ||
|
90dcc48cad | ||
|
590b42a574 | ||
|
ef08633bdb | ||
|
00d376d4ac | ||
|
6513ab38d0 | ||
|
a7c1317af7 | ||
|
2ae0fd01dd | ||
|
398c5402d2 | ||
|
cdfb6e0fd9 | ||
|
1590490db2 | ||
|
f2325bdc24 | ||
|
7caee22aee | ||
|
d15f1ec8f2 | ||
|
00106e9379 | ||
|
fd1a7530ed | ||
|
b7997c220e | ||
|
5d7724762d | ||
|
affe49474d | ||
|
91f5d63b93 | ||
|
1c34d2daff | ||
|
b6472d5406 | ||
|
3a96c8ae56 | ||
|
e7d4b72c8c | ||
|
a43e7a629b | ||
|
c7c9cf2f0f | ||
|
75cda47633 | ||
|
c5e7b29c6c | ||
|
4f2c19b680 | ||
|
af18bcd43f | ||
|
7c3e1e6779 | ||
|
c3cc6c09f4 | ||
|
73d2f45dae | ||
|
de66ac6b08 | ||
|
d4684fa1f7 | ||
|
1e6b1cb201 | ||
|
44a99bdb3a | ||
|
c4c47bdc27 | ||
|
192635f2ce | ||
|
2279b5d845 | ||
|
2273bb388f | ||
|
8a5b25b4ce | ||
|
b85771dc1d | ||
|
cc3e3be118 | ||
|
28eb9ebe5d | ||
|
8e9347b4a0 | ||
|
2812960088 | ||
|
f544768784 | ||
|
0e26424355 | ||
|
1ed2eef65a | ||
|
28d8927c08 | ||
|
2f2d39dc4c | ||
|
d649a00718 | ||
|
302ff4ff29 | ||
|
e02e7f2260 | ||
|
2b95af1b51 | ||
|
a892a37c53 | ||
|
abc4673af7 | ||
|
f816fae6ba | ||
|
2272bb5edd | ||
|
f0e67fb69f | ||
|
c8bd08a290 | ||
|
0749106b96 | ||
|
4b5fd1cda0 | ||
|
a6069f406f | ||
|
45e7b69937 | ||
|
806a644a40 | ||
|
41600dab4f | ||
|
a9515d376a | ||
|
52b7f9523f | ||
|
78d0670f50 | ||
|
06c348126e | ||
|
fec07d0e10 | ||
|
f5b47a2b7e | ||
|
6e6a792984 | ||
|
05e0f031ed | ||
|
11388cb418 | ||
|
bf4675a5e3 | ||
|
bc597c817f | ||
|
f06aa65801 | ||
|
e7c2872e40 | ||
|
5820736a31 | ||
|
06000cbc77 | ||
|
8c9f7ff36d | ||
|
73d0b24aaf | ||
|
5860efa620 | ||
|
f3ff3656ef | ||
|
eba8dc3767 | ||
|
3f46395bd2 | ||
|
a8bb64ffb1 | ||
|
13ec4f4faf | ||
|
fcab598ec4 | ||
|
11e3d7a8f4 | ||
|
13c4438a57 | ||
|
45434ba751 | ||
|
6d0ec5dd85 | ||
|
5d75ee493d | ||
|
91327220a0 |
@ -71,7 +71,7 @@ as long as you:
|
||||
|
||||
## open source acknowledgements
|
||||
### ffmpeg
|
||||
cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have an ability to use it for free, just like anyone else. we believe it should be way more recognized.
|
||||
cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have the ability to use it for free, just like anyone else. we believe it should be way more recognized.
|
||||
|
||||
you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
|
||||
|
||||
|
@ -35,6 +35,7 @@
|
||||
"ffmpeg-static": "^5.1.0",
|
||||
"hls-parser": "^0.10.7",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"mime": "^4.0.4",
|
||||
"nanoid": "^5.0.9",
|
||||
"set-cookie-parser": "2.6.0",
|
||||
"undici": "^5.19.1",
|
||||
|
@ -8,18 +8,19 @@ import jwt from "../security/jwt.js";
|
||||
import stream from "../stream/stream.js";
|
||||
import match from "../processing/match.js";
|
||||
|
||||
import { env, isCluster, setTunnelPort } from "../config.js";
|
||||
import { env } from "../config.js";
|
||||
import { extract } from "../processing/url.js";
|
||||
import { Green, Bright, Cyan } from "../misc/console-text.js";
|
||||
import { Bright, Cyan } from "../misc/console-text.js";
|
||||
import { hashHmac } from "../security/secrets.js";
|
||||
import { createStore } from "../store/redis-ratelimit.js";
|
||||
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
||||
import { verifyTurnstileToken } from "../security/turnstile.js";
|
||||
import { friendlyServiceName } from "../processing/service-alias.js";
|
||||
import { verifyStream, getInternalStream } from "../stream/manage.js";
|
||||
import { verifyStream } from "../stream/manage.js";
|
||||
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
|
||||
import * as APIKeys from "../security/api-keys.js";
|
||||
import * as Cookies from "../processing/cookie/manager.js";
|
||||
import { setupTunnelHandler } from "./itunnel.js";
|
||||
|
||||
const git = {
|
||||
branch: await getBranch(),
|
||||
@ -60,13 +61,13 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
})
|
||||
|
||||
const handleRateExceeded = (_, res) => {
|
||||
const { status, body } = createResponse("error", {
|
||||
const { body } = createResponse("error", {
|
||||
code: "error.api.rate_exceeded",
|
||||
context: {
|
||||
limit: env.rateLimitWindow
|
||||
}
|
||||
});
|
||||
return res.status(status).json(body);
|
||||
return res.status(429).json(body);
|
||||
};
|
||||
|
||||
const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
|
||||
@ -263,6 +264,15 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
}
|
||||
})
|
||||
|
||||
app.use('/tunnel', cors({
|
||||
methods: ['GET'],
|
||||
exposedHeaders: [
|
||||
'Estimated-Content-Length',
|
||||
'Content-Disposition'
|
||||
],
|
||||
...corsConfig,
|
||||
}));
|
||||
|
||||
app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
|
||||
const id = String(req.query.id);
|
||||
const exp = String(req.query.exp);
|
||||
@ -292,31 +302,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
}
|
||||
|
||||
return stream(res, streamInfo);
|
||||
})
|
||||
|
||||
const itunnelHandler = (req, res) => {
|
||||
if (!req.ip.endsWith('127.0.0.1')) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
|
||||
if (String(req.query.id).length !== 21) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
const streamInfo = getInternalStream(req.query.id);
|
||||
if (!streamInfo) {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
|
||||
streamInfo.headers = new Map([
|
||||
...(streamInfo.headers || []),
|
||||
...Object.entries(req.headers)
|
||||
]);
|
||||
|
||||
return stream(res, { type: 'internal', data: streamInfo });
|
||||
};
|
||||
|
||||
app.get('/itunnel', itunnelHandler);
|
||||
});
|
||||
|
||||
app.get('/', (_, res) => {
|
||||
res.type('json');
|
||||
@ -378,17 +364,5 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
}
|
||||
});
|
||||
|
||||
if (isCluster) {
|
||||
const istreamer = express();
|
||||
istreamer.get('/itunnel', itunnelHandler);
|
||||
const server = istreamer.listen({
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
exclusive: true
|
||||
}, () => {
|
||||
const { port } = server.address();
|
||||
console.log(`${Green('[✓]')} cobalt sub-instance running on 127.0.0.1:${port}`);
|
||||
setTunnelPort(port);
|
||||
});
|
||||
}
|
||||
setupTunnelHandler();
|
||||
}
|
||||
|
61
api/src/core/itunnel.js
Normal file
61
api/src/core/itunnel.js
Normal file
@ -0,0 +1,61 @@
|
||||
import stream from "../stream/stream.js";
|
||||
import { getInternalTunnel } from "../stream/manage.js";
|
||||
import { setTunnelPort } from "../config.js";
|
||||
import { Green } from "../misc/console-text.js";
|
||||
import express from "express";
|
||||
|
||||
const validateTunnel = (req, res) => {
|
||||
if (!req.ip.endsWith('127.0.0.1')) {
|
||||
res.sendStatus(403);
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(req.query.id).length !== 21) {
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
const streamInfo = getInternalTunnel(req.query.id);
|
||||
if (!streamInfo) {
|
||||
res.sendStatus(404);
|
||||
return;
|
||||
}
|
||||
|
||||
return streamInfo;
|
||||
}
|
||||
|
||||
const streamTunnel = (req, res) => {
|
||||
const streamInfo = validateTunnel(req, res);
|
||||
if (!streamInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
streamInfo.headers = new Map([
|
||||
...(streamInfo.headers || []),
|
||||
...Object.entries(req.headers)
|
||||
]);
|
||||
|
||||
return stream(res, { type: 'internal', data: streamInfo });
|
||||
}
|
||||
|
||||
export const setupTunnelHandler = () => {
|
||||
const tunnelHandler = express();
|
||||
|
||||
tunnelHandler.get('/itunnel', streamTunnel);
|
||||
|
||||
// fallback
|
||||
tunnelHandler.use((_, res) => res.sendStatus(400));
|
||||
// error handler
|
||||
tunnelHandler.use((_, __, res, ____) => res.socket.end());
|
||||
|
||||
|
||||
const server = tunnelHandler.listen({
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
exclusive: true
|
||||
}, () => {
|
||||
const { port } = server.address();
|
||||
console.log(`${Green('[✓]')} internal tunnel handler running on 127.0.0.1:${port}`);
|
||||
setTunnelPort(port);
|
||||
});
|
||||
}
|
@ -5,7 +5,22 @@ import { audioIgnore } from "./service-config.js";
|
||||
import { createStream } from "../stream/manage.js";
|
||||
import { splitFilenameExtension } from "../misc/utils.js";
|
||||
|
||||
export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) {
|
||||
const extraProcessingTypes = ["merge", "remux", "mute", "audio", "gif"];
|
||||
|
||||
export default function({
|
||||
r,
|
||||
host,
|
||||
audioFormat,
|
||||
isAudioOnly,
|
||||
isAudioMuted,
|
||||
disableMetadata,
|
||||
filenameStyle,
|
||||
convertGif,
|
||||
requestIP,
|
||||
audioBitrate,
|
||||
alwaysProxy,
|
||||
localProcessing
|
||||
}) {
|
||||
let action,
|
||||
responseType = "tunnel",
|
||||
defaultParams = {
|
||||
@ -22,7 +37,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
||||
|
||||
if (r.isPhoto) action = "photo";
|
||||
else if (r.picker) action = "picker"
|
||||
else if (r.isGif && twitterGif) action = "gif";
|
||||
else if (r.isGif && convertGif) action = "gif";
|
||||
else if (isAudioOnly) action = "audio";
|
||||
else if (isAudioMuted) action = "muteVideo";
|
||||
else if (r.isHLS) action = "hls";
|
||||
@ -216,5 +231,14 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
||||
params.type = "proxy";
|
||||
}
|
||||
|
||||
return createResponse(responseType, {...defaultParams, ...params})
|
||||
// TODO: add support for HLS
|
||||
// (very painful)
|
||||
if (localProcessing && !params.isHLS && extraProcessingTypes.includes(params.type)) {
|
||||
responseType = "local-processing";
|
||||
}
|
||||
|
||||
return createResponse(
|
||||
responseType,
|
||||
{ ...defaultParams, ...params }
|
||||
);
|
||||
}
|
||||
|
@ -70,7 +70,7 @@ export default async function({ host, patternMatch, params }) {
|
||||
r = await twitter({
|
||||
id: patternMatch.id,
|
||||
index: patternMatch.index - 1,
|
||||
toGif: !!params.twitterGif,
|
||||
toGif: !!params.convertGif,
|
||||
alwaysProxy: params.alwaysProxy,
|
||||
dispatcher
|
||||
});
|
||||
@ -131,7 +131,7 @@ export default async function({ host, patternMatch, params }) {
|
||||
shortLink: patternMatch.shortLink,
|
||||
fullAudio: params.tiktokFullAudio,
|
||||
isAudioOnly,
|
||||
h265: params.tiktokH265,
|
||||
h265: params.allowH265,
|
||||
alwaysProxy: params.alwaysProxy,
|
||||
});
|
||||
break;
|
||||
@ -243,7 +243,7 @@ export default async function({ host, patternMatch, params }) {
|
||||
case "xiaohongshu":
|
||||
r = await xiaohongshu({
|
||||
...patternMatch,
|
||||
h265: params.tiktokH265,
|
||||
h265: params.allowH265,
|
||||
isAudioOnly,
|
||||
dispatcher,
|
||||
});
|
||||
@ -300,10 +300,11 @@ export default async function({ host, patternMatch, params }) {
|
||||
isAudioMuted,
|
||||
disableMetadata: params.disableMetadata,
|
||||
filenameStyle: params.filenameStyle,
|
||||
twitterGif: params.twitterGif,
|
||||
convertGif: params.convertGif,
|
||||
requestIP,
|
||||
audioBitrate: params.audioBitrate,
|
||||
alwaysProxy: params.alwaysProxy,
|
||||
localProcessing: params.localProcessing,
|
||||
})
|
||||
} catch {
|
||||
return createResponse("error", {
|
||||
|
@ -1,7 +1,8 @@
|
||||
import mime from "mime";
|
||||
import ipaddr from "ipaddr.js";
|
||||
|
||||
import { createStream } from "../stream/manage.js";
|
||||
import { apiSchema } from "./schema.js";
|
||||
import { createProxyTunnels, createStream } from "../stream/manage.js";
|
||||
|
||||
export function createResponse(responseType, responseData) {
|
||||
const internalError = (code) => {
|
||||
@ -49,6 +50,41 @@ export function createResponse(responseType, responseData) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "local-processing":
|
||||
response = {
|
||||
type: responseData?.type,
|
||||
service: responseData?.service,
|
||||
tunnel: createProxyTunnels(responseData),
|
||||
|
||||
output: {
|
||||
type: mime.getType(responseData?.filename) || undefined,
|
||||
filename: responseData?.filename,
|
||||
metadata: responseData?.fileMetadata || undefined,
|
||||
},
|
||||
|
||||
audio: {
|
||||
copy: responseData?.audioCopy,
|
||||
format: responseData?.audioFormat,
|
||||
bitrate: responseData?.audioBitrate,
|
||||
},
|
||||
|
||||
isHLS: responseData?.isHLS,
|
||||
}
|
||||
|
||||
if (!response.audio.format) {
|
||||
if (response.type === "audio") {
|
||||
// audio response without a format is invalid
|
||||
return internalError();
|
||||
}
|
||||
delete response.audio;
|
||||
}
|
||||
|
||||
if (!response.output.type || !response.output.filename) {
|
||||
// response without a type or filename is invalid
|
||||
return internalError();
|
||||
}
|
||||
break;
|
||||
|
||||
case "picker":
|
||||
response = {
|
||||
picker: responseData?.picker,
|
||||
|
@ -36,15 +36,14 @@ export const apiSchema = z.object({
|
||||
.regex(/^[0-9a-zA-Z\-]+$/)
|
||||
.optional(),
|
||||
|
||||
// TODO: remove this variable as it's no longer used
|
||||
// and is kept for schema compatibility reasons
|
||||
youtubeDubBrowserLang: z.boolean().default(false),
|
||||
disableMetadata: z.boolean().default(false),
|
||||
|
||||
allowH265: z.boolean().default(false),
|
||||
convertGif: z.boolean().default(true),
|
||||
tiktokFullAudio: z.boolean().default(false),
|
||||
|
||||
alwaysProxy: z.boolean().default(false),
|
||||
disableMetadata: z.boolean().default(false),
|
||||
tiktokFullAudio: z.boolean().default(false),
|
||||
tiktokH265: z.boolean().default(false),
|
||||
twitterGif: z.boolean().default(true),
|
||||
localProcessing: z.boolean().default(false),
|
||||
|
||||
youtubeHLS: z.boolean().default(false),
|
||||
})
|
||||
|
@ -47,7 +47,8 @@ async function com_download(id) {
|
||||
return {
|
||||
urls: [video.baseUrl, audio.baseUrl],
|
||||
audioFilename: `bilibili_${id}_audio`,
|
||||
filename: `bilibili_${id}_${video.width}x${video.height}.mp4`
|
||||
filename: `bilibili_${id}_${video.width}x${video.height}.mp4`,
|
||||
isHLS: true
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import HLS from "hls-parser";
|
||||
import { createInternalStream } from "./manage.js";
|
||||
import { request } from "undici";
|
||||
|
||||
function getURL(url) {
|
||||
try {
|
||||
@ -55,8 +56,11 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) {
|
||||
|
||||
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
|
||||
|
||||
export function isHlsResponse (req) {
|
||||
return HLS_MIME_TYPES.includes(req.headers['content-type']);
|
||||
export function isHlsResponse(req, streamInfo) {
|
||||
return HLS_MIME_TYPES.includes(req.headers['content-type'])
|
||||
// bluesky's cdn responds with wrong content-type for the hls playlist,
|
||||
// so we enforce it here until they fix it
|
||||
|| (streamInfo.service === 'bsky' && streamInfo.url.endsWith('.m3u8'));
|
||||
}
|
||||
|
||||
export async function handleHlsPlaylist(streamInfo, req, res) {
|
||||
@ -71,3 +75,67 @@ export async function handleHlsPlaylist(streamInfo, req, res) {
|
||||
|
||||
res.send(hlsPlaylist);
|
||||
}
|
||||
|
||||
async function getSegmentSize(url, config) {
|
||||
const segmentResponse = await request(url, {
|
||||
...config,
|
||||
throwOnError: true
|
||||
});
|
||||
|
||||
if (segmentResponse.headers['content-length']) {
|
||||
segmentResponse.body.dump();
|
||||
return +segmentResponse.headers['content-length'];
|
||||
}
|
||||
|
||||
// if the response does not have a content-length
|
||||
// header, we have to compute it ourselves
|
||||
let size = 0;
|
||||
|
||||
for await (const data of segmentResponse.body) {
|
||||
size += data.length;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
export async function probeInternalHLSTunnel(streamInfo) {
|
||||
const { url, headers, dispatcher, signal } = streamInfo;
|
||||
|
||||
// remove all falsy headers
|
||||
Object.keys(headers).forEach(key => {
|
||||
if (!headers[key]) delete headers[key];
|
||||
});
|
||||
|
||||
const config = { headers, dispatcher, signal, maxRedirections: 16 };
|
||||
|
||||
const manifestResponse = await fetch(url, config);
|
||||
|
||||
const manifest = HLS.parse(await manifestResponse.text());
|
||||
if (manifest.segments.length === 0)
|
||||
return -1;
|
||||
|
||||
const segmentSamples = await Promise.all(
|
||||
Array(5).fill().map(async () => {
|
||||
const manifestIdx = Math.floor(Math.random() * manifest.segments.length);
|
||||
const randomSegment = manifest.segments[manifestIdx];
|
||||
if (!randomSegment.uri)
|
||||
throw "segment is missing URI";
|
||||
|
||||
let segmentUrl;
|
||||
|
||||
if (getURL(randomSegment.uri)) {
|
||||
segmentUrl = new URL(randomSegment.uri);
|
||||
} else {
|
||||
segmentUrl = new URL(randomSegment.uri, streamInfo.url);
|
||||
}
|
||||
|
||||
const segmentSize = await getSegmentSize(segmentUrl, config) / randomSegment.duration;
|
||||
return segmentSize;
|
||||
})
|
||||
);
|
||||
|
||||
const averageBitrate = segmentSamples.reduce((a, b) => a + b) / segmentSamples.length;
|
||||
const totalDuration = manifest.segments.reduce((acc, segment) => acc + segment.duration, 0);
|
||||
|
||||
return averageBitrate * totalDuration;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { request } from "undici";
|
||||
import { Readable } from "node:stream";
|
||||
import { closeRequest, getHeaders, pipe } from "./shared.js";
|
||||
import { handleHlsPlaylist, isHlsResponse } from "./internal-hls.js";
|
||||
import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./internal-hls.js";
|
||||
|
||||
const CHUNK_SIZE = BigInt(8e6); // 8 MB
|
||||
const min = (a, b) => a < b ? a : b;
|
||||
@ -118,10 +118,7 @@ async function handleGenericStream(streamInfo, res) {
|
||||
res.status(fileResponse.statusCode);
|
||||
fileResponse.body.on('error', () => {});
|
||||
|
||||
// bluesky's cdn responds with wrong content-type for the hls playlist,
|
||||
// so we enforce it here until they fix it
|
||||
const isHls = isHlsResponse(fileResponse)
|
||||
|| (streamInfo.service === "bsky" && streamInfo.url.endsWith('.m3u8'));
|
||||
const isHls = isHlsResponse(fileResponse, streamInfo);
|
||||
|
||||
for (const [ name, value ] of Object.entries(fileResponse.headers)) {
|
||||
if (!isHls || name.toLowerCase() !== 'content-length') {
|
||||
@ -155,3 +152,40 @@ export function internalStream(streamInfo, res) {
|
||||
|
||||
return handleGenericStream(streamInfo, res);
|
||||
}
|
||||
|
||||
export async function probeInternalTunnel(streamInfo) {
|
||||
try {
|
||||
const signal = AbortSignal.timeout(3000);
|
||||
const headers = {
|
||||
...Object.fromEntries(streamInfo.headers || []),
|
||||
...getHeaders(streamInfo.service),
|
||||
host: undefined,
|
||||
range: undefined
|
||||
};
|
||||
|
||||
if (streamInfo.isHLS) {
|
||||
return probeInternalHLSTunnel({
|
||||
...streamInfo,
|
||||
signal,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
const response = await request(streamInfo.url, {
|
||||
method: 'HEAD',
|
||||
headers,
|
||||
dispatcher: streamInfo.dispatcher,
|
||||
signal,
|
||||
maxRedirections: 16
|
||||
});
|
||||
|
||||
if (response.statusCode !== 200)
|
||||
throw "status is not 200 OK";
|
||||
|
||||
const size = +response.headers['content-length'];
|
||||
if (isNaN(size))
|
||||
throw "content-length is not a number";
|
||||
|
||||
return size;
|
||||
} catch {}
|
||||
}
|
||||
|
@ -70,10 +70,47 @@ export function createStream(obj) {
|
||||
return streamLink.toString();
|
||||
}
|
||||
|
||||
export function getInternalStream(id) {
|
||||
export function createProxyTunnels(info) {
|
||||
const proxyTunnels = [];
|
||||
|
||||
let urls = info.url;
|
||||
|
||||
if (typeof urls === "string") {
|
||||
urls = [urls];
|
||||
}
|
||||
|
||||
for (const url of urls) {
|
||||
proxyTunnels.push(
|
||||
createStream({
|
||||
url,
|
||||
type: "proxy",
|
||||
|
||||
service: info?.service,
|
||||
headers: info?.headers,
|
||||
requestIP: info?.requestIP,
|
||||
|
||||
originalRequest: info?.originalRequest
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return proxyTunnels;
|
||||
}
|
||||
|
||||
export function getInternalTunnel(id) {
|
||||
return internalStreamCache.get(id);
|
||||
}
|
||||
|
||||
export function getInternalTunnelFromURL(url) {
|
||||
url = new URL(url);
|
||||
if (url.hostname !== '127.0.0.1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = url.searchParams.get('id');
|
||||
return getInternalTunnel(id);
|
||||
}
|
||||
|
||||
export function createInternalStream(url, obj = {}) {
|
||||
assert(typeof url === 'string');
|
||||
|
||||
@ -131,7 +168,7 @@ export function destroyInternalStream(url) {
|
||||
const id = getInternalTunnelId(url);
|
||||
|
||||
if (internalStreamCache.has(id)) {
|
||||
closeRequest(getInternalStream(id)?.controller);
|
||||
closeRequest(getInternalTunnel(id)?.controller);
|
||||
internalStreamCache.delete(id);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { genericUserAgent } from "../config.js";
|
||||
import { vkClientAgent } from "../processing/services/vk.js";
|
||||
import { getInternalTunnelFromURL } from "./manage.js";
|
||||
import { probeInternalTunnel } from "./internal.js";
|
||||
|
||||
const defaultHeaders = {
|
||||
'user-agent': genericUserAgent
|
||||
@ -47,3 +49,40 @@ export function pipe(from, to, done) {
|
||||
|
||||
from.pipe(to);
|
||||
}
|
||||
|
||||
export async function estimateTunnelLength(streamInfo, multiplier = 1.1) {
|
||||
let urls = streamInfo.urls;
|
||||
if (!Array.isArray(urls)) {
|
||||
urls = [ urls ];
|
||||
}
|
||||
|
||||
const internalTunnels = urls.map(getInternalTunnelFromURL);
|
||||
if (internalTunnels.some(t => !t))
|
||||
return -1;
|
||||
|
||||
const sizes = await Promise.all(internalTunnels.map(probeInternalTunnel));
|
||||
const estimatedSize = sizes.reduce(
|
||||
// if one of the sizes is missing, let's just make a very
|
||||
// bold guess that it's the same size as the existing one
|
||||
(acc, cur) => cur <= 0 ? acc * 2 : acc + cur,
|
||||
0
|
||||
);
|
||||
|
||||
if (isNaN(estimatedSize) || estimatedSize <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return Math.floor(estimatedSize * multiplier);
|
||||
}
|
||||
|
||||
export function estimateAudioMultiplier(streamInfo) {
|
||||
if (streamInfo.audioFormat === 'wav') {
|
||||
return 1411 / 128;
|
||||
}
|
||||
|
||||
if (streamInfo.audioCopy) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return streamInfo.audioBitrate / 128;
|
||||
}
|
||||
|
@ -10,20 +10,20 @@ export default async function(res, streamInfo) {
|
||||
return await stream.proxy(streamInfo, res);
|
||||
|
||||
case "internal":
|
||||
return internalStream(streamInfo.data, res);
|
||||
return await internalStream(streamInfo.data, res);
|
||||
|
||||
case "merge":
|
||||
return stream.merge(streamInfo, res);
|
||||
return await stream.merge(streamInfo, res);
|
||||
|
||||
case "remux":
|
||||
case "mute":
|
||||
return stream.remux(streamInfo, res);
|
||||
return await stream.remux(streamInfo, res);
|
||||
|
||||
case "audio":
|
||||
return stream.convertAudio(streamInfo, res);
|
||||
return await stream.convertAudio(streamInfo, res);
|
||||
|
||||
case "gif":
|
||||
return stream.convertGif(streamInfo, res);
|
||||
return await stream.convertGif(streamInfo, res);
|
||||
}
|
||||
|
||||
closeResponse(res);
|
||||
|
@ -6,7 +6,7 @@ import { create as contentDisposition } from "content-disposition-header";
|
||||
import { env } from "../config.js";
|
||||
import { destroyInternalStream } from "./manage.js";
|
||||
import { hlsExceptions } from "../processing/service-config.js";
|
||||
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
|
||||
import { getHeaders, closeRequest, closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js";
|
||||
|
||||
const ffmpegArgs = {
|
||||
webm: ["-c:v", "copy", "-c:a", "copy"],
|
||||
@ -29,7 +29,7 @@ const convertMetadataToFFmpeg = (metadata) => {
|
||||
|
||||
for (const [ name, value ] of Object.entries(metadata)) {
|
||||
if (metadataTags.includes(name)) {
|
||||
args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`);
|
||||
args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); // skipcq: JS-0004
|
||||
} else {
|
||||
throw `${name} metadata tag is not supported.`;
|
||||
}
|
||||
@ -98,7 +98,7 @@ const proxy = async (streamInfo, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const merge = (streamInfo, res) => {
|
||||
const merge = async (streamInfo, res) => {
|
||||
let process;
|
||||
const shutdown = () => (
|
||||
killProcess(process),
|
||||
@ -112,7 +112,7 @@ const merge = (streamInfo, res) => {
|
||||
try {
|
||||
if (streamInfo.urls.length !== 2) return shutdown();
|
||||
|
||||
const format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
|
||||
const format = streamInfo.filename.split('.').pop();
|
||||
|
||||
let args = [
|
||||
'-loglevel', '-8',
|
||||
@ -152,6 +152,7 @@ const merge = (streamInfo, res) => {
|
||||
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
|
||||
|
||||
pipe(muxOutput, res, shutdown);
|
||||
|
||||
@ -162,7 +163,7 @@ const merge = (streamInfo, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const remux = (streamInfo, res) => {
|
||||
const remux = async (streamInfo, res) => {
|
||||
let process;
|
||||
const shutdown = () => (
|
||||
killProcess(process),
|
||||
@ -196,7 +197,7 @@ const remux = (streamInfo, res) => {
|
||||
args.push('-bsf:a', 'aac_adtstoasc');
|
||||
}
|
||||
|
||||
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
|
||||
let format = streamInfo.filename.split('.').pop();
|
||||
if (format === "mp4") {
|
||||
args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
|
||||
}
|
||||
@ -215,6 +216,7 @@ const remux = (streamInfo, res) => {
|
||||
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
|
||||
|
||||
pipe(muxOutput, res, shutdown);
|
||||
|
||||
@ -225,7 +227,7 @@ const remux = (streamInfo, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const convertAudio = (streamInfo, res) => {
|
||||
const convertAudio = async (streamInfo, res) => {
|
||||
let process;
|
||||
const shutdown = () => (
|
||||
killProcess(process),
|
||||
@ -284,6 +286,13 @@ const convertAudio = (streamInfo, res) => {
|
||||
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||
res.setHeader(
|
||||
'Estimated-Content-Length',
|
||||
await estimateTunnelLength(
|
||||
streamInfo,
|
||||
estimateAudioMultiplier(streamInfo) * 1.1
|
||||
)
|
||||
);
|
||||
|
||||
pipe(muxOutput, res, shutdown);
|
||||
res.on('finish', shutdown);
|
||||
@ -292,7 +301,7 @@ const convertAudio = (streamInfo, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const convertGif = (streamInfo, res) => {
|
||||
const convertGif = async (streamInfo, res) => {
|
||||
let process;
|
||||
const shutdown = () => (killProcess(process), closeResponse(res));
|
||||
|
||||
@ -321,6 +330,7 @@ const convertGif = (streamInfo, res) => {
|
||||
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo, 60));
|
||||
|
||||
pipe(muxOutput, res, shutdown);
|
||||
|
||||
|
@ -68,8 +68,8 @@ Content-Type: application/json
|
||||
| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
|
||||
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
|
||||
| `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
|
||||
| `tiktokH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. |
|
||||
| `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif |
|
||||
| `allowH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. |
|
||||
| `convertGif` | `boolean` | `true / false` | `true` | changes whether mute looping videos are converted to .gif |
|
||||
| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. |
|
||||
|
||||
### response
|
||||
|
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@ -43,6 +43,9 @@ importers:
|
||||
ipaddr.js:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
mime:
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4
|
||||
nanoid:
|
||||
specifier: ^5.0.9
|
||||
version: 5.0.9
|
||||
@ -101,8 +104,8 @@ importers:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
'@imput/libav.js-remux-cli':
|
||||
specifier: ^5.5.6
|
||||
version: 5.5.6
|
||||
specifier: ^6.5.7
|
||||
version: 6.5.7
|
||||
'@imput/version-info':
|
||||
specifier: workspace:^
|
||||
version: link:../packages/version-info
|
||||
@ -554,8 +557,8 @@ packages:
|
||||
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@imput/libav.js-remux-cli@5.5.6':
|
||||
resolution: {integrity: sha512-XdAab90EZKf6ULtD/x9Y2bnlmNJodXSO6w8aWrn97+N2IRuOS8zv3tAFPRC69SWKa8Utjeu5YTYuTolnX3QprQ==}
|
||||
'@imput/libav.js-remux-cli@6.5.7':
|
||||
resolution: {integrity: sha512-41ld+R5aEwllKdbiszOoCf+K614/8bnuheTZnqjgZESwDtX07xu9O8GO0m/Cpsm7O2CyqPZa1qzDx6BZVO15DQ==}
|
||||
|
||||
'@imput/psl@2.0.4':
|
||||
resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==}
|
||||
@ -2519,7 +2522,7 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/retry@0.4.1': {}
|
||||
|
||||
'@imput/libav.js-remux-cli@5.5.6': {}
|
||||
'@imput/libav.js-remux-cli@6.5.7': {}
|
||||
|
||||
'@imput/psl@2.0.4':
|
||||
dependencies:
|
||||
|
@ -16,5 +16,11 @@
|
||||
"save": "save",
|
||||
"export": "export",
|
||||
"yes": "yes",
|
||||
"no": "no"
|
||||
"no": "no",
|
||||
"clear": "clear",
|
||||
"show_input": "show input",
|
||||
"hide_input": "hide input",
|
||||
"restore_input": "restore input",
|
||||
"clear_input": "clear input",
|
||||
"clear_cache": "clear cache"
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"reset.title": "reset all data?",
|
||||
"reset.body": "are you sure you want to reset all data? this action is immediate and irreversible.",
|
||||
"reset_settings.title": "reset all settings?",
|
||||
"reset_settings.body": "are you sure you want to reset all settings? this action is immediate and irreversible.",
|
||||
|
||||
"picker.title": "select what to save",
|
||||
"picker.description.desktop": "click an item to save it. images can also be saved via the right click menu.",
|
||||
@ -21,5 +21,8 @@
|
||||
"safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.",
|
||||
|
||||
"processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?",
|
||||
"processing.title.ongoing": "processing will be cancelled"
|
||||
"processing.title.ongoing": "processing will be cancelled",
|
||||
|
||||
"clear_cache.title": "clear all cache?",
|
||||
"clear_cache.body": "all files from the processing queue will be removed and on-device features will take longer to load. this action is immediate and irreversible."
|
||||
}
|
||||
|
@ -51,7 +51,7 @@
|
||||
"api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!",
|
||||
"api.content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!",
|
||||
"api.content.video.private": "this video is private, so i can't access it. change its visibility or try another one!",
|
||||
"api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try a different link!",
|
||||
"api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try again or try a different link!",
|
||||
"api.content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!",
|
||||
|
||||
"api.content.region": "this content is region locked, and the processing instance is in a different location. try a different link!",
|
||||
@ -59,11 +59,11 @@
|
||||
|
||||
"api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!",
|
||||
"api.content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!",
|
||||
"api.content.post.age": "this post is age-restricted and isn't available without logging in. try a different link!",
|
||||
"api.content.post.age": "this post is age-restricted, so i can't access it anonymously. try again or try a different link!",
|
||||
|
||||
"api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!",
|
||||
"api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!",
|
||||
"api.youtube.login": "couldn't get this video because youtube asked the instance to log in. this is potentially caused by the processing instance not having any active account tokens or youtube updating something about their api. try again in a few seconds, but if it still doesn't work, please report this issue!",
|
||||
"api.youtube.login": "couldn't get this video because youtube told the processing instance that it's a bot and restricted its access. try again in a few seconds, but if it still doesn't work, please report this issue!",
|
||||
"api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!",
|
||||
"api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
|
||||
"api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
|
||||
|
17
web/i18n/en/queue.json
Normal file
17
web/i18n/en/queue.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "processing queue",
|
||||
"stub": "nothing here yet, just the two of us.\ntry {{ value }} something!",
|
||||
"stub.download": "downloading",
|
||||
"stub.remux": "remuxing",
|
||||
|
||||
"state.waiting": "queued",
|
||||
"state.retrying": "retrying",
|
||||
|
||||
"state.starting": "starting",
|
||||
"state.starting.fetch": "starting downloading",
|
||||
"state.starting.remux": "starting muxing",
|
||||
|
||||
"state.running.remux": "muxing",
|
||||
"state.running.fetch": "downloading",
|
||||
"estimated_storage_usage": "estimated storage usage:"
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
{
|
||||
"title": "drag or select a file",
|
||||
"title.multiple": "drag or select files",
|
||||
"title.drop": "drop the file here!",
|
||||
"title.drop.multiple": "drop the files here!",
|
||||
"accept": "supported formats: {{ formats }}."
|
||||
}
|
||||
|
@ -7,6 +7,8 @@
|
||||
"page.advanced": "advanced",
|
||||
"page.debug": "info for nerds",
|
||||
"page.instances": "instances",
|
||||
"page.local": "local processing",
|
||||
"page.accessibility": "accessibility",
|
||||
|
||||
"section.general": "general",
|
||||
"section.save": "save",
|
||||
@ -72,7 +74,7 @@
|
||||
"metadata.filename.nerdy": "nerdy",
|
||||
"metadata.filename.description": "filename style will only be used for files tunneled by cobalt. some services don't support filename styles other than classic.",
|
||||
|
||||
"metadata.filename.preview.video": "Video Title",
|
||||
"metadata.filename.preview.video": "Video Title - Video Author",
|
||||
"metadata.filename.preview.audio": "Audio Title - Audio Author",
|
||||
|
||||
"metadata.file": "file metadata",
|
||||
@ -86,11 +88,18 @@
|
||||
"saving.copy": "copy",
|
||||
"saving.description": "preferred way of saving the file or link from cobalt. if preferred method is unavailable or something goes wrong, cobalt will ask you what to do next.",
|
||||
|
||||
"accessibility": "accessibility",
|
||||
"accessibility.visual": "visual",
|
||||
"accessibility.haptics": "haptics",
|
||||
"accessibility.behavior": "behavior",
|
||||
|
||||
"accessibility.transparency.title": "reduce visual transparency",
|
||||
"accessibility.transparency.description": "reduces transparency of surfaces and disables blur effects. may also improve ui performance on low performance devices.",
|
||||
"accessibility.transparency.description": "transparency of surfaces will be reduced and all blur effects will be disabled. may also improve ui performance on less powerful devices.",
|
||||
"accessibility.motion.title": "reduce motion",
|
||||
"accessibility.motion.description": "disables animations and transitions whenever possible.",
|
||||
"accessibility.motion.description": "animations and transitions will be disabled whenever possible.",
|
||||
"accessibility.haptics.title": "disable haptics",
|
||||
"accessibility.haptics.description": "all haptic effects will be disabled.",
|
||||
"accessibility.auto_queue.title": "don't open the queue automatically",
|
||||
"accessibility.auto_queue.description": "the processing queue will not be opened automatically whenever a new item is added to it. progress will still be displayed and you will still be able to open it manually.",
|
||||
|
||||
"language": "language",
|
||||
"language.auto.title": "automatic selection",
|
||||
@ -111,8 +120,6 @@
|
||||
"advanced.debug.title": "enable features for nerds",
|
||||
"advanced.debug.description": "gives you easy access to app info that can be useful for debugging. enabling this does not affect functionality of cobalt in any way.",
|
||||
|
||||
"advanced.data": "data management",
|
||||
|
||||
"processing.community": "community instances",
|
||||
"processing.enable_custom.title": "use a custom processing server",
|
||||
"processing.enable_custom.description": "cobalt will use a custom processing instance if you choose to. even though cobalt has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\n\nplease be mindful of what instances you use and make sure they're hosted by people you trust.",
|
||||
@ -122,5 +129,16 @@
|
||||
"processing.access_key.description": "cobalt will use this key to make requests to the processing instance instead of other authentication methods. make sure the instance supports api keys!",
|
||||
|
||||
"processing.custom_instance.input.alt_text": "custom instance domain",
|
||||
"processing.access_key.input.alt_text": "u-u-i-d access key"
|
||||
"processing.access_key.input.alt_text": "u-u-i-d access key",
|
||||
|
||||
"advanced.settings_data": "settings data",
|
||||
"advanced.local_storage": "local storage",
|
||||
|
||||
"local.saving": "media processing",
|
||||
"local.saving.title": "mux and convert media on device",
|
||||
"local.saving.description": "when downloading media, cobalt will do needed processing on-device instead of using cloud compute. files will download faster and more reliably.\n\nexclusive local features are not affected by this toggle, they always run locally.",
|
||||
|
||||
"local.webcodecs": "webcodecs",
|
||||
"local.webcodecs.title": "use webcodecs for on-device processing",
|
||||
"local.webcodecs.description": "when decoding or encoding files, cobalt will try to use webcodecs. this feature allows for GPU-accelerated processing of media files, meaning that all decoding & encoding will be way faster.\n\navailability and stability of this feature depends on your device's and browser's capabilities. stuff might break or not work properly."
|
||||
}
|
||||
|
@ -28,7 +28,7 @@
|
||||
"@fontsource-variable/noto-sans-mono": "^5.0.20",
|
||||
"@fontsource/ibm-plex-mono": "^5.0.13",
|
||||
"@fontsource/redaction-10": "^5.0.2",
|
||||
"@imput/libav.js-remux-cli": "^5.5.6",
|
||||
"@imput/libav.js-remux-cli": "^6.5.7",
|
||||
"@imput/version-info": "workspace:^",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.1",
|
||||
|
@ -18,7 +18,7 @@
|
||||
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/icons/apple-touch-icon.png">
|
||||
<link type="application/activity+json" href="" />
|
||||
<link type="application/activity+json" href="">
|
||||
|
||||
<link crossorigin="use-credentials" rel="manifest" href="%sveltekit.assets%/manifest.json">
|
||||
|
||||
|
@ -38,7 +38,7 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="support-card"
|
||||
class="button support-card"
|
||||
role="link"
|
||||
on:click={() => {
|
||||
openURL(externalLink);
|
||||
@ -68,7 +68,6 @@
|
||||
.support-card {
|
||||
padding: var(--padding);
|
||||
gap: 4px;
|
||||
height: fit-content;
|
||||
|
||||
text-align: start;
|
||||
flex-direction: column;
|
||||
|
@ -6,6 +6,8 @@
|
||||
Value extends CobaltSettings[Context][Id]
|
||||
"
|
||||
>
|
||||
import { hapticSwitch } from "$lib/haptics";
|
||||
|
||||
import settings, { updateSetting } from "$lib/state/settings";
|
||||
import type { CobaltSettings } from "$lib/types/settings";
|
||||
|
||||
@ -22,12 +24,14 @@
|
||||
class="button"
|
||||
class:active={isActive}
|
||||
aria-pressed={isActive}
|
||||
on:click={() =>
|
||||
on:click={() => {
|
||||
hapticSwitch();
|
||||
updateSetting({
|
||||
[settingContext]: {
|
||||
[settingId]: settingValue,
|
||||
},
|
||||
})}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
|
@ -5,6 +5,7 @@
|
||||
Id extends keyof CobaltSettings[Context]
|
||||
"
|
||||
>
|
||||
import { hapticSwitch } from "$lib/haptics";
|
||||
import settings, { updateSetting } from "$lib/state/settings";
|
||||
import type { CobaltSettings } from "$lib/types/settings";
|
||||
|
||||
@ -31,17 +32,18 @@
|
||||
aria-hidden={disabled}
|
||||
>
|
||||
<button
|
||||
class="toggle-container"
|
||||
class="button toggle-container"
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
disabled={disabled}
|
||||
on:click={() =>
|
||||
{disabled}
|
||||
on:click={() => {
|
||||
hapticSwitch();
|
||||
updateSetting({
|
||||
[settingContext]: {
|
||||
[settingId]: !isEnabled,
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<h4 class="toggle-title">{title}</h4>
|
||||
<Toggle enabled={isEnabled} />
|
||||
@ -81,5 +83,12 @@
|
||||
padding: calc(var(--switcher-padding) * 2) 16px;
|
||||
border-radius: var(--border-radius);
|
||||
overflow: scroll;
|
||||
transition: box-shadow 0.1s;
|
||||
}
|
||||
|
||||
.toggle-container:active {
|
||||
box-shadow:
|
||||
var(--button-box-shadow),
|
||||
0 0 0 1.5px var(--button-stroke) inset;
|
||||
}
|
||||
</style>
|
||||
|
@ -75,7 +75,7 @@
|
||||
.switcher.big :global(.button) {
|
||||
width: 100%;
|
||||
/* [base button height] - ([switcher padding] * [padding factor to accommodate for]) */
|
||||
height: calc(40px - var(--switcher-padding) * 1.5);
|
||||
height: calc(40px - var(--switcher-padding) * 2);
|
||||
border-radius: calc(var(--border-radius) - var(--switcher-padding));;
|
||||
}
|
||||
|
||||
@ -87,12 +87,16 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.switcher.big :global(.button:active:not(.active)) {
|
||||
box-shadow: var(--button-box-shadow);
|
||||
}
|
||||
|
||||
.switcher:not(.big) :global(.button:not(:first-child, :last-child)) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* hack to get rid of double border in a list of switches */
|
||||
.switcher:not(.big) :global(:not(.button:first-child)) {
|
||||
margin-left: -1.5px;
|
||||
margin-left: -1px;
|
||||
}
|
||||
</style>
|
||||
|
@ -16,9 +16,14 @@
|
||||
if (dialogParent) {
|
||||
closing = true;
|
||||
open = false;
|
||||
|
||||
// wait 150ms for the closing animation to finish
|
||||
setTimeout(() => {
|
||||
dialogParent.close();
|
||||
killDialog();
|
||||
// check if dialog parent is still present
|
||||
if (dialogParent) {
|
||||
dialogParent.close();
|
||||
killDialog();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
|
@ -136,7 +136,11 @@
|
||||
|
||||
:global(dialog .dialog-body) {
|
||||
margin-bottom: calc(
|
||||
var(--padding) / 2 + env(safe-area-inset-bottom)
|
||||
var(--padding) + calc(
|
||||
env(safe-area-inset-bottom) - 15px * sign(
|
||||
env(safe-area-inset-bottom)
|
||||
)
|
||||
)
|
||||
) !important;
|
||||
box-shadow: 0 0 0 2px var(--popup-stroke) inset;
|
||||
}
|
||||
|
@ -65,6 +65,9 @@
|
||||
<style>
|
||||
.picker-dialog {
|
||||
--picker-item-size: 120px;
|
||||
--picker-item-gap: 4px;
|
||||
--picker-item-area: calc(var(--picker-item-size) + var(--picker-item-gap));
|
||||
|
||||
gap: var(--padding);
|
||||
max-height: calc(
|
||||
90% - env(safe-area-inset-bottom) - env(safe-area-inset-top)
|
||||
@ -77,7 +80,7 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
max-width: calc(var(--picker-item-size) * 4);
|
||||
max-width: calc(var(--picker-item-area) * 4);
|
||||
}
|
||||
|
||||
.popup-title-container {
|
||||
@ -112,6 +115,7 @@
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
gap: var(--picker-item-gap);
|
||||
}
|
||||
|
||||
.three-columns .picker-body {
|
||||
@ -119,7 +123,7 @@
|
||||
}
|
||||
|
||||
.three-columns .popup-header {
|
||||
max-width: calc(var(--picker-item-size) * 3);
|
||||
max-width: calc(var(--picker-item-area) * 3);
|
||||
}
|
||||
|
||||
:global(.picker-item) {
|
||||
@ -133,48 +137,78 @@
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
max-width: calc(var(--picker-item-size) * 3);
|
||||
max-width: calc(var(--picker-item-area) * 3);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
@media screen and (max-width: 410px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 118px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 405px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 116px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 398px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 115px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 380px) {
|
||||
@media screen and (max-width: 388px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 365px) {
|
||||
@media screen and (max-width: 378px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 105px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 350px) {
|
||||
@media screen and (max-width: 365px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 335px) {
|
||||
@media screen and (max-width: 352px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 95px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 334px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 130px;
|
||||
}
|
||||
|
||||
.picker-body,
|
||||
.three-columns .picker-body {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
max-width: calc(var(--picker-item-size) * 3);
|
||||
@media screen and (max-width: 300px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 280px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 255px) {
|
||||
.picker-dialog {
|
||||
--picker-item-size: 120px;
|
||||
--picker-item-size: 140px;
|
||||
}
|
||||
|
||||
.picker-body,
|
||||
|
@ -62,11 +62,20 @@
|
||||
.picker-item {
|
||||
position: relative;
|
||||
background: none;
|
||||
padding: 2px;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
border-radius: calc(var(--border-radius) / 2 + 2px);
|
||||
}
|
||||
|
||||
.picker-item:focus-visible::after {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
:global(.picker-image) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@ -76,7 +85,7 @@
|
||||
pointer-events: all;
|
||||
|
||||
object-fit: cover;
|
||||
border-radius: calc(var(--border-radius) / 2);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.picker-image.loading {
|
||||
|
@ -2,6 +2,7 @@
|
||||
import { t } from "$lib/i18n/translations";
|
||||
|
||||
import { device } from "$lib/device";
|
||||
import { hapticConfirm } from "$lib/haptics";
|
||||
import {
|
||||
copyURL,
|
||||
openURL,
|
||||
@ -101,8 +102,11 @@
|
||||
fill
|
||||
elevated
|
||||
click={async () => {
|
||||
copyURL(url);
|
||||
copied = true;
|
||||
if (!copied) {
|
||||
copyURL(url);
|
||||
hapticConfirm();
|
||||
copied = true;
|
||||
}
|
||||
}}
|
||||
ariaLabel={copied ? $t("button.copied") : ""}
|
||||
>
|
||||
|
@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { hapticError } from "$lib/haptics";
|
||||
import type { Optional } from "$lib/types/generic";
|
||||
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
|
||||
import type { DialogButton, SmallDialogIcons } from "$lib/types/dialog";
|
||||
@ -21,6 +22,11 @@
|
||||
export let leftAligned = false;
|
||||
|
||||
let close: () => void;
|
||||
|
||||
// error meowbalt art is not used in dialogs unless it's an error
|
||||
if (meowbalt === "error") {
|
||||
hapticError();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogContainer {id} {dismissable} bind:close>
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { hapticConfirm } from "$lib/haptics";
|
||||
import { copyURL, openURL } from "$lib/download";
|
||||
|
||||
import CopyIcon from "$components/misc/CopyIcon.svelte";
|
||||
@ -21,14 +22,17 @@
|
||||
|
||||
<div class="wallet-holder">
|
||||
<button
|
||||
class="wallet"
|
||||
class="button wallet"
|
||||
aria-label={$t(`donate.alt.${type}`, {
|
||||
value: name,
|
||||
})}
|
||||
on:click={() => {
|
||||
if (type === "copy") {
|
||||
copied = true;
|
||||
copyURL(address);
|
||||
if (!copied) {
|
||||
copyURL(address);
|
||||
hapticConfirm();
|
||||
copied = true;
|
||||
}
|
||||
} else {
|
||||
openURL(address);
|
||||
}
|
||||
@ -88,7 +92,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 1.5px var(--button-stroke) solid;
|
||||
border-right: 1px var(--button-stroke) solid;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
|
@ -47,18 +47,19 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.donate-card button:active) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:global(.donate-card button:hover) {
|
||||
:global(.donate-card button:hover:not(.selected):not(.scroll-button)) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.donate-card button:active:not(.selected):not(.scroll-button)) {
|
||||
background: rgba(255, 255, 255, 0.125);
|
||||
}
|
||||
|
||||
:global(.donate-card button.selected) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:global(.donate-card button.selected:not(:focus-visible)) {
|
||||
|
@ -75,7 +75,7 @@
|
||||
return window.open(donationMethods[processor](amount), "_blank");
|
||||
};
|
||||
|
||||
const scrollBehavior = $settings.appearance.reduceMotion
|
||||
const scrollBehavior = $settings.accessibility.reduceMotion
|
||||
? "instant"
|
||||
: "smooth";
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
const scroll = (direction: "left" | "right") => {
|
||||
const currentPos = donateList.scrollLeft;
|
||||
const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width;
|
||||
const newPos = direction === "left" ? currentPos - 150 : currentPos + 150;
|
||||
const newPos = direction === "left" ? currentPos - 250 : currentPos + 250;
|
||||
|
||||
donateList.scroll({
|
||||
left: newPos,
|
||||
@ -285,10 +285,17 @@
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
color: var(--white);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
#input-container:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
#input-dollar-sign {
|
||||
@ -336,7 +343,6 @@
|
||||
|
||||
#donation-custom-submit {
|
||||
color: var(--white);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
aspect-ratio: 1/1;
|
||||
padding: 0px 10px;
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
import { device } from "$lib/device";
|
||||
import locale from "$lib/i18n/locale";
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { hapticConfirm } from "$lib/haptics";
|
||||
|
||||
import { openURL, copyURL, shareURL } from "$lib/download";
|
||||
|
||||
@ -51,8 +52,11 @@
|
||||
id="action-button-copy"
|
||||
class="action-button"
|
||||
on:click={async () => {
|
||||
copyURL(cobaltUrl);
|
||||
copied = true;
|
||||
if (!copied) {
|
||||
copyURL(cobaltUrl);
|
||||
hapticConfirm();
|
||||
copied = true;
|
||||
}
|
||||
}}
|
||||
aria-label={copied ? $t("button.copied") : ""}
|
||||
>
|
||||
@ -176,7 +180,7 @@
|
||||
.action-button {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
padding: 0 6px;
|
||||
font-size: 13px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
@ -3,15 +3,15 @@
|
||||
export let classes = "";
|
||||
|
||||
export let draggedOver = false;
|
||||
export let file: File | undefined;
|
||||
export let files: FileList | undefined;
|
||||
|
||||
const dropHandler = async (ev: DragEvent) => {
|
||||
draggedOver = false;
|
||||
ev.preventDefault();
|
||||
|
||||
if (ev?.dataTransfer?.files.length === 1) {
|
||||
file = ev.dataTransfer.files[0];
|
||||
return file;
|
||||
if (ev?.dataTransfer?.files && ev?.dataTransfer?.files.length > 0) {
|
||||
files = ev.dataTransfer.files;
|
||||
return files;
|
||||
}
|
||||
};
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
{id}
|
||||
class={classes}
|
||||
role="region"
|
||||
aria-hidden="true"
|
||||
on:drop={(ev) => dropHandler(ev)}
|
||||
on:dragover={(ev) => dragOverHandler(ev)}
|
||||
on:dragend={() => {
|
||||
|
@ -5,22 +5,33 @@
|
||||
import IconFileImport from "@tabler/icons-svelte/IconFileImport.svelte";
|
||||
import IconUpload from "@tabler/icons-svelte/IconUpload.svelte";
|
||||
|
||||
export let file: File | undefined;
|
||||
export let files: FileList | undefined;
|
||||
export let draggedOver = false;
|
||||
export let acceptTypes: string[];
|
||||
export let acceptExtensions: string[];
|
||||
export let maxFileNumber: number = 100;
|
||||
|
||||
let selectorStringMultiple = maxFileNumber > 1 ? ".multiple" : "";
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
const openFile = async () => {
|
||||
fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = acceptTypes.join(",");
|
||||
|
||||
if (maxFileNumber > 1) {
|
||||
fileInput.multiple = true;
|
||||
}
|
||||
|
||||
fileInput.click();
|
||||
fileInput.onchange = async () => {
|
||||
if (fileInput.files?.length === 1) {
|
||||
file = fileInput.files[0];
|
||||
return file;
|
||||
let userFiles = fileInput?.files;
|
||||
if (userFiles && userFiles.length >= 1) {
|
||||
if (userFiles.length > maxFileNumber) {
|
||||
return alert("too many files, limit is " + maxFileNumber);
|
||||
}
|
||||
return files = userFiles;
|
||||
}
|
||||
};
|
||||
};
|
||||
@ -29,7 +40,7 @@
|
||||
<div class="open-file-container" class:dragged-over={draggedOver}>
|
||||
<Meowbalt emotion="question" />
|
||||
|
||||
<button class="open-file-button" on:click={openFile}>
|
||||
<button class="button open-file-button" on:click={openFile}>
|
||||
<div class="dashed-stroke">
|
||||
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="none" rx="24" ry="24" />
|
||||
@ -47,9 +58,9 @@
|
||||
<div class="open-file-text">
|
||||
<div class="open-title">
|
||||
{#if draggedOver}
|
||||
{$t("receiver.title.drop")}
|
||||
{$t("receiver.title.drop" + selectorStringMultiple)}
|
||||
{:else}
|
||||
{$t("receiver.title")}
|
||||
{$t("receiver.title" + selectorStringMultiple)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="subtext accept-list">
|
||||
|
@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/translations";
|
||||
|
||||
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
|
||||
|
||||
export let emotion: MeowbaltEmotions;
|
||||
|
72
web/src/components/misc/PopoverContainer.svelte
Normal file
72
web/src/components/misc/PopoverContainer.svelte
Normal file
@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
export let id = "";
|
||||
export let expanded = false;
|
||||
export let expandStart: "left" | "center" | "right" = "center";
|
||||
|
||||
/*
|
||||
a popover isn't pre-rendered by default, because the user might never open it.
|
||||
but if they do, we render only once, and then keep it the dom :3
|
||||
*/
|
||||
|
||||
$: renderPopover = false;
|
||||
$: if (expanded && !renderPopover) renderPopover = true;
|
||||
</script>
|
||||
|
||||
<div {id} class="popover {expandStart}" aria-hidden={!expanded} class:expanded>
|
||||
{#if renderPopover}
|
||||
<slot></slot>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 18px;
|
||||
background: var(--button);
|
||||
box-shadow:
|
||||
var(--button-box-shadow),
|
||||
0 0 10px 10px var(--popover-glow);
|
||||
|
||||
position: relative;
|
||||
padding: var(--padding);
|
||||
gap: 6px;
|
||||
top: 6px;
|
||||
z-index: 2;
|
||||
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
transform-origin: top center;
|
||||
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.53, 0.05, 0.23, 1.15),
|
||||
opacity 0.25s cubic-bezier(0.53, 0.05, 0.23, 0.99);
|
||||
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.popover.left {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
:global([dir="rtl"]) .popover.left {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.popover.center {
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.popover.right {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
:global([dir="rtl"]) .popover.right {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.popover.expanded {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { copyURL } from "$lib/download";
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { hapticConfirm } from "$lib/haptics";
|
||||
|
||||
import CopyIcon from "$components/misc/CopyIcon.svelte";
|
||||
|
||||
export let title: string;
|
||||
export let sectionId: string;
|
||||
export let beta = false;
|
||||
export let nolink = false;
|
||||
export let copyData = "";
|
||||
|
||||
const sectionURL = `${$page.url.origin}${$page.url.pathname}#${sectionId}`;
|
||||
@ -32,18 +34,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="link-copy"
|
||||
aria-label={copied
|
||||
? $t("button.copied")
|
||||
: $t(`button.copy${copyData ? "" : ".section"}`)}
|
||||
on:click={() => {
|
||||
copied = true;
|
||||
copyURL(copyData || sectionURL);
|
||||
}}
|
||||
>
|
||||
<CopyIcon check={copied} regularIcon={!!copyData} />
|
||||
</button>
|
||||
{#if !nolink}
|
||||
<button
|
||||
class="link-copy"
|
||||
aria-label={copied
|
||||
? $t("button.copied")
|
||||
: $t(`button.copy${copyData ? "" : ".section"}`)}
|
||||
on:click={() => {
|
||||
if (!copied) {
|
||||
copyURL(copyData || sectionURL);
|
||||
hapticConfirm();
|
||||
copied = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CopyIcon check={copied} regularIcon={!!copyData} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@ -90,7 +97,7 @@
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.9;
|
||||
line-height: 1.86;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
@ -21,7 +21,7 @@
|
||||
border-radius: 5px;
|
||||
border-radius: 100px;
|
||||
background: var(--toggle-bg);
|
||||
transition: background 0.2s;
|
||||
transition: background 0.25s;
|
||||
}
|
||||
|
||||
.toggle:dir(rtl) {
|
||||
@ -34,7 +34,7 @@
|
||||
background: var(--white);
|
||||
border-radius: 100px;
|
||||
transform: translateX(0%);
|
||||
transition: transform 0.2s, width 0.2s;
|
||||
transition: transform 0.25s cubic-bezier(0.53, 0.05, 0.02, 1.2);
|
||||
}
|
||||
|
||||
.toggle.enabled {
|
||||
@ -44,8 +44,4 @@
|
||||
.toggle.enabled .toggle-switcher {
|
||||
transform: translateX(var(--enabled-pos));
|
||||
}
|
||||
|
||||
:global(.toggle-container:active .toggle:not(.enabled) .toggle-switcher) {
|
||||
width: calc(var(--base-size) * 1.3);
|
||||
}
|
||||
</style>
|
||||
|
@ -1,11 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/translations";
|
||||
|
||||
import IconComet from "@tabler/icons-svelte/IconComet.svelte";
|
||||
|
||||
let dismissed = false;
|
||||
</script>
|
||||
|
||||
<div id="update-notification" role="alert" aria-atomic="true">
|
||||
<button class="update-button" on:click={() => window.location.reload()}>
|
||||
<button
|
||||
class="button update-button"
|
||||
class:visible={!dismissed}
|
||||
on:click={() => {
|
||||
dismissed = true;
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
<div class="update-icon">
|
||||
<IconComet />
|
||||
</div>
|
||||
@ -32,12 +40,19 @@
|
||||
pointer-events: all;
|
||||
gap: 8px;
|
||||
margin: var(--padding);
|
||||
margin-right: 71px;
|
||||
margin-top: calc(env(safe-area-inset-top) + var(--padding));
|
||||
box-shadow:
|
||||
var(--button-box-shadow),
|
||||
0 0 10px 0px var(--button-elevated-hover);
|
||||
border-radius: 14px;
|
||||
animation: slide-in-top 0.4s;
|
||||
|
||||
transform: translateY(-150px);
|
||||
transition: transform 0.4s cubic-bezier(0.53, 0.05, 0.23, 1.15);
|
||||
}
|
||||
|
||||
.update-button.visible {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.update-icon {
|
||||
@ -74,29 +89,15 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@keyframes slide-in-top {
|
||||
from {
|
||||
transform: translateY(-150px);
|
||||
}
|
||||
100% {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 535px) {
|
||||
#update-notification {
|
||||
bottom: var(--sidebar-height-mobile);
|
||||
bottom: calc(var(--sidebar-height-mobile) + 5px);
|
||||
justify-content: center;
|
||||
animation: slide-in-bottom 0.4s;
|
||||
}
|
||||
|
||||
@keyframes slide-in-bottom {
|
||||
from {
|
||||
transform: translateY(300px);
|
||||
}
|
||||
100% {
|
||||
transform: none;
|
||||
}
|
||||
.update-button {
|
||||
transform: translateY(300px);
|
||||
margin-right: var(--padding);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
212
web/src/components/queue/ProcessingQueue.svelte
Normal file
212
web/src/components/queue/ProcessingQueue.svelte
Normal file
@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { onNavigate } from "$app/navigation";
|
||||
import { onMount, type SvelteComponent } from "svelte";
|
||||
|
||||
import { formatFileSize } from "$lib/util";
|
||||
import { clearFileStorage, getStorageQuota } from "$lib/storage";
|
||||
|
||||
import { queueVisible } from "$lib/state/queue-visibility";
|
||||
import { currentTasks } from "$lib/state/queen-bee/current-tasks";
|
||||
import { clearQueue, queue as readableQueue } from "$lib/state/queen-bee/queue";
|
||||
|
||||
import SectionHeading from "$components/misc/SectionHeading.svelte";
|
||||
import PopoverContainer from "$components/misc/PopoverContainer.svelte";
|
||||
import ProcessingStatus from "$components/queue/ProcessingStatus.svelte";
|
||||
import ProcessingQueueItem from "$components/queue/ProcessingQueueItem.svelte";
|
||||
import ProcessingQueueStub from "$components/queue/ProcessingQueueStub.svelte";
|
||||
|
||||
import IconX from "@tabler/icons-svelte/IconX.svelte";
|
||||
|
||||
let popover: SvelteComponent;
|
||||
let quotaUsage = 0;
|
||||
|
||||
const updateQuota = async () => {
|
||||
const storageInfo = await getStorageQuota();
|
||||
quotaUsage = storageInfo?.usage || 0;
|
||||
}
|
||||
|
||||
const popoverAction = () => {
|
||||
$queueVisible = !$queueVisible;
|
||||
};
|
||||
|
||||
const totalItemProgress = (completed: number, current: number, total: number) => {
|
||||
return (completed * 100 + current) / total
|
||||
}
|
||||
|
||||
$: queue = Object.entries($readableQueue);
|
||||
|
||||
$: totalProgress = queue.length ? queue.map(([, item]) => {
|
||||
if (item.state === "done" || item.state === "error") {
|
||||
return 100;
|
||||
} else if (item.state === "running") {
|
||||
return totalItemProgress(
|
||||
item.completedWorkers?.length || 0,
|
||||
$currentTasks[item.runningWorker]?.progress?.percentage || 0,
|
||||
item.pipeline.length || 0
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}).reduce((a, b) => a + b) / (100 * queue.length) : 0;
|
||||
|
||||
$: indeterminate = queue.length > 0 && totalProgress === 0;
|
||||
|
||||
$: if ($queueVisible) {
|
||||
updateQuota();
|
||||
}
|
||||
|
||||
onNavigate(() => {
|
||||
$queueVisible = false;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// clear old files from storage on first page load
|
||||
clearFileStorage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="processing-queue" class:expanded={$queueVisible}>
|
||||
<ProcessingStatus
|
||||
progress={totalProgress * 100}
|
||||
{indeterminate}
|
||||
expandAction={popoverAction}
|
||||
/>
|
||||
|
||||
<PopoverContainer
|
||||
bind:this={popover}
|
||||
id="processing-popover"
|
||||
expanded={$queueVisible}
|
||||
expandStart="right"
|
||||
>
|
||||
<div id="processing-header">
|
||||
<div class="header-top">
|
||||
<SectionHeading
|
||||
title={$t("queue.title")}
|
||||
sectionId="queue"
|
||||
beta
|
||||
nolink
|
||||
/>
|
||||
<div class="header-buttons">
|
||||
{#if queue.length}
|
||||
<button class="clear-button" on:click={() => {
|
||||
clearQueue();
|
||||
updateQuota();
|
||||
}}>
|
||||
<IconX />
|
||||
{$t("button.clear")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if quotaUsage}
|
||||
<div class="storage-info">
|
||||
{$t("queue.estimated_storage_usage")} {formatFileSize(quotaUsage)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div id="processing-list">
|
||||
{#each queue as [id, item]}
|
||||
<ProcessingQueueItem
|
||||
{id}
|
||||
info={item}
|
||||
runningWorker={
|
||||
item.state === "running" ? $currentTasks[item.runningWorker] : undefined
|
||||
}
|
||||
runningWorkerId={
|
||||
item.state === "running" ? item.runningWorker : undefined
|
||||
}
|
||||
/>
|
||||
{/each}
|
||||
{#if queue.length === 0}
|
||||
<ProcessingQueueStub />
|
||||
{/if}
|
||||
</div>
|
||||
</PopoverContainer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#processing-queue {
|
||||
--holder-padding: 16px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: end;
|
||||
z-index: 9;
|
||||
pointer-events: none;
|
||||
padding: var(--holder-padding);
|
||||
width: calc(100% - var(--holder-padding) * 2);
|
||||
}
|
||||
|
||||
#processing-queue :global(#processing-popover) {
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
padding-bottom: 0;
|
||||
width: calc(100% - 16px * 2);
|
||||
max-width: 425px;
|
||||
}
|
||||
|
||||
#processing-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
font-size: 12px;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
.header-buttons button {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.header-buttons button :global(svg) {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
color: var(--medium-red);
|
||||
}
|
||||
|
||||
#processing-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
max-height: 65vh;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 535px) {
|
||||
#processing-queue {
|
||||
--holder-padding: 8px;
|
||||
padding-top: 4px;
|
||||
top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
</style>
|
352
web/src/components/queue/ProcessingQueueItem.svelte
Normal file
352
web/src/components/queue/ProcessingQueueItem.svelte
Normal file
@ -0,0 +1,352 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { formatFileSize } from "$lib/util";
|
||||
import { downloadFile } from "$lib/download";
|
||||
import { removeItem } from "$lib/state/queen-bee/queue";
|
||||
import { savingHandler } from "$lib/api/saving-handler";
|
||||
|
||||
import type { CobaltQueueItem } from "$lib/types/queue";
|
||||
import type { CobaltWorkerProgress } from "$lib/types/workers";
|
||||
import type { CobaltCurrentTaskItem } from "$lib/types/queen-bee";
|
||||
|
||||
import ProgressBar from "$components/queue/ProgressBar.svelte";
|
||||
|
||||
import IconX from "@tabler/icons-svelte/IconX.svelte";
|
||||
import IconCheck from "@tabler/icons-svelte/IconCheck.svelte";
|
||||
import IconReload from "@tabler/icons-svelte/IconReload.svelte";
|
||||
import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte";
|
||||
import IconDownload from "@tabler/icons-svelte/IconDownload.svelte";
|
||||
import IconExclamationCircle from "@tabler/icons-svelte/IconExclamationCircle.svelte";
|
||||
|
||||
import IconMovie from "@tabler/icons-svelte/IconMovie.svelte";
|
||||
import IconMusic from "@tabler/icons-svelte/IconMusic.svelte";
|
||||
import IconPhoto from "@tabler/icons-svelte/IconPhoto.svelte";
|
||||
|
||||
const itemIcons = {
|
||||
video: IconMovie,
|
||||
audio: IconMusic,
|
||||
image: IconPhoto,
|
||||
};
|
||||
|
||||
export let id: string;
|
||||
export let info: CobaltQueueItem;
|
||||
export let runningWorker: CobaltCurrentTaskItem | undefined;
|
||||
export let runningWorkerId: string | undefined;
|
||||
|
||||
let retrying = false;
|
||||
|
||||
const retry = async (info: CobaltQueueItem) => {
|
||||
if (info.canRetry && info.originalRequest) {
|
||||
retrying = true;
|
||||
await savingHandler({
|
||||
request: info.originalRequest,
|
||||
});
|
||||
retrying = false;
|
||||
}
|
||||
};
|
||||
|
||||
const download = (file: File) =>
|
||||
downloadFile({
|
||||
file: new File([file], info.filename, {
|
||||
type: info.mimeType,
|
||||
}),
|
||||
});
|
||||
|
||||
$: progress = runningWorker?.progress;
|
||||
$: size = formatFileSize(runningWorker?.progress?.size);
|
||||
|
||||
type StatusText = {
|
||||
info: CobaltQueueItem;
|
||||
runningWorker: CobaltCurrentTaskItem | undefined;
|
||||
progress: CobaltWorkerProgress | undefined;
|
||||
size: string;
|
||||
retrying: boolean;
|
||||
};
|
||||
|
||||
const generateStatusText = ({ info, runningWorker, progress, retrying, size }: StatusText) => {
|
||||
switch (info.state) {
|
||||
case "running":
|
||||
if (runningWorker) {
|
||||
const running = $t(`queue.state.running.${runningWorker.type}`);
|
||||
if (progress && progress.percentage) {
|
||||
return `${running}: ${Math.ceil(progress.percentage)}%, ${size}`;
|
||||
}
|
||||
else if (runningWorker && progress && size) {
|
||||
return `${running}: ${size}`;
|
||||
}
|
||||
else if (runningWorker?.type) {
|
||||
const starting = $t(`queue.state.starting.${runningWorker.type}`);
|
||||
|
||||
if (info.pipeline.length > 1) {
|
||||
const currentPipeline = (info.completedWorkers?.length || 0) + 1;
|
||||
return `${starting} (${currentPipeline}/${info.pipeline.length})`;
|
||||
}
|
||||
return starting;
|
||||
}
|
||||
}
|
||||
return $t("queue.state.starting");
|
||||
|
||||
case "done":
|
||||
return formatFileSize(info.resultFile?.file?.size);
|
||||
|
||||
case "error":
|
||||
return !retrying ? info.errorCode : $t("queue.state.retrying");
|
||||
|
||||
case "waiting":
|
||||
return $t("queue.state.waiting");
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
params are passed here because svelte will re-run
|
||||
the function every time either of them is changed,
|
||||
which is what we want in this case :3
|
||||
*/
|
||||
$: statusText = generateStatusText({
|
||||
info,
|
||||
runningWorker,
|
||||
progress,
|
||||
retrying,
|
||||
size,
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="processing-item">
|
||||
<div class="processing-info">
|
||||
<div class="file-title">
|
||||
<div class="processing-type">
|
||||
<svelte:component this={itemIcons[info.mediaType]} />
|
||||
</div>
|
||||
<span class="filename">
|
||||
{info.filename}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if info.state === "running"}
|
||||
<div class="progress-holder">
|
||||
{#each info.pipeline as pipeline}
|
||||
<ProgressBar
|
||||
percentage={progress?.percentage}
|
||||
workerId={pipeline.workerId}
|
||||
{runningWorkerId}
|
||||
completedWorkers={info.completedWorkers}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="file-status {info.state}" class:retrying>
|
||||
<div class="status-icon">
|
||||
{#if info.state === "done"}
|
||||
<IconCheck />
|
||||
{/if}
|
||||
{#if info.state === "error" && !retrying}
|
||||
<IconExclamationCircle />
|
||||
{/if}
|
||||
{#if info.state === "running" || retrying}
|
||||
<div class="status-spinner">
|
||||
<IconLoader2 />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="status-text">
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-actions">
|
||||
{#if info.state === "done" && info.resultFile}
|
||||
<button
|
||||
class="button action-button"
|
||||
on:click={() => download(info.resultFile.file)}
|
||||
>
|
||||
<IconDownload />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if !retrying}
|
||||
{#if info.state === "error" && info?.canRetry}
|
||||
<button
|
||||
class="button action-button"
|
||||
on:click={() => retry(info)}
|
||||
>
|
||||
<IconReload />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="button action-button"
|
||||
on:click={() => removeItem(id)}
|
||||
>
|
||||
<IconX />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.processing-item,
|
||||
.file-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.processing-item {
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
gap: 8px;
|
||||
border-bottom: 1.5px var(--button-elevated) solid;
|
||||
}
|
||||
|
||||
.processing-type {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.processing-type :global(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
.processing-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-holder {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.file-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.filename {
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-status {
|
||||
font-size: 12px;
|
||||
color: var(--gray);
|
||||
line-break: anywhere;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-status.error:not(.retrying) {
|
||||
color: var(--medium-red);
|
||||
}
|
||||
|
||||
.file-status :global(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.status-icon,
|
||||
.status-spinner,
|
||||
.status-text {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/*
|
||||
margin is used instead of gap cuz queued state doesn't have an icon.
|
||||
margin is applied only to the visible icon, so there's no awkward gap.
|
||||
*/
|
||||
.status-icon :global(svg) {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
:global([dir="rtl"]) .status-icon :global(svg) {
|
||||
margin-left: 6px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.status-spinner :global(svg) {
|
||||
animation: spinner 0.7s infinite linear;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.file-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background-color: var(--button);
|
||||
height: 90%;
|
||||
padding-left: 18px;
|
||||
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 20%
|
||||
);
|
||||
}
|
||||
|
||||
:global([dir="rtl"]) .file-actions {
|
||||
left: 0;
|
||||
right: unset;
|
||||
padding-left: 0;
|
||||
padding-right: 18px;
|
||||
mask-image: linear-gradient(
|
||||
-90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(0, 0, 0, 1) 20%
|
||||
);
|
||||
}
|
||||
|
||||
.processing-item:hover .file-actions {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.processing-info {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 8px;
|
||||
height: auto;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.action-button :global(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
.processing-item:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.processing-item:last-child {
|
||||
padding-bottom: 16px;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
44
web/src/components/queue/ProcessingQueueStub.svelte
Normal file
44
web/src/components/queue/ProcessingQueueStub.svelte
Normal file
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import Meowbalt from "$components/misc/Meowbalt.svelte";
|
||||
|
||||
const stubActions = ["download", "remux"];
|
||||
|
||||
const randomAction = () => {
|
||||
return stubActions[Math.floor(Math.random() * stubActions.length)];
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="queue-stub">
|
||||
<Meowbalt emotion="think" />
|
||||
<span class="subtext stub-text">
|
||||
{$t("queue.stub", {
|
||||
value: $t(`queue.stub.${randomAction()}`),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.queue-stub {
|
||||
--base-padding: calc(var(--padding) * 1.5);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gray);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--base-padding);
|
||||
padding-bottom: calc(var(--base-padding) + 16px);
|
||||
text-align: center;
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
.queue-stub :global(.meowbalt) {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.stub-text {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
133
web/src/components/queue/ProcessingStatus.svelte
Normal file
133
web/src/components/queue/ProcessingStatus.svelte
Normal file
@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import IconArrowDown from "@tabler/icons-svelte/IconArrowDown.svelte";
|
||||
|
||||
export let indeterminate = false;
|
||||
export let progress: number = 0;
|
||||
export let expandAction: () => void;
|
||||
|
||||
$: progressStroke = `${progress}, 100`;
|
||||
const indeterminateStroke = "15, 5";
|
||||
</script>
|
||||
|
||||
<button
|
||||
id="processing-status"
|
||||
on:click={expandAction}
|
||||
class="button"
|
||||
class:completed={progress >= 100}
|
||||
>
|
||||
<svg
|
||||
id="progress-ring"
|
||||
class:indeterminate
|
||||
class:progressive={progress > 0 && !indeterminate}
|
||||
>
|
||||
<circle
|
||||
cx="19"
|
||||
cy="19"
|
||||
r="16"
|
||||
fill="none"
|
||||
stroke-dasharray={indeterminate
|
||||
? indeterminateStroke
|
||||
: progressStroke}
|
||||
/>
|
||||
</svg>
|
||||
<div class="icon-holder">
|
||||
<IconArrowDown />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
#processing-status {
|
||||
--processing-status-glow: 0 0 8px 0px var(--button-elevated-hover);
|
||||
|
||||
pointer-events: all;
|
||||
padding: 7px;
|
||||
border-radius: 30px;
|
||||
box-shadow:
|
||||
var(--button-box-shadow),
|
||||
var(--processing-status-glow);
|
||||
|
||||
transition: box-shadow 0.2s, background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
#processing-status:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--white) !important;
|
||||
}
|
||||
|
||||
#processing-status:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
#processing-status.completed {
|
||||
box-shadow:
|
||||
var(--focus-ring),
|
||||
var(--processing-status-glow);
|
||||
}
|
||||
|
||||
:global([data-theme="light"]) #processing-status.completed {
|
||||
background-color: #e0eeff;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) #processing-status.completed {
|
||||
background-color: #1f3249;
|
||||
}
|
||||
|
||||
.icon-holder {
|
||||
display: flex;
|
||||
background-color: var(--button-elevated-hover);
|
||||
padding: 2px;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.icon-holder :global(svg) {
|
||||
height: 21px;
|
||||
width: 21px;
|
||||
stroke: var(--secondary);
|
||||
stroke-width: 1.5px;
|
||||
transition: stroke 0.2s;
|
||||
}
|
||||
|
||||
.completed .icon-holder {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
|
||||
.completed .icon-holder :global(svg) {
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
#progress-ring {
|
||||
position: absolute;
|
||||
transform: rotate(-90deg);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
#progress-ring circle {
|
||||
stroke: var(--blue);
|
||||
stroke-width: 4;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
#progress-ring.progressive circle {
|
||||
transition: stroke-dasharray 0.2s;
|
||||
}
|
||||
|
||||
#progress-ring.progressive,
|
||||
#progress-ring.indeterminate {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#progress-ring.indeterminate {
|
||||
animation: spinner 3s linear infinite;
|
||||
}
|
||||
|
||||
#progress-ring.indeterminate circle {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.completed #progress-ring {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
50
web/src/components/queue/ProgressBar.svelte
Normal file
50
web/src/components/queue/ProgressBar.svelte
Normal file
@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import Skeleton from "$components/misc/Skeleton.svelte";
|
||||
|
||||
export let percentage: number = 0;
|
||||
export let workerId: string;
|
||||
export let runningWorkerId: string | undefined;
|
||||
export let completedWorkers: string[] = [];
|
||||
</script>
|
||||
|
||||
<div class="file-progress">
|
||||
{#if percentage && workerId === runningWorkerId}
|
||||
<div
|
||||
class="progress"
|
||||
style="width: {Math.min(100, percentage || 0)}%"
|
||||
></div>
|
||||
{:else if completedWorkers?.includes(workerId)}
|
||||
<div
|
||||
class="progress"
|
||||
style="width: 100%"
|
||||
></div>
|
||||
{:else if workerId === runningWorkerId}
|
||||
<Skeleton
|
||||
height="6px"
|
||||
width="100%"
|
||||
class="elevated indeterminate-progress"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-progress {
|
||||
width: 100%;
|
||||
background-color: var(--button-elevated);
|
||||
}
|
||||
|
||||
.file-progress,
|
||||
.file-progress .progress {
|
||||
height: 6px;
|
||||
border-radius: 10px;
|
||||
transition: width 0.1s;
|
||||
}
|
||||
|
||||
.file-progress :global(.indeterminate-progress) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.file-progress .progress {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
</style>
|
@ -10,7 +10,9 @@
|
||||
|
||||
import dialogs from "$lib/state/dialogs";
|
||||
import { link } from "$lib/state/omnibox";
|
||||
import { hapticSwitch } from "$lib/haptics";
|
||||
import { updateSetting } from "$lib/state/settings";
|
||||
import { savingHandler } from "$lib/api/saving-handler";
|
||||
import { pasteLinkFromClipboard } from "$lib/clipboard";
|
||||
import { turnstileEnabled, turnstileSolved } from "$lib/state/turnstile";
|
||||
|
||||
@ -65,6 +67,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
hapticSwitch();
|
||||
|
||||
const pastedData = await pasteLinkFromClipboard();
|
||||
if (!pastedData) return;
|
||||
|
||||
@ -75,7 +79,7 @@
|
||||
|
||||
if (!isBotCheckOngoing) {
|
||||
await tick(); // wait for button to render
|
||||
downloadButton.download($link);
|
||||
savingHandler({ url: $link });
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -94,7 +98,7 @@
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && validLink($link) && isFocused) {
|
||||
downloadButton.download($link);
|
||||
savingHandler({ url: $link });
|
||||
}
|
||||
|
||||
if (["Escape", "Clear"].includes(e.key) && isFocused) {
|
||||
@ -217,7 +221,7 @@
|
||||
flex-direction: column;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
#input-container {
|
||||
@ -242,8 +246,8 @@
|
||||
}
|
||||
|
||||
#input-container.focused {
|
||||
box-shadow: 0 0 0 1.5px var(--secondary) inset;
|
||||
outline: var(--secondary) 0.5px solid;
|
||||
box-shadow: 0 0 0 1px var(--secondary) inset;
|
||||
outline: var(--secondary) 1px solid;
|
||||
}
|
||||
|
||||
#input-container.focused :global(#input-icons svg) {
|
||||
|
@ -3,10 +3,39 @@
|
||||
import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte";
|
||||
|
||||
export let loading: boolean;
|
||||
export let animated = !!loading;
|
||||
|
||||
/*
|
||||
initial spinner state is equal to loading state,
|
||||
just so it's animated on init (or not).
|
||||
on transition start, it overrides the value
|
||||
to start spinning (to prevent zooming in with no spinning).
|
||||
|
||||
then, on transition end, when the spinner is hidden,
|
||||
and if loading state is false, the class is removed
|
||||
and the spinner doesn't spin in background while being invisible.
|
||||
|
||||
if loading state is true, then it will just stay spinning
|
||||
(aka when it's visible and should be spinning).
|
||||
|
||||
the spin on transition start is needed for the whirlpool effect
|
||||
of the link icon being sucked into the spinner.
|
||||
|
||||
this may be unnecessarily complicated but i think it looks neat.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<div id="input-icons" class:loading>
|
||||
<div class="input-icon spinner-icon">
|
||||
<div
|
||||
class="input-icon spinner-icon"
|
||||
class:animated
|
||||
on:transitionstart={() => {
|
||||
animated = true;
|
||||
}}
|
||||
on:transitionend={() => {
|
||||
animated = !!loading;
|
||||
}}
|
||||
>
|
||||
<IconLoader2 />
|
||||
</div>
|
||||
<div class="input-icon link-icon">
|
||||
@ -49,12 +78,12 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spinner-icon :global(svg) {
|
||||
animation: spin 0.7s infinite linear;
|
||||
.spinner-icon.animated :global(svg) {
|
||||
animation: spinner 0.7s infinite linear;
|
||||
}
|
||||
|
||||
.loading .link-icon :global(svg) {
|
||||
animation: spin 0.7s infinite linear;
|
||||
animation: spinner 0.7s linear;
|
||||
}
|
||||
|
||||
.loading .link-icon {
|
||||
@ -66,13 +95,4 @@
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,18 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { getServerInfo } from "$lib/api/server-info";
|
||||
import cachedInfo from "$lib/state/server-info";
|
||||
import { getServerInfo } from "$lib/api/server-info";
|
||||
|
||||
import type { SvelteComponent } from "svelte";
|
||||
|
||||
import Skeleton from "$components/misc/Skeleton.svelte";
|
||||
import IconPlus from "@tabler/icons-svelte/IconPlus.svelte";
|
||||
import PopoverContainer from "$components/misc/PopoverContainer.svelte";
|
||||
|
||||
let services: string[] = [];
|
||||
|
||||
let popover: HTMLDivElement;
|
||||
|
||||
let popover: SvelteComponent;
|
||||
$: expanded = false;
|
||||
|
||||
let servicesContainer: HTMLDivElement;
|
||||
$: loaded = false;
|
||||
$: renderPopover = false;
|
||||
|
||||
const loadInfo = async () => {
|
||||
await getServerInfo();
|
||||
@ -29,19 +32,7 @@
|
||||
await loadInfo();
|
||||
}
|
||||
if (expanded) {
|
||||
popover.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const showPopover = async () => {
|
||||
const timeout = !renderPopover;
|
||||
renderPopover = true;
|
||||
|
||||
// 10ms delay to let the popover render for the first time
|
||||
if (timeout) {
|
||||
setTimeout(popoverAction, 10);
|
||||
} else {
|
||||
await popoverAction();
|
||||
servicesContainer.focus();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@ -49,7 +40,8 @@
|
||||
<div id="supported-services" class:expanded>
|
||||
<button
|
||||
id="services-button"
|
||||
on:click={showPopover}
|
||||
class="button"
|
||||
on:click={popoverAction}
|
||||
aria-label={$t(`save.services.title_${expanded ? "hide" : "show"}`)}
|
||||
>
|
||||
<div class="expand-icon">
|
||||
@ -58,33 +50,35 @@
|
||||
<span class="title">{$t("save.services.title")}</span>
|
||||
</button>
|
||||
|
||||
{#if renderPopover}
|
||||
<div id="services-popover">
|
||||
<div
|
||||
id="services-container"
|
||||
bind:this={popover}
|
||||
tabindex="-1"
|
||||
data-focus-ring-hidden
|
||||
>
|
||||
{#if loaded}
|
||||
{#each services as service}
|
||||
<div class="service-item">{service}</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each { length: 17 } as _}
|
||||
<Skeleton
|
||||
class="elevated"
|
||||
width={Math.random() * 44 + 50 + "px"}
|
||||
height="24.5px"
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div id="services-disclaimer" class="subtext">
|
||||
{$t("save.services.disclaimer")}
|
||||
</div>
|
||||
<PopoverContainer
|
||||
bind:this={popover}
|
||||
id="services-popover"
|
||||
{expanded}
|
||||
>
|
||||
<div
|
||||
id="services-container"
|
||||
bind:this={servicesContainer}
|
||||
tabindex="-1"
|
||||
data-focus-ring-hidden
|
||||
>
|
||||
{#if loaded}
|
||||
{#each services as service}
|
||||
<div class="service-item">{service}</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each { length: 17 } as _}
|
||||
<Skeleton
|
||||
class="elevated"
|
||||
width={Math.random() * 44 + 50 + "px"}
|
||||
height="24.5px"
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div id="services-disclaimer" class="subtext">
|
||||
{$t("save.services.disclaimer")}
|
||||
</div>
|
||||
</PopoverContainer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@ -97,34 +91,6 @@
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
#services-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 18px;
|
||||
background: var(--button);
|
||||
box-shadow:
|
||||
var(--button-box-shadow),
|
||||
0 0 10px 10px var(--popover-glow);
|
||||
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
gap: 6px;
|
||||
top: 6px;
|
||||
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
transform-origin: top center;
|
||||
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.53, 0.05, 0.23, 1.15),
|
||||
opacity 0.25s cubic-bezier(0.53, 0.05, 0.23, 0.99);
|
||||
}
|
||||
|
||||
.expanded #services-popover {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#services-button {
|
||||
gap: 9px;
|
||||
padding: 7px 13px 7px 10px;
|
||||
@ -135,9 +101,10 @@
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
transition: background 0.2s, box-shadow 0.1s;
|
||||
}
|
||||
|
||||
#services-button:not(:focus-visible) {
|
||||
#services-button:not(:focus-visible):not(:active) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@ -151,19 +118,37 @@
|
||||
background: var(--button-elevated);
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
transition: transform 0.2s;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
#services-button:active .expand-icon {
|
||||
background: var(--button-elevated-hover);
|
||||
#services-button:active {
|
||||
background: var(--button-hover-transparent);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
#services-button:hover {
|
||||
background: var(--button-hover-transparent);
|
||||
}
|
||||
|
||||
#services-button:active {
|
||||
background: var(--button-press-transparent);
|
||||
}
|
||||
|
||||
#services-button:hover .expand-icon {
|
||||
background: var(--button-elevated-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
#services-button:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
#services-button:active .expand-icon {
|
||||
background: var(--button-elevated-press);
|
||||
}
|
||||
|
||||
.expand-icon :global(svg) {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
|
@ -7,6 +7,7 @@
|
||||
|
||||
<button
|
||||
id="clear-button"
|
||||
class="button"
|
||||
on:click={click}
|
||||
aria-label={$t("a11y.save.clear_input")}
|
||||
>
|
||||
|
@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import "@fontsource-variable/noto-sans-mono";
|
||||
|
||||
import API from "$lib/api/api";
|
||||
import { onDestroy } from "svelte";
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { createDialog } from "$lib/state/dialogs";
|
||||
import { downloadFile } from "$lib/download";
|
||||
import { hapticSwitch } from "$lib/haptics";
|
||||
import { savingHandler } from "$lib/api/saving-handler";
|
||||
import { downloadButtonState } from "$lib/state/omnibox";
|
||||
|
||||
import type { DialogInfo } from "$lib/types/dialog";
|
||||
import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
|
||||
|
||||
export let url: string;
|
||||
export let disabled = false;
|
||||
@ -15,148 +16,50 @@
|
||||
$: buttonText = ">>";
|
||||
$: buttonAltText = $t("a11y.save.download");
|
||||
|
||||
let defaultErrorPopup: DialogInfo = {
|
||||
id: "save-error",
|
||||
type: "small",
|
||||
meowbalt: "error",
|
||||
buttons: [
|
||||
{
|
||||
text: $t("button.gotit"),
|
||||
main: true,
|
||||
action: () => {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type DownloadButtonState = "idle" | "think" | "check" | "done" | "error";
|
||||
|
||||
const changeDownloadButton = (state: DownloadButtonState) => {
|
||||
disabled = state !== "idle";
|
||||
loading = state === "think" || state === "check";
|
||||
const unsubscribe = downloadButtonState.subscribe(
|
||||
(state: CobaltDownloadButtonState) => {
|
||||
disabled = state !== "idle";
|
||||
loading = state === "think" || state === "check";
|
||||
|
||||
buttonText = {
|
||||
idle: ">>",
|
||||
think: "...",
|
||||
check: "..?",
|
||||
done: ">>>",
|
||||
error: "!!",
|
||||
}[state];
|
||||
buttonText = {
|
||||
idle: ">>",
|
||||
think: "...",
|
||||
check: "..?",
|
||||
done: ">>>",
|
||||
error: "!!",
|
||||
}[state];
|
||||
|
||||
buttonAltText = $t(
|
||||
{
|
||||
idle: "a11y.save.download",
|
||||
think: "a11y.save.download.think",
|
||||
check: "a11y.save.download.check",
|
||||
done: "a11y.save.download.done",
|
||||
error: "a11y.save.download.error",
|
||||
}[state]
|
||||
);
|
||||
|
||||
// states that don't wait for anything, and thus can
|
||||
// transition back to idle after some period of time.
|
||||
const final: DownloadButtonState[] = ["done", "error"];
|
||||
if (final.includes(state)) {
|
||||
setTimeout(() => changeDownloadButton("idle"), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
export const download = async (link: string) => {
|
||||
changeDownloadButton("think");
|
||||
|
||||
const response = await API.request(link);
|
||||
|
||||
if (!response) {
|
||||
changeDownloadButton("error");
|
||||
|
||||
return createDialog({
|
||||
...defaultErrorPopup,
|
||||
bodyText: $t("error.api.unreachable"),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === "error") {
|
||||
changeDownloadButton("error");
|
||||
|
||||
return createDialog({
|
||||
...defaultErrorPopup,
|
||||
bodyText: $t(response.error.code, response?.error?.context),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === "redirect") {
|
||||
changeDownloadButton("done");
|
||||
|
||||
return downloadFile({
|
||||
url: response.url,
|
||||
urlType: "redirect",
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === "tunnel") {
|
||||
changeDownloadButton("check");
|
||||
|
||||
const probeResult = await API.probeCobaltTunnel(response.url);
|
||||
|
||||
if (probeResult === 200) {
|
||||
changeDownloadButton("done");
|
||||
|
||||
return downloadFile({
|
||||
url: response.url,
|
||||
});
|
||||
} else {
|
||||
changeDownloadButton("error");
|
||||
|
||||
return createDialog({
|
||||
...defaultErrorPopup,
|
||||
bodyText: $t("error.tunnel.probe"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === "picker") {
|
||||
changeDownloadButton("done");
|
||||
const buttons = [
|
||||
buttonAltText = $t(
|
||||
{
|
||||
text: $t("button.done"),
|
||||
main: true,
|
||||
action: () => {},
|
||||
},
|
||||
];
|
||||
idle: "a11y.save.download",
|
||||
think: "a11y.save.download.think",
|
||||
check: "a11y.save.download.check",
|
||||
done: "a11y.save.download.done",
|
||||
error: "a11y.save.download.error",
|
||||
}[state]
|
||||
);
|
||||
|
||||
if (response.audio) {
|
||||
const pickerAudio = response.audio;
|
||||
buttons.unshift({
|
||||
text: $t("button.download.audio"),
|
||||
main: false,
|
||||
action: () => {
|
||||
downloadFile({
|
||||
url: pickerAudio,
|
||||
});
|
||||
},
|
||||
});
|
||||
// states that don't wait for anything, and thus can
|
||||
// transition back to idle after some period of time.
|
||||
const final: DownloadButtonState[] = ["done", "error"];
|
||||
if (final.includes(state)) {
|
||||
setTimeout(() => downloadButtonState.set("idle"), 1500);
|
||||
}
|
||||
|
||||
return createDialog({
|
||||
id: "download-picker",
|
||||
type: "picker",
|
||||
items: response.picker,
|
||||
buttons,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
changeDownloadButton("error");
|
||||
|
||||
return createDialog({
|
||||
...defaultErrorPopup,
|
||||
bodyText: $t("error.api.unknown_response"),
|
||||
});
|
||||
};
|
||||
onDestroy(() => unsubscribe());
|
||||
</script>
|
||||
|
||||
<button
|
||||
id="download-button"
|
||||
{disabled}
|
||||
on:click={() => download(url)}
|
||||
on:click={() => {
|
||||
hapticSwitch();
|
||||
savingHandler({ url });
|
||||
}}
|
||||
aria-label={buttonAltText}
|
||||
>
|
||||
<span id="download-state">{buttonText}</span>
|
||||
@ -170,9 +73,12 @@
|
||||
|
||||
height: 100%;
|
||||
min-width: 48px;
|
||||
width: 48px;
|
||||
|
||||
border-radius: 0;
|
||||
padding: 0 12px;
|
||||
|
||||
/* visually align the button, +1.5px because of inset box-shadow on parent */
|
||||
padding: 0 13.5px 0 12px;
|
||||
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
@ -194,7 +100,7 @@
|
||||
}
|
||||
|
||||
#download-button:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--blue) inset;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
#download-state {
|
||||
@ -212,7 +118,7 @@
|
||||
|
||||
#download-button:disabled {
|
||||
cursor: unset;
|
||||
opacity: 0.7;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
:global(#input-container.focused) #download-button {
|
||||
@ -225,11 +131,12 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
#download-button:hover {
|
||||
#download-button:hover:not(:disabled) {
|
||||
background: var(--button-hover-transparent);
|
||||
}
|
||||
#download-button:disabled:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
#download-button:active:not(:disabled) {
|
||||
background: var(--button-press-transparent);
|
||||
}
|
||||
</style>
|
||||
|
42
web/src/components/settings/ClearStorageButton.svelte
Normal file
42
web/src/components/settings/ClearStorageButton.svelte
Normal file
@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { createDialog } from "$lib/state/dialogs";
|
||||
import { clearQueue } from "$lib/state/queen-bee/queue";
|
||||
import { clearCacheStorage, clearFileStorage } from "$lib/storage";
|
||||
|
||||
import IconFileShredder from "@tabler/icons-svelte/IconFileShredder.svelte";
|
||||
import DataSettingsButton from "$components/settings/DataSettingsButton.svelte";
|
||||
|
||||
const clearDialog = () => {
|
||||
createDialog({
|
||||
id: "wipe-confirm",
|
||||
type: "small",
|
||||
icon: "warn-red",
|
||||
title: $t("dialog.clear_cache.title"),
|
||||
bodyText: $t("dialog.clear_cache.body"),
|
||||
buttons: [
|
||||
{
|
||||
text: $t("button.cancel"),
|
||||
main: false,
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
text: $t("button.clear"),
|
||||
color: "red",
|
||||
main: true,
|
||||
timeout: 2000,
|
||||
action: async () => {
|
||||
clearQueue();
|
||||
await clearFileStorage();
|
||||
await clearCacheStorage();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<DataSettingsButton id="clear-cache" click={clearDialog} danger>
|
||||
<IconFileShredder />
|
||||
{$t("button.clear_cache")}
|
||||
</DataSettingsButton>
|
32
web/src/components/settings/DataSettingsButton.svelte
Normal file
32
web/src/components/settings/DataSettingsButton.svelte
Normal file
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
export let id: string;
|
||||
export let click: () => void;
|
||||
export let danger = false;
|
||||
</script>
|
||||
|
||||
<button {id} class="button data-button" class:danger on:click={click}>
|
||||
<slot></slot>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.data-button {
|
||||
padding: 8px 14px;
|
||||
width: max-content;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.data-button :global(svg) {
|
||||
stroke-width: 1.8px;
|
||||
height: 21px;
|
||||
width: 21px;
|
||||
}
|
||||
|
||||
.data-button.danger {
|
||||
background-color: var(--red);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.data-button.danger:hover {
|
||||
background-color: var(--dark-red);
|
||||
}
|
||||
</style>
|
@ -106,12 +106,16 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px var(--padding);
|
||||
gap: 9px;
|
||||
padding: 7px var(--padding);
|
||||
}
|
||||
|
||||
.filename-preview-item:first-child {
|
||||
border-bottom: 1.5px var(--button-stroke) solid;
|
||||
border-bottom: 1px var(--button-stroke) solid;
|
||||
}
|
||||
|
||||
.filename-preview-item:last-child {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
@ -144,6 +148,7 @@
|
||||
|
||||
.item-text .description {
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
|
@ -5,7 +5,7 @@
|
||||
import { validateSettings } from "$lib/settings/validate";
|
||||
import { storedSettings, updateSetting, loadFromString } from "$lib/state/settings";
|
||||
|
||||
import ActionButton from "$components/buttons/ActionButton.svelte";
|
||||
import DataSettingsButton from "$components/settings/DataSettingsButton.svelte";
|
||||
import ResetSettingsButton from "$components/settings/ResetSettingsButton.svelte";
|
||||
|
||||
import IconFileExport from "@tabler/icons-svelte/IconFileExport.svelte";
|
||||
@ -95,16 +95,16 @@
|
||||
</script>
|
||||
|
||||
<div class="button-row" id="settings-data-transfer">
|
||||
<ActionButton id="import-settings" click={importSettings}>
|
||||
<DataSettingsButton id="import-settings" click={importSettings}>
|
||||
<IconFileImport />
|
||||
{$t("button.import")}
|
||||
</ActionButton>
|
||||
</DataSettingsButton>
|
||||
|
||||
{#if $storedSettings.schemaVersion}
|
||||
<ActionButton id="export-settings" click={exportSettings}>
|
||||
<DataSettingsButton id="export-settings" click={exportSettings}>
|
||||
<IconFileExport />
|
||||
{$t("button.export")}
|
||||
</ActionButton>
|
||||
</DataSettingsButton>
|
||||
{/if}
|
||||
|
||||
{#if $storedSettings.schemaVersion}
|
||||
|
@ -3,15 +3,16 @@
|
||||
import { createDialog } from "$lib/state/dialogs";
|
||||
import { resetSettings } from "$lib/state/settings";
|
||||
|
||||
import IconTrash from "@tabler/icons-svelte/IconTrash.svelte";
|
||||
import IconRestore from "@tabler/icons-svelte/IconRestore.svelte";
|
||||
import DataSettingsButton from "$components/settings/DataSettingsButton.svelte";
|
||||
|
||||
const resetDialog = () => {
|
||||
createDialog({
|
||||
id: "wipe-confirm",
|
||||
type: "small",
|
||||
icon: "warn-red",
|
||||
title: $t("dialog.reset.title"),
|
||||
bodyText: $t("dialog.reset.body"),
|
||||
title: $t("dialog.reset_settings.title"),
|
||||
bodyText: $t("dialog.reset_settings.body"),
|
||||
buttons: [
|
||||
{
|
||||
text: $t("button.cancel"),
|
||||
@ -30,26 +31,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<button id="setting-button-reset" class="button" on:click={resetDialog}>
|
||||
<IconTrash />
|
||||
<DataSettingsButton id="reset-settings" click={resetDialog} danger>
|
||||
<IconRestore />
|
||||
{$t("button.reset")}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
#setting-button-reset {
|
||||
background-color: var(--red);
|
||||
color: var(--white);
|
||||
width: max-content;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
#setting-button-reset:hover {
|
||||
background-color: var(--dark-red);
|
||||
}
|
||||
|
||||
#setting-button-reset :global(svg) {
|
||||
stroke-width: 2px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
</style>
|
||||
</DataSettingsButton>
|
||||
|
@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { copyURL as _copyURL } from "$lib/download";
|
||||
|
||||
import SectionHeading from "$components/misc/SectionHeading.svelte";
|
||||
|
||||
export let title: string;
|
||||
|
@ -8,6 +8,7 @@
|
||||
import { updateSetting } from "$lib/state/settings";
|
||||
import type { CobaltSettings } from "$lib/types/settings";
|
||||
|
||||
import { hapticConfirm, hapticSwitch } from "$lib/haptics";
|
||||
import IconSelector from "@tabler/icons-svelte/IconSelector.svelte";
|
||||
|
||||
export let title: string;
|
||||
@ -22,8 +23,9 @@
|
||||
export let disabled = false;
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
hapticConfirm();
|
||||
|
||||
const target = event.target as HTMLSelectElement;
|
||||
updateSetting({
|
||||
[settingContext]: {
|
||||
[settingId]: target.value,
|
||||
@ -46,13 +48,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select on:change={e => onChange(e)} {disabled}>
|
||||
<select
|
||||
on:click={() => hapticSwitch()}
|
||||
on:change={(e) => onChange(e)}
|
||||
{disabled}
|
||||
>
|
||||
{#each Object.keys(items) as value, i}
|
||||
<option {value} selected={selectedOption === value}>
|
||||
{items[value]}
|
||||
</option>
|
||||
{#if i === 0}
|
||||
<hr>
|
||||
<hr />
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
@ -157,10 +163,4 @@
|
||||
background: initial;
|
||||
border: initial;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.selector:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -14,25 +14,59 @@
|
||||
|
||||
import IconX from "@tabler/icons-svelte/IconX.svelte";
|
||||
import IconCheck from "@tabler/icons-svelte/IconCheck.svelte";
|
||||
import IconArrowBack from "@tabler/icons-svelte/IconArrowBack.svelte";
|
||||
|
||||
import IconEye from "@tabler/icons-svelte/IconEye.svelte";
|
||||
import IconEyeClosed from "@tabler/icons-svelte/IconEyeClosed.svelte";
|
||||
|
||||
type SettingsInputType = "url" | "uuid";
|
||||
|
||||
export let settingId: Id;
|
||||
export let settingContext: Context;
|
||||
export let placeholder: string;
|
||||
export let altText: string;
|
||||
export let type: "url" | "uuid" = "url";
|
||||
|
||||
export let sensitive = false;
|
||||
export let showInstanceWarning = false;
|
||||
|
||||
const regex = {
|
||||
url: "https?:\\/\\/[a-z0-9.\\-]+(:\\d+)?/?",
|
||||
uuid: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
};
|
||||
|
||||
let input: HTMLInputElement;
|
||||
let inputValue: string = String(get(settings)[settingContext][settingId]);
|
||||
let inputFocused = false;
|
||||
let validInput = false;
|
||||
let validInput = true;
|
||||
|
||||
const writeToSettings = (value: string, type: "url" | "uuid" | "text") => {
|
||||
let inputHidden = true;
|
||||
|
||||
$: inputType = sensitive && inputHidden ? "password" : "text";
|
||||
|
||||
const checkInput = () => {
|
||||
// mark input as valid if it's empty to allow wiping
|
||||
if (inputValue.length === 0) {
|
||||
validInput = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "url") {
|
||||
try {
|
||||
new URL(inputValue)?.origin?.toString();
|
||||
validInput = true;
|
||||
return;
|
||||
} catch {
|
||||
validInput = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
validInput = new RegExp(regex[type]).test(inputValue);
|
||||
}
|
||||
}
|
||||
|
||||
const writeToSettings = (value: string, type: SettingsInputType) => {
|
||||
// we assume that the url is valid and error can't be thrown here
|
||||
// since it was tested before by checkInput()
|
||||
updateSetting({
|
||||
[settingContext]: {
|
||||
[settingId]:
|
||||
@ -46,8 +80,9 @@
|
||||
if (showInstanceWarning) {
|
||||
await customInstanceWarning();
|
||||
|
||||
if ($settings.processing.seenCustomWarning && inputValue) {
|
||||
return writeToSettings(inputValue, type);
|
||||
if ($settings.processing.seenCustomWarning) {
|
||||
// fall back to uuid to allow writing empty strings
|
||||
return writeToSettings(inputValue, inputValue ? type : "uuid");
|
||||
}
|
||||
|
||||
return;
|
||||
@ -58,49 +93,89 @@
|
||||
</script>
|
||||
|
||||
<div id="settings-input-holder">
|
||||
<div id="input-container" class:focused={inputFocused} aria-hidden="false">
|
||||
<div
|
||||
id="input-container"
|
||||
class:focused={inputFocused}
|
||||
aria-hidden="false"
|
||||
>
|
||||
<input
|
||||
id="input-box"
|
||||
class="input-box"
|
||||
bind:this={input}
|
||||
bind:value={inputValue}
|
||||
on:input={() => (validInput = input.checkValidity())}
|
||||
on:input={() => (inputFocused = true)}
|
||||
on:input={() => {
|
||||
inputFocused = true;
|
||||
checkInput();
|
||||
}}
|
||||
on:focus={() => (inputFocused = true)}
|
||||
on:blur={() => (inputFocused = false)}
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
maxlength="64"
|
||||
pattern={regex[type]}
|
||||
aria-label={altText}
|
||||
aria-hidden="false"
|
||||
aria-invalid={!validInput}
|
||||
{...{ type: inputType }}
|
||||
/>
|
||||
|
||||
{#if inputValue.length > 0}
|
||||
<button
|
||||
class="button input-inner-button"
|
||||
on:click={() => {
|
||||
inputValue = "";
|
||||
checkInput();
|
||||
}}
|
||||
aria-label={$t("button.clear_input")}
|
||||
>
|
||||
<IconX />
|
||||
</button>
|
||||
|
||||
{#if sensitive}
|
||||
<button
|
||||
class="button input-inner-button"
|
||||
on:click={() => (inputHidden = !inputHidden)}
|
||||
aria-label={$t(
|
||||
inputHidden ? "button.show_input" : "button.hide_input"
|
||||
)}
|
||||
>
|
||||
{#if inputHidden}
|
||||
<IconEye />
|
||||
{:else}
|
||||
<IconEyeClosed />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if inputValue.length === 0}
|
||||
<span class="input-placeholder" aria-hidden="true">
|
||||
{placeholder}
|
||||
</span>
|
||||
|
||||
{#if String($settings[settingContext][settingId]).length > 0}
|
||||
<button
|
||||
class="button input-inner-button"
|
||||
on:click={() => {
|
||||
inputValue = String($settings[settingContext][settingId]);
|
||||
checkInput();
|
||||
}}
|
||||
aria-label={$t("button.restore_input")}
|
||||
>
|
||||
<IconArrowBack />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div id="settings-input-buttons">
|
||||
<button
|
||||
class="settings-input-button"
|
||||
class="button settings-input-button"
|
||||
aria-label={$t("button.save")}
|
||||
disabled={inputValue == $settings[settingContext][settingId] || !validInput}
|
||||
disabled={inputValue === $settings[settingContext][settingId] || !validInput}
|
||||
on:click={save}
|
||||
>
|
||||
<IconCheck />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="settings-input-button"
|
||||
aria-label={$t("button.reset")}
|
||||
disabled={String($settings[settingContext][settingId]).length <= 0}
|
||||
on:click={() => writeToSettings("", "text")}
|
||||
>
|
||||
<IconX />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -111,7 +186,8 @@
|
||||
}
|
||||
|
||||
#input-container {
|
||||
padding: 0 18px;
|
||||
padding: 0 16px;
|
||||
padding-right: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--secondary);
|
||||
background-color: var(--button);
|
||||
@ -124,26 +200,20 @@
|
||||
}
|
||||
|
||||
#input-container,
|
||||
#input-box {
|
||||
font-size: 13.5px;
|
||||
.input-box {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#input-box {
|
||||
.input-box {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
color: var(--secondary);
|
||||
border: none;
|
||||
padding-block: 0;
|
||||
padding-inline: 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
#input-box::placeholder {
|
||||
color: var(--gray);
|
||||
/* fix for firefox */
|
||||
opacity: 1;
|
||||
padding: 11.5px 0;
|
||||
}
|
||||
|
||||
.input-placeholder {
|
||||
@ -153,7 +223,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#input-box:focus-visible {
|
||||
.input-box:focus-visible {
|
||||
box-shadow: unset !important;
|
||||
}
|
||||
|
||||
@ -168,19 +238,38 @@
|
||||
}
|
||||
|
||||
.settings-input-button {
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-input-button :global(svg) {
|
||||
height: 21px;
|
||||
width: 21px;
|
||||
stroke-width: 1.5px;
|
||||
stroke-width: 1.8px;
|
||||
}
|
||||
|
||||
.settings-input-button[disabled] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input-inner-button {
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
/* 4px is padding outside of the button */
|
||||
border-radius: calc(var(--border-radius) - 4px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input-inner-button :global(svg) {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
stroke-width: 1.8px;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
|
@ -11,10 +11,14 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: calc(var(--padding) * 2);
|
||||
padding: calc(var(--sidebar-tab-padding) * 2);
|
||||
|
||||
/* accommodate space for scaling animation */
|
||||
padding-bottom: calc(var(--padding) * 2 - var(--sidebar-inner-padding));
|
||||
padding-bottom: calc(var(--sidebar-tab-padding) * 2 - var(--sidebar-inner-padding));
|
||||
}
|
||||
|
||||
#cobalt-logo :global(path) {
|
||||
fill: var(--sidebar-highlight);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 535px) {
|
||||
|
@ -60,7 +60,7 @@
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
padding: var(--sidebar-inner-padding);
|
||||
padding-bottom: var(--border-radius);
|
||||
padding-bottom: var(--sidebar-tab-padding);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@ -79,6 +79,7 @@
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
z-index: 3;
|
||||
padding: var(--sidebar-inner-padding) 0;
|
||||
}
|
||||
|
||||
#sidebar::before {
|
||||
@ -95,27 +96,26 @@
|
||||
#sidebar-tabs {
|
||||
overflow-y: visible;
|
||||
overflow-x: scroll;
|
||||
padding-bottom: 0;
|
||||
padding: var(--sidebar-inner-padding) 0;
|
||||
padding: 0;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
#sidebar :global(.sidebar-inner-container:first-child) {
|
||||
padding-left: calc(var(--border-radius) * 2);
|
||||
padding-left: calc(var(--border-radius) * 1.5);
|
||||
}
|
||||
|
||||
#sidebar :global(.sidebar-inner-container:last-child) {
|
||||
padding-right: calc(var(--border-radius) * 2);
|
||||
padding-right: calc(var(--border-radius) * 1.5);
|
||||
}
|
||||
|
||||
#sidebar :global(.sidebar-inner-container:first-child:dir(rtl)) {
|
||||
padding-left: 0;
|
||||
padding-right: calc(var(--border-radius) * 2);
|
||||
padding-right: calc(var(--border-radius) * 1.5);
|
||||
}
|
||||
|
||||
#sidebar :global(.sidebar-inner-container:last-child:dir(rtl)) {
|
||||
padding-right: 0;
|
||||
padding-left: calc(var(--border-radius) * 2);
|
||||
padding-left: calc(var(--border-radius) * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -48,7 +48,7 @@
|
||||
{/if}
|
||||
|
||||
<svelte:component this={icon} />
|
||||
{$t(`tabs.${name}`)}
|
||||
<span class="tab-title">{$t(`tabs.${name}`)}</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
@ -58,7 +58,7 @@
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 3px;
|
||||
padding: var(--padding) 3px;
|
||||
padding: var(--sidebar-tab-padding) 3px;
|
||||
color: var(--sidebar-highlight);
|
||||
font-size: var(--sidebar-font-size);
|
||||
opacity: 0.75;
|
||||
@ -108,6 +108,14 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-tab:active:not(.active) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes pressButton {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
@ -121,14 +129,23 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sidebar-tab:active:not(.active) {
|
||||
opacity: 1;
|
||||
background-color: var(--sidebar-hover);
|
||||
.sidebar-tab:hover:not(.active) {
|
||||
background-color: var(--button-hover-transparent);
|
||||
}
|
||||
|
||||
.sidebar-tab:active:not(.active),
|
||||
.sidebar-tab:focus:hover:not(.active) {
|
||||
background-color: var(--button-press-transparent);
|
||||
}
|
||||
|
||||
.sidebar-tab:hover:not(.active) {
|
||||
opacity: 1;
|
||||
background-color: var(--sidebar-hover);
|
||||
}
|
||||
|
||||
.sidebar-tab:active:not(.active),
|
||||
.sidebar-tab:focus:hover:not(.active) {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 1px var(--sidebar-stroke) inset;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
export let path: string;
|
||||
export let title: string;
|
||||
export let icon: ConstructorOfATypedSvelteComponent;
|
||||
export let iconColor: "gray" | "blue" | "green" = "gray";
|
||||
export let iconColor: "gray" | "blue" | "green" | "magenta" | "purple" | "orange" = "gray";
|
||||
|
||||
$: isActive = $page.url.pathname === path;
|
||||
</script>
|
||||
@ -17,8 +17,8 @@
|
||||
class:active={isActive}
|
||||
role="button"
|
||||
>
|
||||
<div class="subnav-tab-left">
|
||||
<div class="tab-icon" style="background: var(--{iconColor})">
|
||||
<div class="subnav-tab-left" style="--icon-color: var(--{iconColor})">
|
||||
<div class="tab-icon">
|
||||
<svelte:component this={icon} />
|
||||
</div>
|
||||
<div class="subnav-tab-text">
|
||||
@ -41,7 +41,6 @@
|
||||
gap: calc(var(--small-padding) * 2);
|
||||
padding: var(--big-padding);
|
||||
font-weight: 500;
|
||||
background: var(--primary);
|
||||
color: var(--button-text);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
@ -66,6 +65,7 @@
|
||||
align-items: center;
|
||||
padding: var(--small-padding);
|
||||
border-radius: 5px;
|
||||
background: var(--icon-color);
|
||||
}
|
||||
|
||||
.subnav-tab .tab-icon :global(svg) {
|
||||
@ -75,6 +75,19 @@
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.subnav-tab:not(.active) .tab-icon {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
box-shadow: var(--button-box-shadow);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .subnav-tab:not(.active) .tab-icon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.subnav-tab:not(.active) .tab-icon :global(svg) {
|
||||
stroke: var(--icon-color);
|
||||
}
|
||||
|
||||
.subnav-tab-chevron :global(svg) {
|
||||
display: none;
|
||||
stroke-width: 2px;
|
||||
@ -93,8 +106,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.subnav-tab:active {
|
||||
background: var(--button-hover-transparent);
|
||||
.subnav-tab:active,
|
||||
.subnav-tab:focus:hover:not(.active) {
|
||||
background: var(--button-press-transparent);
|
||||
box-shadow: var(--button-box-shadow);
|
||||
}
|
||||
|
||||
.subnav-tab.active {
|
||||
@ -118,7 +133,7 @@
|
||||
.subnav-tab:not(:last-child) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
box-shadow: 48px 3px 0px -1.8px var(--button-stroke);
|
||||
box-shadow: 48px 3px 0px -2px var(--button-stroke);
|
||||
}
|
||||
|
||||
.subnav-tab:not(:first-child) {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { get } from "svelte/store";
|
||||
|
||||
import settings from "$lib/state/settings";
|
||||
import lazySettingGetter from "$lib/settings/lazy-get";
|
||||
|
||||
import { getSession } from "$lib/api/session";
|
||||
import { currentApiURL } from "$lib/api/api-url";
|
||||
@ -10,7 +9,7 @@ import cachedInfo from "$lib/state/server-info";
|
||||
import { getServerInfo } from "$lib/api/server-info";
|
||||
|
||||
import type { Optional } from "$lib/types/generic";
|
||||
import type { CobaltAPIResponse, CobaltErrorResponse } from "$lib/types/api";
|
||||
import type { CobaltAPIResponse, CobaltErrorResponse, CobaltSaveRequestBody } from "$lib/types/api";
|
||||
|
||||
const getAuthorization = async () => {
|
||||
const processing = get(settings).processing;
|
||||
@ -43,31 +42,7 @@ const getAuthorization = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const request = async (url: string) => {
|
||||
const getSetting = lazySettingGetter(get(settings));
|
||||
|
||||
const request = {
|
||||
url,
|
||||
|
||||
downloadMode: getSetting("save", "downloadMode"),
|
||||
audioBitrate: getSetting("save", "audioBitrate"),
|
||||
audioFormat: getSetting("save", "audioFormat"),
|
||||
tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
|
||||
youtubeDubLang: getSetting("save", "youtubeDubLang"),
|
||||
|
||||
youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
|
||||
videoQuality: getSetting("save", "videoQuality"),
|
||||
youtubeHLS: getSetting("save", "youtubeHLS"),
|
||||
|
||||
filenameStyle: getSetting("save", "filenameStyle"),
|
||||
disableMetadata: getSetting("save", "disableMetadata"),
|
||||
|
||||
twitterGif: getSetting("save", "twitterGif"),
|
||||
tiktokH265: getSetting("save", "tiktokH265"),
|
||||
|
||||
alwaysProxy: getSetting("privacy", "alwaysProxy"),
|
||||
}
|
||||
|
||||
const request = async (request: CobaltSaveRequestBody) => {
|
||||
await getServerInfo();
|
||||
|
||||
const getCachedInfo = get(cachedInfo);
|
||||
|
160
web/src/lib/api/saving-handler.ts
Normal file
160
web/src/lib/api/saving-handler.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import API from "$lib/api/api";
|
||||
import settings from "$lib/state/settings";
|
||||
import lazySettingGetter from "$lib/settings/lazy-get";
|
||||
|
||||
import { get } from "svelte/store";
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { downloadFile } from "$lib/download";
|
||||
import { createDialog } from "$lib/state/dialogs";
|
||||
import { downloadButtonState } from "$lib/state/omnibox";
|
||||
import { createSavePipeline } from "$lib/queen-bee/queue";
|
||||
|
||||
import type { DialogInfo } from "$lib/types/dialog";
|
||||
import type { CobaltSaveRequestBody } from "$lib/types/api";
|
||||
|
||||
const defaultErrorPopup: DialogInfo = {
|
||||
id: "save-error",
|
||||
type: "small",
|
||||
meowbalt: "error",
|
||||
};
|
||||
|
||||
export const savingHandler = async ({ url, request }: { url?: string, request?: CobaltSaveRequestBody }) => {
|
||||
downloadButtonState.set("think");
|
||||
|
||||
const errorButtons = [
|
||||
{
|
||||
text: get(t)("button.gotit"),
|
||||
main: true,
|
||||
action: () => { },
|
||||
},
|
||||
];
|
||||
|
||||
const getSetting = lazySettingGetter(get(settings));
|
||||
|
||||
if (!request && !url) return;
|
||||
|
||||
const selectedRequest = request || {
|
||||
// pointing typescript to the fact that
|
||||
// url is either present or not used at all,
|
||||
// aka in cases when request is present
|
||||
url: url!,
|
||||
|
||||
alwaysProxy: getSetting("save", "alwaysProxy"),
|
||||
localProcessing: getSetting("save", "localProcessing"),
|
||||
downloadMode: getSetting("save", "downloadMode"),
|
||||
|
||||
filenameStyle: getSetting("save", "filenameStyle"),
|
||||
disableMetadata: getSetting("save", "disableMetadata"),
|
||||
|
||||
audioBitrate: getSetting("save", "audioBitrate"),
|
||||
audioFormat: getSetting("save", "audioFormat"),
|
||||
tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
|
||||
youtubeDubLang: getSetting("save", "youtubeDubLang"),
|
||||
|
||||
youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
|
||||
videoQuality: getSetting("save", "videoQuality"),
|
||||
youtubeHLS: getSetting("save", "youtubeHLS"),
|
||||
|
||||
convertGif: getSetting("save", "convertGif"),
|
||||
allowH265: getSetting("save", "allowH265"),
|
||||
}
|
||||
|
||||
const response = await API.request(selectedRequest);
|
||||
|
||||
if (!response) {
|
||||
downloadButtonState.set("error");
|
||||
|
||||
return createDialog({
|
||||
...defaultErrorPopup,
|
||||
buttons: errorButtons,
|
||||
bodyText: get(t)("error.api.unreachable"),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === "error") {
|
||||
downloadButtonState.set("error");
|
||||
|
||||
return createDialog({
|
||||
...defaultErrorPopup,
|
||||
buttons: errorButtons,
|
||||
bodyText: get(t)(response.error.code, response?.error?.context),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === "redirect") {
|
||||
downloadButtonState.set("done");
|
||||
|
||||
return downloadFile({
|
||||
url: response.url,
|
||||
urlType: "redirect",
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === "tunnel") {
|
||||
downloadButtonState.set("check");
|
||||
|
||||
const probeResult = await API.probeCobaltTunnel(response.url);
|
||||
|
||||
if (probeResult === 200) {
|
||||
downloadButtonState.set("done");
|
||||
|
||||
return downloadFile({
|
||||
url: response.url,
|
||||
});
|
||||
} else {
|
||||
downloadButtonState.set("error");
|
||||
|
||||
return createDialog({
|
||||
...defaultErrorPopup,
|
||||
buttons: errorButtons,
|
||||
bodyText: get(t)("error.tunnel.probe"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === "local-processing") {
|
||||
// TODO: remove debug logging
|
||||
console.log(response);
|
||||
|
||||
downloadButtonState.set("done");
|
||||
return createSavePipeline(response, selectedRequest);
|
||||
}
|
||||
|
||||
if (response.status === "picker") {
|
||||
downloadButtonState.set("done");
|
||||
const buttons = [
|
||||
{
|
||||
text: get(t)("button.done"),
|
||||
main: true,
|
||||
action: () => { },
|
||||
},
|
||||
];
|
||||
|
||||
if (response.audio) {
|
||||
const pickerAudio = response.audio;
|
||||
buttons.unshift({
|
||||
text: get(t)("button.download.audio"),
|
||||
main: false,
|
||||
action: () => {
|
||||
downloadFile({
|
||||
url: pickerAudio,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createDialog({
|
||||
id: "download-picker",
|
||||
type: "picker",
|
||||
items: response.picker,
|
||||
buttons,
|
||||
});
|
||||
}
|
||||
|
||||
downloadButtonState.set("error");
|
||||
return createDialog({
|
||||
...defaultErrorPopup,
|
||||
buttons: errorButtons,
|
||||
bodyText: get(t)("error.api.unknown_response"),
|
||||
});
|
||||
}
|
@ -14,6 +14,9 @@ const device = {
|
||||
android: false,
|
||||
mobile: false,
|
||||
},
|
||||
browser: {
|
||||
chrome: false,
|
||||
},
|
||||
prefers: {
|
||||
language: "en",
|
||||
reducedMotion: false,
|
||||
@ -22,6 +25,7 @@ const device = {
|
||||
supports: {
|
||||
share: false,
|
||||
directDownload: false,
|
||||
haptics: false,
|
||||
},
|
||||
userAgent: "sveltekit server",
|
||||
}
|
||||
@ -32,6 +36,9 @@ if (browser) {
|
||||
const iPhone = ua.includes("iphone os");
|
||||
const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0;
|
||||
|
||||
const iosVersion = Number(ua.match(/iphone os (\d+)_/)?.[1]);
|
||||
const modernIOS = iPhone && iosVersion >= 18;
|
||||
|
||||
const iOS = iPhone || iPad;
|
||||
const android = ua.includes("android") || ua.includes("diordna");
|
||||
|
||||
@ -42,11 +49,16 @@ if (browser) {
|
||||
};
|
||||
|
||||
device.is = {
|
||||
mobile: iOS || android,
|
||||
android,
|
||||
|
||||
iPhone,
|
||||
iPad,
|
||||
iOS,
|
||||
android,
|
||||
mobile: iOS || android,
|
||||
};
|
||||
|
||||
device.browser = {
|
||||
chrome: ua.includes("chrome/"),
|
||||
};
|
||||
|
||||
device.prefers = {
|
||||
@ -58,6 +70,10 @@ if (browser) {
|
||||
device.supports = {
|
||||
share: navigator.share !== undefined,
|
||||
directDownload: !(installed && iOS),
|
||||
|
||||
// not sure if vibrations feel the same on android,
|
||||
// so they're enabled only on ios 18+ for now
|
||||
haptics: modernIOS,
|
||||
};
|
||||
|
||||
device.userAgent = navigator.userAgent;
|
||||
|
@ -14,6 +14,8 @@ const variables = {
|
||||
PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'),
|
||||
PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'),
|
||||
DEFAULT_API: getEnv('DEFAULT_API'),
|
||||
// temporary variable until webcodecs features are ready for testing
|
||||
ENABLE_WEBCODECS: !!getEnv('ENABLE_WEBCODECS'),
|
||||
}
|
||||
|
||||
const contacts = {
|
||||
|
43
web/src/lib/haptics.ts
Normal file
43
web/src/lib/haptics.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { get } from "svelte/store";
|
||||
import { device } from "$lib/device";
|
||||
import settings from "$lib/state/settings";
|
||||
|
||||
const canUseHaptics = () => {
|
||||
return device.supports.haptics && !get(settings).accessibility.disableHaptics;
|
||||
}
|
||||
|
||||
export const hapticSwitch = () => {
|
||||
if (!canUseHaptics()) return;
|
||||
|
||||
try {
|
||||
const label = document.createElement("label");
|
||||
label.ariaHidden = "true";
|
||||
label.style.display = "none";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.setAttribute("switch", "");
|
||||
label.appendChild(input);
|
||||
|
||||
document.head.appendChild(label);
|
||||
label.click();
|
||||
document.head.removeChild(label);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export const hapticConfirm = () => {
|
||||
if (!canUseHaptics()) return;
|
||||
|
||||
hapticSwitch();
|
||||
setTimeout(() => hapticSwitch(), 120);
|
||||
}
|
||||
|
||||
export const hapticError = () => {
|
||||
if (!canUseHaptics()) return;
|
||||
|
||||
hapticSwitch();
|
||||
setTimeout(() => hapticSwitch(), 120);
|
||||
setTimeout(() => hapticSwitch(), 240);
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import mime from "mime";
|
||||
import { OPFSStorage } from "$lib/storage";
|
||||
import LibAV, { type LibAV as LibAVInstance } from "@imput/libav.js-remux-cli";
|
||||
import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, FileInfo, RenderParams } from "./types/libav";
|
||||
|
||||
import type { FfprobeData } from "fluent-ffmpeg";
|
||||
import { browser } from "$app/environment";
|
||||
import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, RenderParams } from "$lib/types/libav";
|
||||
|
||||
export default class LibAVWrapper {
|
||||
libav: Promise<LibAVInstance> | null;
|
||||
@ -11,14 +11,18 @@ export default class LibAVWrapper {
|
||||
|
||||
constructor(onProgress?: FFmpegProgressCallback) {
|
||||
this.libav = null;
|
||||
this.concurrency = Math.min(4, browser ? navigator.hardwareConcurrency : 0);
|
||||
this.concurrency = Math.min(4, navigator.hardwareConcurrency || 0);
|
||||
this.onProgress = onProgress;
|
||||
}
|
||||
|
||||
init() {
|
||||
init(options?: LibAV.LibAVOpts) {
|
||||
if (!options) options = {
|
||||
yesthreads: true,
|
||||
}
|
||||
|
||||
if (this.concurrency && !this.libav) {
|
||||
this.libav = LibAV.LibAV({
|
||||
yesthreads: true,
|
||||
...options,
|
||||
base: '/_libav'
|
||||
});
|
||||
}
|
||||
@ -35,6 +39,8 @@ export default class LibAVWrapper {
|
||||
if (!this.libav) throw new Error("LibAV wasn't initialized");
|
||||
const libav = await this.libav;
|
||||
|
||||
console.log('yay loaded libav :3');
|
||||
|
||||
await libav.mkreadaheadfile('input', blob);
|
||||
|
||||
try {
|
||||
@ -57,60 +63,31 @@ export default class LibAVWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
static getExtensionFromType(blob: Blob) {
|
||||
const extensions = mime.getAllExtensions(blob.type);
|
||||
const overrides = ['mp3', 'mov'];
|
||||
|
||||
if (!extensions)
|
||||
return;
|
||||
|
||||
for (const override of overrides)
|
||||
if (extensions?.has(override))
|
||||
return override;
|
||||
|
||||
return [...extensions][0];
|
||||
}
|
||||
|
||||
async render({ blob, output, args }: RenderParams) {
|
||||
async render({ files, output, args }: RenderParams) {
|
||||
if (!this.libav) throw new Error("LibAV wasn't initialized");
|
||||
const libav = await this.libav;
|
||||
const inputKind = blob.type.split("/")[0];
|
||||
const inputExtension = LibAVWrapper.getExtensionFromType(blob);
|
||||
|
||||
if (inputKind !== "video" && inputKind !== "audio") return;
|
||||
if (!inputExtension) return;
|
||||
|
||||
const input: FileInfo = {
|
||||
kind: inputKind,
|
||||
extension: inputExtension,
|
||||
if (!(output.format && output.type)) {
|
||||
throw new Error("output's format or type is missing");
|
||||
}
|
||||
|
||||
if (!output) output = input;
|
||||
|
||||
output.type = mime.getType(output.extension);
|
||||
if (!output.type) return;
|
||||
|
||||
const outputName = `output.${output.extension}`;
|
||||
const outputName = `output.${output.format}`;
|
||||
const ffInputs = [];
|
||||
|
||||
try {
|
||||
await libav.mkreadaheadfile("input", blob);
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i].file;
|
||||
|
||||
await libav.mkreadaheadfile(`input${i}`, file);
|
||||
ffInputs.push('-i', `input${i}`);
|
||||
}
|
||||
|
||||
// https://github.com/Yahweasel/libav.js/blob/7d359f69/docs/IO.md#block-writer-devices
|
||||
await libav.mkwriterdev(outputName);
|
||||
await libav.mkwriterdev('progress.txt');
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const chunks: Uint8Array[] = [];
|
||||
const chunkSize = Math.min(512 * MB, blob.size);
|
||||
const storage = await OPFSStorage.init();
|
||||
|
||||
// since we expect the output file to be roughly the same size
|
||||
// as the original, preallocate its size for the output
|
||||
for (let toAllocate = blob.size; toAllocate > 0; toAllocate -= chunkSize) {
|
||||
chunks.push(new Uint8Array(chunkSize));
|
||||
}
|
||||
|
||||
let actualSize = 0;
|
||||
libav.onwrite = (name, pos, data) => {
|
||||
libav.onwrite = async (name, pos, data) => {
|
||||
if (name === 'progress.txt') {
|
||||
try {
|
||||
return this.#emitProgress(data);
|
||||
@ -119,26 +96,7 @@ export default class LibAVWrapper {
|
||||
}
|
||||
} else if (name !== outputName) return;
|
||||
|
||||
const writeEnd = pos + data.length;
|
||||
if (writeEnd > chunkSize * chunks.length) {
|
||||
chunks.push(new Uint8Array(chunkSize));
|
||||
}
|
||||
|
||||
const chunkIndex = pos / chunkSize | 0;
|
||||
const offset = pos - (chunkSize * chunkIndex);
|
||||
|
||||
if (offset + data.length > chunkSize) {
|
||||
chunks[chunkIndex].set(
|
||||
data.subarray(0, chunkSize - offset), offset
|
||||
);
|
||||
chunks[chunkIndex + 1].set(
|
||||
data.subarray(chunkSize - offset), 0
|
||||
);
|
||||
} else {
|
||||
chunks[chunkIndex].set(data, offset);
|
||||
}
|
||||
|
||||
actualSize = Math.max(writeEnd, actualSize);
|
||||
await storage.write(data, pos);
|
||||
};
|
||||
|
||||
await libav.ffmpeg([
|
||||
@ -146,40 +104,28 @@ export default class LibAVWrapper {
|
||||
'-loglevel', 'error',
|
||||
'-progress', 'progress.txt',
|
||||
'-threads', this.concurrency.toString(),
|
||||
'-i', 'input',
|
||||
...ffInputs,
|
||||
...args,
|
||||
outputName
|
||||
]);
|
||||
|
||||
// if we didn't need as much space as we allocated for some reason,
|
||||
// shrink the buffers so that we don't inflate the file with zeroes
|
||||
const outputView: Uint8Array[] = [];
|
||||
const file = await storage.res();
|
||||
|
||||
for (let i = 0; i < chunks.length; ++i) {
|
||||
outputView.push(
|
||||
chunks[i].subarray(
|
||||
0, Math.min(chunkSize, actualSize)
|
||||
)
|
||||
);
|
||||
if (file.size === 0) return;
|
||||
|
||||
actualSize -= chunkSize;
|
||||
if (actualSize <= 0) {
|
||||
break;
|
||||
}
|
||||
return {
|
||||
file,
|
||||
type: output.type,
|
||||
}
|
||||
|
||||
const renderBlob = new Blob(
|
||||
outputView,
|
||||
{ type: output.type }
|
||||
);
|
||||
|
||||
if (renderBlob.size === 0) return;
|
||||
return renderBlob;
|
||||
} finally {
|
||||
try {
|
||||
await libav.unlink(outputName);
|
||||
await libav.unlink('progress.txt');
|
||||
await libav.unlinkreadaheadfile("input");
|
||||
|
||||
await Promise.allSettled(
|
||||
files.map((_, i) =>
|
||||
libav.unlinkreadaheadfile(`input${i}`)
|
||||
));
|
||||
} catch { /* catch & ignore */ }
|
||||
}
|
||||
}
|
||||
@ -192,7 +138,7 @@ export default class LibAVWrapper {
|
||||
const entries = Object.fromEntries(
|
||||
text.split('\n')
|
||||
.filter(a => a)
|
||||
.map(a => a.split('=', ))
|
||||
.map(a => a.split('='))
|
||||
);
|
||||
|
||||
const status: FFmpegProgressStatus = (() => {
|
||||
|
104
web/src/lib/queen-bee/queue.ts
Normal file
104
web/src/lib/queen-bee/queue.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { addItem } from "$lib/state/queen-bee/queue";
|
||||
import { openQueuePopover } from "$lib/state/queue-visibility";
|
||||
|
||||
import type { CobaltPipelineItem } from "$lib/types/workers";
|
||||
import type { CobaltLocalProcessingResponse, CobaltSaveRequestBody } from "$lib/types/api";
|
||||
|
||||
export const getMediaType = (type: string) => {
|
||||
const kind = type.split('/')[0];
|
||||
|
||||
// can't use .includes() here for some reason
|
||||
if (kind === "video" || kind === "audio" || kind === "image") {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
export const createRemuxPipeline = (file: File) => {
|
||||
// chopped khia
|
||||
const parentId = crypto.randomUUID();
|
||||
const mediaType = getMediaType(file.type);
|
||||
|
||||
const pipeline: CobaltPipelineItem[] = [{
|
||||
worker: "remux",
|
||||
workerId: crypto.randomUUID(),
|
||||
parentId,
|
||||
workerArgs: {
|
||||
files: [{
|
||||
file,
|
||||
type: file.type,
|
||||
}],
|
||||
ffargs: [
|
||||
"-c", "copy",
|
||||
"-map", "0"
|
||||
],
|
||||
output: {
|
||||
type: file.type,
|
||||
format: file.name.split(".").pop(),
|
||||
},
|
||||
},
|
||||
}];
|
||||
|
||||
if (mediaType) {
|
||||
addItem({
|
||||
id: parentId,
|
||||
state: "waiting",
|
||||
pipeline,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
mediaType,
|
||||
});
|
||||
|
||||
openQueuePopover();
|
||||
}
|
||||
}
|
||||
|
||||
export const createSavePipeline = (info: CobaltLocalProcessingResponse, request: CobaltSaveRequestBody) => {
|
||||
// TODO: proper error here
|
||||
if (!(info.output?.filename && info.output?.type)) return;
|
||||
|
||||
const parentId = crypto.randomUUID();
|
||||
const pipeline: CobaltPipelineItem[] = [];
|
||||
|
||||
// reverse is needed for audio (second item) to be downloaded first
|
||||
const tunnels = info.tunnel.reverse();
|
||||
|
||||
for (const tunnel of tunnels) {
|
||||
pipeline.push({
|
||||
worker: "fetch",
|
||||
workerId: crypto.randomUUID(),
|
||||
parentId,
|
||||
workerArgs: {
|
||||
url: tunnel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pipeline.push({
|
||||
worker: "remux",
|
||||
workerId: crypto.randomUUID(),
|
||||
parentId,
|
||||
workerArgs: {
|
||||
ffargs: [
|
||||
"-c:v", "copy",
|
||||
"-c:a", "copy"
|
||||
],
|
||||
output: {
|
||||
type: info.output.type,
|
||||
format: info.output.filename.split(".").pop(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
addItem({
|
||||
id: parentId,
|
||||
state: "waiting",
|
||||
pipeline,
|
||||
canRetry: true,
|
||||
originalRequest: request,
|
||||
filename: info.output.filename,
|
||||
mimeType: info.output.type,
|
||||
mediaType: "video",
|
||||
});
|
||||
|
||||
openQueuePopover();
|
||||
}
|
50
web/src/lib/queen-bee/run-worker.ts
Normal file
50
web/src/lib/queen-bee/run-worker.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { get } from "svelte/store";
|
||||
import { queue } from "$lib/state/queen-bee/queue";
|
||||
|
||||
import { runRemuxWorker } from "$lib/queen-bee/runners/remux";
|
||||
import { runFetchWorker } from "$lib/queen-bee/runners/fetch";
|
||||
|
||||
import type { CobaltPipelineItem } from "$lib/types/workers";
|
||||
import type { CobaltFileReference } from "$lib/types/storage";
|
||||
|
||||
export const killWorker = (worker: Worker, unsubscribe: () => void, interval?: NodeJS.Timeout) => {
|
||||
unsubscribe();
|
||||
worker.terminate();
|
||||
if (interval) clearInterval(interval);
|
||||
}
|
||||
|
||||
export const startWorker = async ({ worker, workerId, parentId, workerArgs }: CobaltPipelineItem) => {
|
||||
let files: CobaltFileReference[] = [];
|
||||
|
||||
switch (worker) {
|
||||
case "remux":
|
||||
if (workerArgs?.files) {
|
||||
files = workerArgs.files;
|
||||
}
|
||||
|
||||
if (files?.length === 0) {
|
||||
const parent = get(queue)[parentId];
|
||||
if (parent.state === "running" && parent.pipelineResults) {
|
||||
files = parent.pipelineResults;
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0 && workerArgs.ffargs && workerArgs.output) {
|
||||
await runRemuxWorker(
|
||||
workerId,
|
||||
parentId,
|
||||
files,
|
||||
workerArgs.ffargs,
|
||||
workerArgs.output,
|
||||
true, // resetStartCounter
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "fetch":
|
||||
if (workerArgs?.url) {
|
||||
await runFetchWorker(workerId, parentId, workerArgs.url)
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
51
web/src/lib/queen-bee/runners/fetch.ts
Normal file
51
web/src/lib/queen-bee/runners/fetch.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import FetchWorker from "$lib/workers/fetch?worker";
|
||||
|
||||
import { killWorker } from "$lib/queen-bee/run-worker";
|
||||
import { updateWorkerProgress } from "$lib/state/queen-bee/current-tasks";
|
||||
import { pipelineTaskDone, itemError, queue } from "$lib/state/queen-bee/queue";
|
||||
|
||||
import type { CobaltQueue } from "$lib/types/queue";
|
||||
|
||||
export const runFetchWorker = async (workerId: string, parentId: string, url: string) => {
|
||||
const worker = new FetchWorker();
|
||||
|
||||
const unsubscribe = queue.subscribe((queue: CobaltQueue) => {
|
||||
if (!queue[parentId]) {
|
||||
// TODO: remove logging
|
||||
console.log("worker's parent is gone, so it killed itself");
|
||||
killWorker(worker, unsubscribe);
|
||||
}
|
||||
});
|
||||
|
||||
worker.postMessage({
|
||||
cobaltFetchWorker: {
|
||||
url
|
||||
}
|
||||
});
|
||||
|
||||
worker.onmessage = (event) => {
|
||||
const eventData = event.data.cobaltFetchWorker;
|
||||
if (!eventData) return;
|
||||
|
||||
if (eventData.progress) {
|
||||
updateWorkerProgress(workerId, {
|
||||
percentage: eventData.progress,
|
||||
size: eventData.size,
|
||||
})
|
||||
}
|
||||
|
||||
if (eventData.result) {
|
||||
killWorker(worker, unsubscribe);
|
||||
return pipelineTaskDone(
|
||||
parentId,
|
||||
workerId,
|
||||
eventData.result,
|
||||
);
|
||||
}
|
||||
|
||||
if (eventData.error) {
|
||||
killWorker(worker, unsubscribe);
|
||||
return itemError(parentId, workerId, eventData.error);
|
||||
}
|
||||
}
|
||||
}
|
109
web/src/lib/queen-bee/runners/remux.ts
Normal file
109
web/src/lib/queen-bee/runners/remux.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import RemuxWorker from "$lib/workers/remux?worker";
|
||||
|
||||
import { killWorker } from "$lib/queen-bee/run-worker";
|
||||
import { updateWorkerProgress } from "$lib/state/queen-bee/current-tasks";
|
||||
import { pipelineTaskDone, itemError, queue } from "$lib/state/queen-bee/queue";
|
||||
|
||||
import type { FileInfo } from "$lib/types/libav";
|
||||
import type { CobaltQueue } from "$lib/types/queue";
|
||||
import type { CobaltFileReference } from "$lib/types/storage";
|
||||
|
||||
let startAttempts = 0;
|
||||
|
||||
export const runRemuxWorker = async (
|
||||
workerId: string,
|
||||
parentId: string,
|
||||
files: CobaltFileReference[],
|
||||
args: string[],
|
||||
output: FileInfo,
|
||||
resetStartCounter?: boolean
|
||||
) => {
|
||||
const worker = new RemuxWorker();
|
||||
|
||||
// sometimes chrome refuses to start libav wasm,
|
||||
// so we check if it started, try 10 more times if not, and kill self if it still doesn't work
|
||||
// TODO: fix the underlying issue because this is ridiculous
|
||||
|
||||
if (resetStartCounter) startAttempts = 0;
|
||||
|
||||
let bumpAttempts = 0;
|
||||
const startCheck = setInterval(async () => {
|
||||
bumpAttempts++;
|
||||
|
||||
if (bumpAttempts === 10) {
|
||||
startAttempts++;
|
||||
if (startAttempts <= 10) {
|
||||
killWorker(worker, unsubscribe, startCheck);
|
||||
console.error("worker didn't start after 5 seconds, so it was killed and started again");
|
||||
return await runRemuxWorker(workerId, parentId, files, args, output);
|
||||
} else {
|
||||
killWorker(worker, unsubscribe, startCheck);
|
||||
console.error("worker didn't start after 10 attempts, so we're giving up");
|
||||
|
||||
// TODO: proper error code
|
||||
return itemError(parentId, workerId, "worker didn't start");
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const unsubscribe = queue.subscribe((queue: CobaltQueue) => {
|
||||
if (!queue[parentId]) {
|
||||
// TODO: remove logging
|
||||
console.log("worker's parent is gone, so it killed itself");
|
||||
killWorker(worker, unsubscribe, startCheck);
|
||||
}
|
||||
});
|
||||
|
||||
worker.postMessage({
|
||||
cobaltRemuxWorker: {
|
||||
files,
|
||||
args,
|
||||
output,
|
||||
}
|
||||
});
|
||||
|
||||
worker.onerror = (e) => {
|
||||
console.error("remux worker exploded:", e);
|
||||
killWorker(worker, unsubscribe, startCheck);
|
||||
|
||||
// TODO: proper error code
|
||||
return itemError(parentId, workerId, "internal error");
|
||||
};
|
||||
|
||||
let totalDuration: number | null = null;
|
||||
|
||||
worker.onmessage = (event) => {
|
||||
const eventData = event.data.cobaltRemuxWorker;
|
||||
if (!eventData) return;
|
||||
|
||||
clearInterval(startCheck);
|
||||
|
||||
// temporary debug logging
|
||||
console.log(JSON.stringify(eventData, null, 2));
|
||||
|
||||
if (eventData.progress) {
|
||||
if (eventData.progress.duration) {
|
||||
totalDuration = eventData.progress.duration;
|
||||
}
|
||||
|
||||
updateWorkerProgress(workerId, {
|
||||
percentage: totalDuration ? (eventData.progress.durationProcessed / totalDuration) * 100 : 0,
|
||||
size: eventData.progress.size,
|
||||
})
|
||||
}
|
||||
|
||||
if (eventData.render) {
|
||||
killWorker(worker, unsubscribe, startCheck);
|
||||
return pipelineTaskDone(
|
||||
parentId,
|
||||
workerId,
|
||||
eventData.render,
|
||||
);
|
||||
}
|
||||
|
||||
if (eventData.error) {
|
||||
killWorker(worker, unsubscribe, startCheck);
|
||||
return itemError(parentId, workerId, eventData.error);
|
||||
}
|
||||
};
|
||||
}
|
68
web/src/lib/queen-bee/scheduler.ts
Normal file
68
web/src/lib/queen-bee/scheduler.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { get } from "svelte/store";
|
||||
import { startWorker } from "$lib/queen-bee/run-worker";
|
||||
import { itemDone, itemError, itemRunning, queue } from "$lib/state/queen-bee/queue";
|
||||
import { addWorkerToQueue, currentTasks } from "$lib/state/queen-bee/current-tasks";
|
||||
import type { CobaltPipelineItem } from "$lib/types/workers";
|
||||
|
||||
const startPipeline = (pipelineItem: CobaltPipelineItem) => {
|
||||
addWorkerToQueue(pipelineItem.workerId, {
|
||||
type: pipelineItem.worker,
|
||||
parentId: pipelineItem.parentId,
|
||||
});
|
||||
|
||||
itemRunning(
|
||||
pipelineItem.parentId,
|
||||
pipelineItem.workerId,
|
||||
);
|
||||
|
||||
startWorker(pipelineItem);
|
||||
}
|
||||
|
||||
export const checkTasks = () => {
|
||||
const queueItems = get(queue);
|
||||
const ongoingTasks = get(currentTasks);
|
||||
|
||||
// TODO (?): task concurrency
|
||||
if (Object.keys(ongoingTasks).length > 0) return;
|
||||
|
||||
for (const item of Object.keys(queueItems)) {
|
||||
const task = queueItems[item];
|
||||
|
||||
if (task.state === "running") {
|
||||
// if the running worker isn't completed and wait to be called again
|
||||
// (on worker completion)
|
||||
if (!task.completedWorkers?.includes(task.runningWorker)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// if all workers are completed, then return the final file and go to next task
|
||||
if (task.completedWorkers.length === task.pipeline.length) {
|
||||
const finalFile = task.pipelineResults?.pop();
|
||||
if (finalFile) {
|
||||
itemDone(task.id, finalFile);
|
||||
continue;
|
||||
} else {
|
||||
itemError(task.id, task.runningWorker, "no final file");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// if current worker is completed, but there are more workers,
|
||||
// then start the next one and wait to be called again
|
||||
for (let i = 0; i < task.pipeline.length; i++) {
|
||||
if (!task.completedWorkers.includes(task.pipeline[i].workerId)) {
|
||||
startPipeline(task.pipeline[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// start the nearest waiting task and wait to be called again
|
||||
if (task.state === "waiting" && task.pipeline.length > 0) {
|
||||
startPipeline(task.pipeline[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
@ -2,34 +2,40 @@ import { defaultLocale } from "$lib/i18n/translations";
|
||||
import type { CobaltSettings } from "$lib/types/settings";
|
||||
|
||||
const defaultSettings: CobaltSettings = {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
advanced: {
|
||||
debug: false,
|
||||
useWebCodecs: false,
|
||||
},
|
||||
appearance: {
|
||||
theme: "auto",
|
||||
language: defaultLocale,
|
||||
autoLanguage: true,
|
||||
},
|
||||
accessibility: {
|
||||
reduceMotion: false,
|
||||
reduceTransparency: false,
|
||||
disableHaptics: false,
|
||||
dontAutoOpenQueue: false,
|
||||
},
|
||||
save: {
|
||||
alwaysProxy: false,
|
||||
localProcessing: false,
|
||||
audioBitrate: "128",
|
||||
audioFormat: "mp3",
|
||||
disableMetadata: false,
|
||||
downloadMode: "auto",
|
||||
filenameStyle: "classic",
|
||||
savingMethod: "download",
|
||||
tiktokH265: false,
|
||||
allowH265: false,
|
||||
tiktokFullAudio: false,
|
||||
twitterGif: true,
|
||||
convertGif: true,
|
||||
videoQuality: "1080",
|
||||
youtubeVideoCodec: "h264",
|
||||
youtubeDubLang: "original",
|
||||
youtubeHLS: false,
|
||||
},
|
||||
privacy: {
|
||||
alwaysProxy: false,
|
||||
disableAnalytics: false,
|
||||
},
|
||||
processing: {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import defaults from "$lib/settings/defaults";
|
||||
import type { CobaltSettings } from "$lib/types/settings";
|
||||
import defaults from "./defaults";
|
||||
|
||||
export default function lazySettingGetter(settings: CobaltSettings) {
|
||||
// Returns the setting value only if it differs from the default.
|
||||
|
@ -1,9 +1,10 @@
|
||||
import type { RecursivePartial } from "$lib/types/generic";
|
||||
import type {
|
||||
PartialSettings,
|
||||
AllPartialSettingsWithSchema,
|
||||
CobaltSettingsV3,
|
||||
CobaltSettingsV4,
|
||||
PartialSettings,
|
||||
CobaltSettingsV5,
|
||||
} from "$lib/types/settings";
|
||||
import { getBrowserLanguage } from "$lib/settings/youtube-lang";
|
||||
|
||||
@ -40,6 +41,42 @@ const migrations: Record<number, Migrator> = {
|
||||
|
||||
return out as AllPartialSettingsWithSchema;
|
||||
},
|
||||
|
||||
[5]: (settings: AllPartialSettingsWithSchema) => {
|
||||
const out = settings as RecursivePartial<CobaltSettingsV5>;
|
||||
out.schemaVersion = 5;
|
||||
|
||||
if (settings?.save) {
|
||||
if ("tiktokH265" in settings.save) {
|
||||
out.save!.allowH265 = settings.save.tiktokH265;
|
||||
delete settings.save.tiktokH265;
|
||||
}
|
||||
if ("twitterGif" in settings.save) {
|
||||
out.save!.convertGif = settings.save.twitterGif;
|
||||
delete settings.save.twitterGif;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings?.privacy) {
|
||||
if ("alwaysProxy" in settings.privacy) {
|
||||
out.save!.alwaysProxy = settings.privacy.alwaysProxy;
|
||||
delete settings.privacy.alwaysProxy;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings?.appearance) {
|
||||
if ("reduceMotion" in settings.appearance) {
|
||||
out.accessibility!.reduceMotion = settings.appearance.reduceMotion;
|
||||
delete settings.appearance.reduceMotion;
|
||||
}
|
||||
if ("reduceTransparency" in settings.appearance) {
|
||||
out.accessibility!.reduceTransparency = settings.appearance.reduceTransparency;
|
||||
delete settings.appearance.reduceTransparency;
|
||||
}
|
||||
}
|
||||
|
||||
return out as AllPartialSettingsWithSchema;
|
||||
},
|
||||
};
|
||||
|
||||
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
|
||||
|
||||
export const link = writable("");
|
||||
export const downloadButtonState = writable<CobaltDownloadButtonState>("idle");
|
||||
|
40
web/src/lib/state/queen-bee/current-tasks.ts
Normal file
40
web/src/lib/state/queen-bee/current-tasks.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { readable, type Updater } from "svelte/store";
|
||||
|
||||
import type { CobaltWorkerProgress } from "$lib/types/workers";
|
||||
import type { CobaltCurrentTasks, CobaltCurrentTaskItem } from "$lib/types/queen-bee";
|
||||
|
||||
let update: (_: Updater<CobaltCurrentTasks>) => void;
|
||||
|
||||
const currentTasks = readable<CobaltCurrentTasks>(
|
||||
{},
|
||||
(_, _update) => { update = _update }
|
||||
);
|
||||
|
||||
export function addWorkerToQueue(workerId: string, item: CobaltCurrentTaskItem) {
|
||||
update(tasks => {
|
||||
tasks[workerId] = item;
|
||||
return tasks;
|
||||
});
|
||||
}
|
||||
|
||||
export function removeWorkerFromQueue(id: string) {
|
||||
update(tasks => {
|
||||
delete tasks[id];
|
||||
return tasks;
|
||||
});
|
||||
}
|
||||
|
||||
export function updateWorkerProgress(workerId: string, progress: CobaltWorkerProgress) {
|
||||
update(allTasks => {
|
||||
allTasks[workerId].progress = progress;
|
||||
return allTasks;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearCurrentTasks() {
|
||||
update(() => {
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
export { currentTasks };
|
128
web/src/lib/state/queen-bee/queue.ts
Normal file
128
web/src/lib/state/queen-bee/queue.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { readable, type Updater } from "svelte/store";
|
||||
|
||||
import { checkTasks } from "$lib/queen-bee/scheduler";
|
||||
import { clearFileStorage, removeFromFileStorage } from "$lib/storage";
|
||||
import { clearCurrentTasks, removeWorkerFromQueue } from "$lib/state/queen-bee/current-tasks";
|
||||
|
||||
import type { CobaltFileReference } from "$lib/types/storage";
|
||||
import type { CobaltQueue, CobaltQueueItem } from "$lib/types/queue";
|
||||
|
||||
const clearPipelineCache = (queueItem: CobaltQueueItem) => {
|
||||
if (queueItem.state === "running" && queueItem.pipelineResults) {
|
||||
for (const item of queueItem.pipelineResults) {
|
||||
removeFromFileStorage(item.file.name);
|
||||
}
|
||||
delete queueItem.pipelineResults;
|
||||
}
|
||||
if (queueItem.state === "done") {
|
||||
removeFromFileStorage(queueItem.resultFile.file.name);
|
||||
}
|
||||
|
||||
return queueItem;
|
||||
}
|
||||
|
||||
let update: (_: Updater<CobaltQueue>) => void;
|
||||
|
||||
const queue = readable<CobaltQueue>(
|
||||
{},
|
||||
(_, _update) => { update = _update }
|
||||
);
|
||||
|
||||
export function addItem(item: CobaltQueueItem) {
|
||||
update(queueData => {
|
||||
queueData[item.id] = item;
|
||||
return queueData;
|
||||
});
|
||||
|
||||
checkTasks();
|
||||
}
|
||||
|
||||
export function itemError(id: string, workerId: string, error: string) {
|
||||
update(queueData => {
|
||||
if (queueData[id]) {
|
||||
queueData[id] = clearPipelineCache(queueData[id]);
|
||||
|
||||
queueData[id] = {
|
||||
...queueData[id],
|
||||
state: "error",
|
||||
errorCode: error,
|
||||
}
|
||||
}
|
||||
return queueData;
|
||||
});
|
||||
|
||||
removeWorkerFromQueue(workerId);
|
||||
checkTasks();
|
||||
}
|
||||
|
||||
export function itemDone(id: string, file: CobaltFileReference) {
|
||||
update(queueData => {
|
||||
if (queueData[id]) {
|
||||
queueData[id] = clearPipelineCache(queueData[id]);
|
||||
|
||||
queueData[id] = {
|
||||
...queueData[id],
|
||||
state: "done",
|
||||
resultFile: file,
|
||||
}
|
||||
}
|
||||
return queueData;
|
||||
});
|
||||
|
||||
checkTasks();
|
||||
}
|
||||
|
||||
export function pipelineTaskDone(id: string, workerId: string, file: CobaltFileReference) {
|
||||
update(queueData => {
|
||||
if (queueData[id] && queueData[id].state === "running") {
|
||||
queueData[id].pipelineResults = [...queueData[id].pipelineResults || [], file];
|
||||
queueData[id].completedWorkers = [...queueData[id].completedWorkers || [], workerId];
|
||||
}
|
||||
return queueData;
|
||||
});
|
||||
|
||||
removeWorkerFromQueue(workerId);
|
||||
checkTasks();
|
||||
}
|
||||
|
||||
export function itemRunning(id: string, workerId: string) {
|
||||
update(queueData => {
|
||||
if (queueData[id]) {
|
||||
queueData[id] = {
|
||||
...queueData[id],
|
||||
state: "running",
|
||||
runningWorker: workerId,
|
||||
}
|
||||
}
|
||||
return queueData;
|
||||
});
|
||||
|
||||
checkTasks();
|
||||
}
|
||||
|
||||
export function removeItem(id: string) {
|
||||
update(queueData => {
|
||||
if (queueData[id].pipeline) {
|
||||
for (const worker in queueData[id].pipeline) {
|
||||
removeWorkerFromQueue(queueData[id].pipeline[worker].workerId);
|
||||
}
|
||||
clearPipelineCache(queueData[id]);
|
||||
}
|
||||
|
||||
delete queueData[id];
|
||||
return queueData;
|
||||
});
|
||||
|
||||
checkTasks();
|
||||
}
|
||||
|
||||
export function clearQueue() {
|
||||
update(() => {
|
||||
return {};
|
||||
});
|
||||
|
||||
clearCurrentTasks();
|
||||
clearFileStorage();
|
||||
}
|
||||
|
||||
export { queue };
|
11
web/src/lib/state/queue-visibility.ts
Normal file
11
web/src/lib/state/queue-visibility.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import settings from "$lib/state/settings";
|
||||
import { get, writable } from "svelte/store";
|
||||
|
||||
export const queueVisible = writable(false);
|
||||
|
||||
export const openQueuePopover = () => {
|
||||
const visible = get(queueVisible);
|
||||
if (!visible && !get(settings).accessibility.dontAutoOpenQueue) {
|
||||
return queueVisible.update(v => !v);
|
||||
}
|
||||
}
|
81
web/src/lib/storage.ts
Normal file
81
web/src/lib/storage.ts
Normal file
@ -0,0 +1,81 @@
|
||||
const cobaltProcessingDir = "cobalt-processing-data";
|
||||
|
||||
export class OPFSStorage {
|
||||
#root;
|
||||
#handle;
|
||||
#io;
|
||||
|
||||
constructor(root: FileSystemDirectoryHandle, handle: FileSystemFileHandle, reader: FileSystemSyncAccessHandle) {
|
||||
this.#root = root;
|
||||
this.#handle = handle;
|
||||
this.#io = reader;
|
||||
}
|
||||
|
||||
static async init() {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const cobaltDir = await root.getDirectoryHandle(cobaltProcessingDir, { create: true });
|
||||
const handle = await cobaltDir.getFileHandle(crypto.randomUUID(), { create: true });
|
||||
const reader = await handle.createSyncAccessHandle();
|
||||
|
||||
return new this(cobaltDir, handle, reader);
|
||||
}
|
||||
|
||||
async res() {
|
||||
// await for compat with ios 15
|
||||
await this.#io.flush();
|
||||
await this.#io.close();
|
||||
return await this.#handle.getFile();
|
||||
}
|
||||
|
||||
read(size: number, offset: number) {
|
||||
const out = new Uint8Array(size);
|
||||
const bytesRead = this.#io.read(out, { at: offset });
|
||||
|
||||
return out.subarray(0, bytesRead);
|
||||
}
|
||||
|
||||
async write(data: Uint8Array | Int8Array, offset: number) {
|
||||
return this.#io.write(data, { at: offset })
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
await this.#root.removeEntry(this.#handle.name);
|
||||
}
|
||||
|
||||
static isAvailable() {
|
||||
return !!navigator.storage?.getDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
export const removeFromFileStorage = async (filename: string) => {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const cobaltDir = await root.getDirectoryHandle(cobaltProcessingDir);
|
||||
return await cobaltDir.removeEntry(filename);
|
||||
}
|
||||
|
||||
export const clearFileStorage = async () => {
|
||||
if (navigator.storage.getDirectory) {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
try {
|
||||
await root.removeEntry(cobaltProcessingDir, { recursive: true });
|
||||
} catch {
|
||||
// ignore the error because the dir might be missing and that's okay!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const clearCacheStorage = async () => {
|
||||
const keys = await caches.keys();
|
||||
|
||||
for (const key of keys) {
|
||||
caches.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export const getStorageQuota = async () => {
|
||||
let estimate;
|
||||
if (navigator.storage.estimate) {
|
||||
estimate = await navigator.storage.estimate();
|
||||
}
|
||||
return estimate;
|
||||
}
|
@ -1,8 +1,11 @@
|
||||
import type { CobaltSettings } from "$lib/types/settings";
|
||||
|
||||
enum CobaltResponseType {
|
||||
Error = 'error',
|
||||
Picker = 'picker',
|
||||
Redirect = 'redirect',
|
||||
Tunnel = 'tunnel',
|
||||
LocalProcessing = 'local-processing',
|
||||
}
|
||||
|
||||
export type CobaltErrorResponse = {
|
||||
@ -40,6 +43,36 @@ type CobaltTunnelResponse = {
|
||||
status: CobaltResponseType.Tunnel,
|
||||
} & CobaltPartialURLResponse;
|
||||
|
||||
export type CobaltLocalProcessingResponse = {
|
||||
status: CobaltResponseType.LocalProcessing,
|
||||
|
||||
// TODO: proper type for processing types
|
||||
type: string,
|
||||
service: string,
|
||||
tunnel: string[],
|
||||
|
||||
output: {
|
||||
type: string, // mimetype
|
||||
filename: string,
|
||||
metadata?: {
|
||||
album?: string,
|
||||
copyright?: string,
|
||||
title?: string,
|
||||
artist?: string,
|
||||
track?: string,
|
||||
date?: string
|
||||
},
|
||||
},
|
||||
|
||||
audio?: {
|
||||
copy: boolean,
|
||||
format: string,
|
||||
bitrate: string,
|
||||
},
|
||||
|
||||
isHLS?: boolean,
|
||||
}
|
||||
|
||||
export type CobaltFileUrlType = "redirect" | "tunnel";
|
||||
|
||||
export type CobaltSession = {
|
||||
@ -63,10 +96,17 @@ export type CobaltServerInfo = {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: strict partial
|
||||
// this allows for extra properties, which is not ideal,
|
||||
// but i couldn't figure out how to make a strict partial :(
|
||||
export type CobaltSaveRequestBody =
|
||||
{ url: string } & Partial<Omit<CobaltSettings['save'], 'savingMethod'>>;
|
||||
|
||||
export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;
|
||||
export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;
|
||||
|
||||
export type CobaltAPIResponse = CobaltErrorResponse
|
||||
| CobaltPickerResponse
|
||||
| CobaltRedirectResponse
|
||||
| CobaltTunnelResponse;
|
||||
| CobaltTunnelResponse
|
||||
| CobaltLocalProcessingResponse;
|
||||
|
@ -1,18 +1,16 @@
|
||||
export type InputFileKind = "video" | "audio";
|
||||
import type { CobaltFileReference } from "$lib/types/storage";
|
||||
|
||||
export type FileInfo = {
|
||||
type?: string | null,
|
||||
kind: InputFileKind,
|
||||
extension: string,
|
||||
type?: string,
|
||||
format?: string,
|
||||
}
|
||||
|
||||
export type RenderParams = {
|
||||
blob: Blob,
|
||||
output?: FileInfo,
|
||||
files: CobaltFileReference[],
|
||||
output: FileInfo,
|
||||
args: string[],
|
||||
}
|
||||
|
||||
|
||||
export type FFmpegProgressStatus = "continue" | "end" | "unknown";
|
||||
export type FFmpegProgressEvent = {
|
||||
status: FFmpegProgressStatus,
|
||||
|
1
web/src/lib/types/omnibox.ts
Normal file
1
web/src/lib/types/omnibox.ts
Normal file
@ -0,0 +1 @@
|
||||
export type CobaltDownloadButtonState = "idle" | "think" | "check" | "done" | "error";
|
11
web/src/lib/types/queen-bee.ts
Normal file
11
web/src/lib/types/queen-bee.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { CobaltWorkerProgress, CobaltWorkerType } from "$lib/types/workers";
|
||||
|
||||
export type CobaltCurrentTaskItem = {
|
||||
type: CobaltWorkerType,
|
||||
parentId: string,
|
||||
progress?: CobaltWorkerProgress,
|
||||
}
|
||||
|
||||
export type CobaltCurrentTasks = {
|
||||
[id: string]: CobaltCurrentTaskItem,
|
||||
}
|
44
web/src/lib/types/queue.ts
Normal file
44
web/src/lib/types/queue.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { CobaltSaveRequestBody } from "$lib/types/api";
|
||||
import type { CobaltFileReference } from "$lib/types/storage";
|
||||
import type { CobaltPipelineItem, CobaltPipelineResultFileType } from "$lib/types/workers";
|
||||
|
||||
export type CobaltQueueItemState = "waiting" | "running" | "done" | "error";
|
||||
|
||||
export type CobaltQueueBaseItem = {
|
||||
id: string,
|
||||
state: CobaltQueueItemState,
|
||||
pipeline: CobaltPipelineItem[],
|
||||
canRetry?: boolean,
|
||||
originalRequest?: CobaltSaveRequestBody,
|
||||
// TODO: metadata
|
||||
filename: string,
|
||||
mimeType?: string,
|
||||
mediaType: CobaltPipelineResultFileType,
|
||||
};
|
||||
|
||||
export type CobaltQueueItemWaiting = CobaltQueueBaseItem & {
|
||||
state: "waiting",
|
||||
};
|
||||
|
||||
export type CobaltQueueItemRunning = CobaltQueueBaseItem & {
|
||||
state: "running",
|
||||
runningWorker: string,
|
||||
completedWorkers?: string[],
|
||||
pipelineResults?: CobaltFileReference[],
|
||||
};
|
||||
|
||||
export type CobaltQueueItemDone = CobaltQueueBaseItem & {
|
||||
state: "done",
|
||||
resultFile: CobaltFileReference,
|
||||
};
|
||||
|
||||
export type CobaltQueueItemError = CobaltQueueBaseItem & {
|
||||
state: "error",
|
||||
errorCode: string,
|
||||
};
|
||||
|
||||
export type CobaltQueueItem = CobaltQueueItemWaiting | CobaltQueueItemRunning | CobaltQueueItemDone | CobaltQueueItemError;
|
||||
|
||||
export type CobaltQueue = {
|
||||
[id: string]: CobaltQueueItem,
|
||||
};
|
@ -2,14 +2,16 @@ import type { RecursivePartial } from "$lib/types/generic";
|
||||
import type { CobaltSettingsV2 } from "$lib/types/settings/v2";
|
||||
import type { CobaltSettingsV3 } from "$lib/types/settings/v3";
|
||||
import type { CobaltSettingsV4 } from "$lib/types/settings/v4";
|
||||
import type { CobaltSettingsV5 } from "$lib/types/settings/v5";
|
||||
|
||||
export * from "$lib/types/settings/v2";
|
||||
export * from "$lib/types/settings/v3";
|
||||
export * from "$lib/types/settings/v4";
|
||||
export * from "$lib/types/settings/v5";
|
||||
|
||||
export type CobaltSettings = CobaltSettingsV4;
|
||||
export type CobaltSettings = CobaltSettingsV5;
|
||||
|
||||
export type AnyCobaltSettings = CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
|
||||
export type AnyCobaltSettings = CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
|
||||
|
||||
export type PartialSettings = RecursivePartial<CobaltSettings>;
|
||||
|
||||
|
22
web/src/lib/types/settings/v5.ts
Normal file
22
web/src/lib/types/settings/v5.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { type CobaltSettingsV4 } from "$lib/types/settings/v4";
|
||||
|
||||
export type CobaltSettingsV5 = Omit<CobaltSettingsV4, 'schemaVersion' | 'advanced' | 'save' | 'privacy' | 'appearance'> & {
|
||||
schemaVersion: 5,
|
||||
appearance: Omit<CobaltSettingsV4['appearance'], 'reduceMotion' | 'reduceTransparency'>,
|
||||
accessibility: {
|
||||
reduceMotion: boolean;
|
||||
reduceTransparency: boolean;
|
||||
disableHaptics: boolean;
|
||||
dontAutoOpenQueue: boolean;
|
||||
},
|
||||
advanced: CobaltSettingsV4['advanced'] & {
|
||||
useWebCodecs: boolean;
|
||||
},
|
||||
privacy: Omit<CobaltSettingsV4['privacy'], 'alwaysProxy'>,
|
||||
save: Omit<CobaltSettingsV4['save'], 'tiktokH265' | 'twitterGif'> & {
|
||||
alwaysProxy: boolean;
|
||||
localProcessing: boolean;
|
||||
allowH265: boolean;
|
||||
convertGif: boolean;
|
||||
},
|
||||
};
|
4
web/src/lib/types/storage.ts
Normal file
4
web/src/lib/types/storage.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type CobaltFileReference = {
|
||||
file: File,
|
||||
type: string,
|
||||
}
|
27
web/src/lib/types/workers.ts
Normal file
27
web/src/lib/types/workers.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { FileInfo } from "$lib/types/libav";
|
||||
import type { CobaltFileReference } from "$lib/types/storage";
|
||||
|
||||
export const resultFileTypes = ["video", "audio", "image"] as const;
|
||||
|
||||
export type CobaltWorkerType = "remux" | "fetch";
|
||||
export type CobaltPipelineResultFileType = typeof resultFileTypes[number];
|
||||
|
||||
export type CobaltWorkerProgress = {
|
||||
percentage?: number,
|
||||
speed?: number,
|
||||
size: number,
|
||||
}
|
||||
|
||||
export type CobaltWorkerArgs = {
|
||||
files?: CobaltFileReference[],
|
||||
url?: string,
|
||||
ffargs?: string[],
|
||||
output?: FileInfo,
|
||||
}
|
||||
|
||||
export type CobaltPipelineItem = {
|
||||
worker: CobaltWorkerType,
|
||||
workerId: string,
|
||||
parentId: string,
|
||||
workerArgs: CobaltWorkerArgs,
|
||||
}
|
14
web/src/lib/util.ts
Normal file
14
web/src/lib/util.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export const formatFileSize = (size: number | undefined) => {
|
||||
size ||= 0;
|
||||
|
||||
// gigabyte, megabyte, kilobyte, byte
|
||||
const units = ['G', 'M', 'K', ''];
|
||||
while (size >= 1024 && units.length > 1) {
|
||||
size /= 1024;
|
||||
units.pop();
|
||||
}
|
||||
|
||||
const roundedSize = size.toFixed(2);
|
||||
const unit = units[units.length - 1] + "B";
|
||||
return `${roundedSize} ${unit}`;
|
||||
}
|
94
web/src/lib/workers/fetch.ts
Normal file
94
web/src/lib/workers/fetch.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { OPFSStorage } from "$lib/storage";
|
||||
|
||||
let attempts = 0;
|
||||
|
||||
const fetchFile = async (url: string) => {
|
||||
const error = async (code: string) => {
|
||||
attempts++;
|
||||
|
||||
if (attempts <= 5) {
|
||||
// try 5 more times before actually failing
|
||||
|
||||
console.log("fetch attempt", attempts);
|
||||
await fetchFile(url);
|
||||
} else {
|
||||
// if it still fails, then throw an error and quit
|
||||
self.postMessage({
|
||||
cobaltFetchWorker: {
|
||||
// TODO: return proper error code here
|
||||
// (error.code and not just random shit i typed up)
|
||||
error: code,
|
||||
}
|
||||
});
|
||||
self.close();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return error("file response wasn't ok");
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || 'application/octet-stream';
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
|
||||
const totalBytes = contentLength ? parseInt(contentLength, 10) : null;
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
const storage = await OPFSStorage.init();
|
||||
|
||||
if (!reader) {
|
||||
return error("no reader");
|
||||
}
|
||||
|
||||
let receivedBytes = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
await storage.write(value, receivedBytes);
|
||||
receivedBytes += value.length;
|
||||
|
||||
if (totalBytes) {
|
||||
self.postMessage({
|
||||
cobaltFetchWorker: {
|
||||
progress: Math.round((receivedBytes / totalBytes) * 100),
|
||||
size: receivedBytes,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (receivedBytes === 0) {
|
||||
return error("tunnel is broken");
|
||||
}
|
||||
|
||||
const file = await storage.res();
|
||||
|
||||
if (Number(contentLength) !== file.size) {
|
||||
return error("file was not downloaded fully");
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
cobaltFetchWorker: {
|
||||
result: {
|
||||
file,
|
||||
type: contentType,
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return error("error when downloading the file");
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent) => {
|
||||
if (event.data.cobaltFetchWorker) {
|
||||
await fetchFile(event.data.cobaltFetchWorker.url);
|
||||
self.close();
|
||||
}
|
||||
}
|
105
web/src/lib/workers/remux.ts
Normal file
105
web/src/lib/workers/remux.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import LibAVWrapper from "$lib/libav";
|
||||
|
||||
import type { FileInfo } from "$lib/types/libav";
|
||||
import type { CobaltFileReference } from "$lib/types/storage";
|
||||
|
||||
const error = (code: string) => {
|
||||
self.postMessage({
|
||||
cobaltRemuxWorker: {
|
||||
error: `error.${code}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const remux = async (files: CobaltFileReference[], args: string[], output: FileInfo) => {
|
||||
if (!(files && output && args)) return;
|
||||
|
||||
const ff = new LibAVWrapper((progress) => {
|
||||
self.postMessage({
|
||||
cobaltRemuxWorker: {
|
||||
progress: {
|
||||
durationProcessed: progress.out_time_sec,
|
||||
speed: progress.speed,
|
||||
size: progress.total_size,
|
||||
currentFrame: progress.frame,
|
||||
fps: progress.fps,
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
ff.init();
|
||||
|
||||
try {
|
||||
// probing just the first file in files array (usually audio) for duration progress
|
||||
const probeFile = files[0]?.file;
|
||||
if (!probeFile) return error("couldn't probe one of files");
|
||||
|
||||
const file_info = await ff.probe(probeFile).catch((e) => {
|
||||
if (e?.message?.toLowerCase().includes("out of memory")) {
|
||||
console.error("uh oh! out of memory");
|
||||
console.error(e);
|
||||
|
||||
error("remux.out_of_resources");
|
||||
self.close();
|
||||
}
|
||||
});
|
||||
|
||||
if (!file_info?.format) {
|
||||
return error("remux.corrupted");
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
cobaltRemuxWorker: {
|
||||
progress: {
|
||||
duration: Number(file_info.format.duration),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.type) {
|
||||
// TODO: better & more appropriate error code
|
||||
return error("remux.corrupted");
|
||||
}
|
||||
}
|
||||
|
||||
const render = await ff
|
||||
.render({
|
||||
files,
|
||||
output,
|
||||
args,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("uh-oh! render error");
|
||||
console.error(e);
|
||||
// TODO: better error codes, there are more reasons for a crash
|
||||
error("remux.out_of_resources");
|
||||
});
|
||||
|
||||
if (!render) {
|
||||
console.log("not a valid file");
|
||||
return error("incorrect input or output");
|
||||
}
|
||||
|
||||
await ff.terminate();
|
||||
|
||||
self.postMessage({
|
||||
cobaltRemuxWorker: {
|
||||
render
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return error("remux.crashed");
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent) => {
|
||||
const ed = event.data.cobaltRemuxWorker;
|
||||
if (ed) {
|
||||
if (ed.files && ed.args && ed.output) {
|
||||
await remux(ed.files, ed.args, ed.output);
|
||||
}
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
import "@fontsource/ibm-plex-mono/400-italic.css";
|
||||
import "@fontsource/ibm-plex-mono/500.css";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { updated } from "$app/stores";
|
||||
import { browser } from "$app/environment";
|
||||
@ -24,15 +25,19 @@
|
||||
import Turnstile from "$components/misc/Turnstile.svelte";
|
||||
import NotchSticker from "$components/misc/NotchSticker.svelte";
|
||||
import DialogHolder from "$components/dialog/DialogHolder.svelte";
|
||||
import ProcessingQueue from "$components/queue/ProcessingQueue.svelte";
|
||||
import UpdateNotification from "$components/misc/UpdateNotification.svelte";
|
||||
|
||||
$: reduceMotion =
|
||||
$settings.appearance.reduceMotion || device.prefers.reducedMotion;
|
||||
$settings.accessibility.reduceMotion || device.prefers.reducedMotion;
|
||||
|
||||
$: reduceTransparency =
|
||||
$settings.appearance.reduceTransparency ||
|
||||
$settings.accessibility.reduceTransparency ||
|
||||
device.prefers.reducedTransparency;
|
||||
|
||||
$: preloadMeowbalt = false;
|
||||
$: plausibleLoaded = false;
|
||||
|
||||
afterNavigate(async () => {
|
||||
const to_focus: HTMLElement | null =
|
||||
document.querySelector("[data-first-focus]");
|
||||
@ -42,6 +47,10 @@
|
||||
await getServerInfo();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
preloadMeowbalt = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@ -59,10 +68,13 @@
|
||||
<meta name="theme-color" content={statusBarColors[$currentTheme]} />
|
||||
{/if}
|
||||
|
||||
{#if env.PLAUSIBLE_ENABLED}
|
||||
{#if plausibleLoaded || (browser && env.PLAUSIBLE_ENABLED && !$settings.privacy.disableAnalytics)}
|
||||
<script
|
||||
defer
|
||||
data-domain={env.HOST}
|
||||
on:load={() => {
|
||||
plausibleLoaded = true;
|
||||
}}
|
||||
src="https://{env.PLAUSIBLE_HOST}/js/script.js"
|
||||
>
|
||||
</script>
|
||||
@ -74,21 +86,26 @@
|
||||
data-theme={browser ? $currentTheme : undefined}
|
||||
lang={$locale}
|
||||
>
|
||||
{#if preloadMeowbalt}
|
||||
<div id="preload-meowbalt" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div
|
||||
id="cobalt"
|
||||
class:loaded={browser}
|
||||
data-chrome={device.browser.chrome}
|
||||
data-iphone={device.is.iPhone}
|
||||
data-reduce-motion={reduceMotion}
|
||||
data-reduce-transparency={reduceTransparency}
|
||||
>
|
||||
{#if $updated}
|
||||
<UpdateNotification />
|
||||
{/if}
|
||||
{#if device.is.iPhone && app.is.installed}
|
||||
<NotchSticker />
|
||||
{/if}
|
||||
<DialogHolder />
|
||||
<Sidebar />
|
||||
{#if $updated}
|
||||
<UpdateNotification />
|
||||
{/if}
|
||||
<ProcessingQueue />
|
||||
<div id="content">
|
||||
{#if ($turnstileEnabled && $page.url.pathname === "/") || $turnstileCreated}
|
||||
<Turnstile />
|
||||
@ -107,20 +124,30 @@
|
||||
--gray: #75757e;
|
||||
|
||||
--red: #ed2236;
|
||||
--medium-red: #ce3030;
|
||||
--dark-red: #d61c2e;
|
||||
--green: #51cf5e;
|
||||
--green: #30bd1b;
|
||||
--blue: #2f8af9;
|
||||
--magenta: #eb445a;
|
||||
--purple: #5857d4;
|
||||
--orange: #f19a38;
|
||||
|
||||
--focus-ring: 0 0 0 2px var(--blue) inset;
|
||||
|
||||
--button: #f4f4f4;
|
||||
--button-hover: #e8e8e8;
|
||||
--button-hover: #ededed;
|
||||
--button-press: #e8e8e8;
|
||||
--button-active-hover: #2a2a2a;
|
||||
|
||||
--button-hover-transparent: rgba(0, 0, 0, 0.06);
|
||||
--button-press-transparent: rgba(0, 0, 0, 0.09);
|
||||
--button-stroke: rgba(0, 0, 0, 0.06);
|
||||
--button-text: #282828;
|
||||
--button-box-shadow: 0 0 0 1.5px var(--button-stroke) inset;
|
||||
--button-box-shadow: 0 0 0 1px var(--button-stroke) inset;
|
||||
|
||||
--button-elevated: #e3e3e3;
|
||||
--button-elevated-hover: #dadada;
|
||||
--button-elevated-press: #d3d3d3;
|
||||
--button-elevated-shimmer: #ededed;
|
||||
|
||||
--popover-glow: var(--button-stroke);
|
||||
@ -130,9 +157,11 @@
|
||||
|
||||
--dialog-backdrop: rgba(255, 255, 255, 0.3);
|
||||
|
||||
--sidebar-bg: #000000;
|
||||
--sidebar-highlight: #ffffff;
|
||||
--sidebar-hover: rgba(255, 255, 255, 0.1);
|
||||
--sidebar-bg: var(--button);
|
||||
--sidebar-highlight: var(--secondary);
|
||||
--sidebar-stroke: rgba(0, 0, 0, 0.04);
|
||||
|
||||
--content-border: rgba(0, 0, 0, 0.03);
|
||||
|
||||
--input-border: #adadb7;
|
||||
|
||||
@ -145,23 +174,32 @@
|
||||
--sidebar-width: 80px;
|
||||
--sidebar-font-size: 11px;
|
||||
--sidebar-inner-padding: 4px;
|
||||
--sidebar-tab-padding: 10px;
|
||||
|
||||
/* reduce default inset by 5px if it's not 0 */
|
||||
--sidebar-height-mobile: calc(
|
||||
50px + calc(var(--sidebar-inner-padding) * 2) +
|
||||
env(safe-area-inset-bottom)
|
||||
50px + calc(
|
||||
env(safe-area-inset-bottom) - 5px * sign(
|
||||
env(safe-area-inset-bottom)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
--content-border-thickness: 1px;
|
||||
--content-margin: calc(var(--sidebar-inner-padding) + var(--content-border-thickness));
|
||||
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
--switcher-padding: var(--sidebar-inner-padding);
|
||||
--switcher-padding: 3.5px;
|
||||
|
||||
/* used for fading the tab bar on scroll */
|
||||
--sidebar-mobile-gradient: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.9) 0%,
|
||||
rgba(0, 0, 0, 0) 4%,
|
||||
rgba(0, 0, 0, 0) 5%,
|
||||
rgba(0, 0, 0, 0) 50%,
|
||||
rgba(0, 0, 0, 0) 96%,
|
||||
rgba(0, 0, 0, 0) 95%,
|
||||
rgba(0, 0, 0, 0.9) 100%
|
||||
);
|
||||
|
||||
@ -190,15 +228,20 @@
|
||||
--green: #37aa42;
|
||||
|
||||
--button: #191919;
|
||||
--button-hover: #2a2a2a;
|
||||
--button-hover: #242424;
|
||||
--button-press: #2a2a2a;
|
||||
|
||||
--button-active-hover: #f9f9f9;
|
||||
|
||||
--button-hover-transparent: rgba(225, 225, 225, 0.1);
|
||||
--button-press-transparent: rgba(225, 225, 225, 0.15);
|
||||
--button-stroke: rgba(255, 255, 255, 0.05);
|
||||
--button-text: #e1e1e1;
|
||||
--button-box-shadow: 0 0 0 1.5px var(--button-stroke) inset;
|
||||
--button-box-shadow: 0 0 0 1px var(--button-stroke) inset;
|
||||
|
||||
--button-elevated: #282828;
|
||||
--button-elevated-hover: #323232;
|
||||
--button-elevated-hover: #2f2f2f;
|
||||
--button-elevated-press: #343434;
|
||||
|
||||
--popover-glow: rgba(135, 135, 135, 0.12);
|
||||
|
||||
@ -207,8 +250,11 @@
|
||||
|
||||
--dialog-backdrop: rgba(0, 0, 0, 0.3);
|
||||
|
||||
--sidebar-bg: #101010;
|
||||
--sidebar-highlight: #f2f2f2;
|
||||
--sidebar-bg: #131313;
|
||||
--sidebar-highlight: var(--secondary);
|
||||
--sidebar-stroke: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--content-border: rgba(255, 255, 255, 0.045);
|
||||
|
||||
--input-border: #383838;
|
||||
|
||||
@ -217,11 +263,11 @@
|
||||
|
||||
--sidebar-mobile-gradient: linear-gradient(
|
||||
90deg,
|
||||
rgba(16, 16, 16, 0.9) 0%,
|
||||
rgba(16, 16, 16, 0) 4%,
|
||||
rgba(16, 16, 16, 0) 50%,
|
||||
rgba(16, 16, 16, 0) 96%,
|
||||
rgba(16, 16, 16, 0.9) 100%
|
||||
rgba(19, 19, 19, 0.9) 0%,
|
||||
rgba(19, 19, 19, 0) 5%,
|
||||
rgba(19, 19, 19, 0) 50%,
|
||||
rgba(19, 19, 19, 0) 95%,
|
||||
rgba(19, 19, 19, 0.9) 100%
|
||||
);
|
||||
|
||||
--skeleton-gradient: linear-gradient(
|
||||
@ -239,6 +285,11 @@
|
||||
);
|
||||
}
|
||||
|
||||
/* fall back to less pretty value cuz chrome doesn't support sign() */
|
||||
:global([data-chrome="true"]) {
|
||||
--sidebar-height-mobile: calc(50px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
:global([data-theme="light"] [data-reduce-transparency="true"]) {
|
||||
--dialog-backdrop: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
@ -281,6 +332,10 @@
|
||||
|
||||
#cobalt[data-iphone="true"] #content {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
/* disable the desktop frame */
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -288,30 +343,44 @@
|
||||
display: flex;
|
||||
overflow: scroll;
|
||||
background-color: var(--primary);
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
box-shadow: 0 0 0 var(--content-border-thickness) var(--content-border);
|
||||
|
||||
border-radius: 8px;
|
||||
margin: var(--content-margin);
|
||||
margin-left: var(--content-border-thickness);
|
||||
}
|
||||
|
||||
#content:dir(rtl) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
margin-left: var(--content-margin);
|
||||
margin-right: var(--content-border-thickness);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 535px) {
|
||||
/* dark navbar cuz it looks better on mobile */
|
||||
:global([data-theme="light"]) {
|
||||
--sidebar-bg: #000000;
|
||||
--sidebar-highlight: var(--primary);
|
||||
}
|
||||
|
||||
#cobalt {
|
||||
display: grid;
|
||||
grid-template-columns: unset;
|
||||
grid-template-rows: 1fr var(--sidebar-height-mobile);
|
||||
grid-template-rows:
|
||||
1fr
|
||||
calc(
|
||||
var(--sidebar-height-mobile) + var(--sidebar-inner-padding) * 2
|
||||
);
|
||||
}
|
||||
|
||||
#content,
|
||||
#content:dir(rtl) {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
order: -1;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
|
||||
border-bottom-left-radius: calc(var(--border-radius) * 2);
|
||||
border-bottom-right-radius: calc(var(--border-radius) * 2);
|
||||
}
|
||||
@ -362,7 +431,7 @@
|
||||
}
|
||||
|
||||
:global(:focus-visible) {
|
||||
box-shadow: 0 0 0 2px var(--blue) inset !important;
|
||||
box-shadow: var(--focus-ring) !important;
|
||||
outline: none;
|
||||
z-index: 1;
|
||||
}
|
||||
@ -371,27 +440,41 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:global(button:active, .button:active) {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
:global(.button.elevated) {
|
||||
background-color: var(--button-elevated);
|
||||
}
|
||||
|
||||
:global(.button.elevated:not(.color):active) {
|
||||
background-color: var(--button-elevated-hover);
|
||||
}
|
||||
|
||||
:global(.button.elevated:not(:focus-visible)) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.button.active) {
|
||||
color: var(--primary);
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:global(.button:hover) {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
:global(.button.elevated:not(.color):hover) {
|
||||
background-color: var(--button-elevated-hover);
|
||||
}
|
||||
|
||||
:global(.button.active:not(.color):hover) {
|
||||
background-color: var(--button-active-hover);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.button:active) {
|
||||
background-color: var(--button-press);
|
||||
}
|
||||
|
||||
:global(.button.elevated:not(.color):active) {
|
||||
background-color: var(--button-elevated-press);
|
||||
}
|
||||
|
||||
:global(.button.elevated:not(:focus-visible)) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.button.active:not(.color):active) {
|
||||
background-color: var(--button-active-hover);
|
||||
}
|
||||
@ -413,20 +496,6 @@
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:global(button:hover) {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
:global(.button.elevated:not(.color):hover) {
|
||||
background-color: var(--button-elevated-hover);
|
||||
}
|
||||
|
||||
:global(.button.active:not(.color):hover) {
|
||||
background-color: var(--button-active-hover);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.center-column-container) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@ -580,4 +649,26 @@
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@keyframes -global-spinner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* preload meowbalt assets to prevent flickering in dialogs */
|
||||
#preload-meowbalt {
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
z-index: -10;
|
||||
content:
|
||||
url(/meowbalt/smile.png)
|
||||
url(/meowbalt/error.png)
|
||||
url(/meowbalt/question.png)
|
||||
url(/meowbalt/think.png);
|
||||
}
|
||||
</style>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user