mirror of
https://github.com/imputnet/cobalt.git
synced 2025-01-15 19:35:31 +00:00
api & web: merge base queue ui & api updates
This commit is contained in:
commit
a6069f406f
|
@ -8,18 +8,19 @@ import jwt from "../security/jwt.js";
|
||||||
import stream from "../stream/stream.js";
|
import stream from "../stream/stream.js";
|
||||||
import match from "../processing/match.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 { 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 { hashHmac } from "../security/secrets.js";
|
||||||
import { createStore } from "../store/redis-ratelimit.js";
|
import { createStore } from "../store/redis-ratelimit.js";
|
||||||
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
||||||
import { verifyTurnstileToken } from "../security/turnstile.js";
|
import { verifyTurnstileToken } from "../security/turnstile.js";
|
||||||
import { friendlyServiceName } from "../processing/service-alias.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 { createResponse, normalizeRequest, getIP } from "../processing/request.js";
|
||||||
import * as APIKeys from "../security/api-keys.js";
|
import * as APIKeys from "../security/api-keys.js";
|
||||||
import * as Cookies from "../processing/cookie/manager.js";
|
import * as Cookies from "../processing/cookie/manager.js";
|
||||||
|
import { setupTunnelHandler } from "./itunnel.js";
|
||||||
|
|
||||||
const git = {
|
const git = {
|
||||||
branch: await getBranch(),
|
branch: await getBranch(),
|
||||||
|
@ -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) => {
|
app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
|
||||||
const id = String(req.query.id);
|
const id = String(req.query.id);
|
||||||
const exp = String(req.query.exp);
|
const exp = String(req.query.exp);
|
||||||
|
@ -292,31 +302,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return stream(res, streamInfo);
|
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', ...streamInfo });
|
|
||||||
};
|
|
||||||
|
|
||||||
app.get('/itunnel', itunnelHandler);
|
|
||||||
|
|
||||||
app.get('/', (_, res) => {
|
app.get('/', (_, res) => {
|
||||||
res.type('json');
|
res.type('json');
|
||||||
|
@ -378,17 +364,5 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCluster) {
|
setupTunnelHandler();
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
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', ...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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -58,7 +58,8 @@ async function com_download(id) {
|
||||||
return {
|
return {
|
||||||
urls: [video.baseUrl, audio.baseUrl],
|
urls: [video.baseUrl, audio.baseUrl],
|
||||||
audioFilename: `bilibili_${id}_audio`,
|
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 HLS from "hls-parser";
|
||||||
import { createInternalStream } from "./manage.js";
|
import { createInternalStream } from "./manage.js";
|
||||||
|
import { request } from "undici";
|
||||||
|
|
||||||
function getURL(url) {
|
function getURL(url) {
|
||||||
try {
|
try {
|
||||||
|
@ -55,8 +56,11 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) {
|
||||||
|
|
||||||
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
|
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
|
||||||
|
|
||||||
export function isHlsResponse (req) {
|
export function isHlsResponse(req, streamInfo) {
|
||||||
return HLS_MIME_TYPES.includes(req.headers['content-type']);
|
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) {
|
export async function handleHlsPlaylist(streamInfo, req, res) {
|
||||||
|
@ -71,3 +75,59 @@ export async function handleHlsPlaylist(streamInfo, req, res) {
|
||||||
|
|
||||||
res.send(hlsPlaylist);
|
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";
|
||||||
|
|
||||||
|
const segmentSize = await getSegmentSize(randomSegment.uri, 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 { request } from "undici";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { closeRequest, getHeaders, pipe } from "./shared.js";
|
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 CHUNK_SIZE = BigInt(8e6); // 8 MB
|
||||||
const min = (a, b) => a < b ? a : b;
|
const min = (a, b) => a < b ? a : b;
|
||||||
|
@ -96,10 +96,7 @@ async function handleGenericStream(streamInfo, res) {
|
||||||
res.status(fileResponse.statusCode);
|
res.status(fileResponse.statusCode);
|
||||||
fileResponse.body.on('error', () => {});
|
fileResponse.body.on('error', () => {});
|
||||||
|
|
||||||
// bluesky's cdn responds with wrong content-type for the hls playlist,
|
const isHls = isHlsResponse(fileResponse, streamInfo);
|
||||||
// so we enforce it here until they fix it
|
|
||||||
const isHls = isHlsResponse(fileResponse)
|
|
||||||
|| (streamInfo.service === "bsky" && streamInfo.url.endsWith('.m3u8'));
|
|
||||||
|
|
||||||
for (const [ name, value ] of Object.entries(fileResponse.headers)) {
|
for (const [ name, value ] of Object.entries(fileResponse.headers)) {
|
||||||
if (!isHls || name.toLowerCase() !== 'content-length') {
|
if (!isHls || name.toLowerCase() !== 'content-length') {
|
||||||
|
@ -133,3 +130,40 @@ export function internalStream(streamInfo, res) {
|
||||||
|
|
||||||
return handleGenericStream(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 {}
|
||||||
|
}
|
||||||
|
|
|
@ -68,10 +68,20 @@ export function createStream(obj) {
|
||||||
return streamLink.toString();
|
return streamLink.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInternalStream(id) {
|
export function getInternalTunnel(id) {
|
||||||
return internalStreamCache.get(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 = {}) {
|
export function createInternalStream(url, obj = {}) {
|
||||||
assert(typeof url === 'string');
|
assert(typeof url === 'string');
|
||||||
|
|
||||||
|
@ -124,7 +134,7 @@ export function destroyInternalStream(url) {
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
|
|
||||||
if (internalStreamCache.has(id)) {
|
if (internalStreamCache.has(id)) {
|
||||||
closeRequest(getInternalStream(id)?.controller);
|
closeRequest(getInternalTunnel(id)?.controller);
|
||||||
internalStreamCache.delete(id);
|
internalStreamCache.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { genericUserAgent } from "../config.js";
|
import { genericUserAgent } from "../config.js";
|
||||||
import { vkClientAgent } from "../processing/services/vk.js";
|
import { vkClientAgent } from "../processing/services/vk.js";
|
||||||
|
import { getInternalTunnelFromURL } from "./manage.js";
|
||||||
|
import { probeInternalTunnel } from "./internal.js";
|
||||||
|
|
||||||
const defaultHeaders = {
|
const defaultHeaders = {
|
||||||
'user-agent': genericUserAgent
|
'user-agent': genericUserAgent
|
||||||
|
@ -47,3 +49,40 @@ export function pipe(from, to, done) {
|
||||||
|
|
||||||
from.pipe(to);
|
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);
|
return await stream.proxy(streamInfo, res);
|
||||||
|
|
||||||
case "internal":
|
case "internal":
|
||||||
return internalStream(streamInfo, res);
|
return await internalStream(streamInfo, res);
|
||||||
|
|
||||||
case "merge":
|
case "merge":
|
||||||
return stream.merge(streamInfo, res);
|
return await stream.merge(streamInfo, res);
|
||||||
|
|
||||||
case "remux":
|
case "remux":
|
||||||
case "mute":
|
case "mute":
|
||||||
return stream.remux(streamInfo, res);
|
return await stream.remux(streamInfo, res);
|
||||||
|
|
||||||
case "audio":
|
case "audio":
|
||||||
return stream.convertAudio(streamInfo, res);
|
return await stream.convertAudio(streamInfo, res);
|
||||||
|
|
||||||
case "gif":
|
case "gif":
|
||||||
return stream.convertGif(streamInfo, res);
|
return await stream.convertGif(streamInfo, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeResponse(res);
|
closeResponse(res);
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { create as contentDisposition } from "content-disposition-header";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { destroyInternalStream } from "./manage.js";
|
import { destroyInternalStream } from "./manage.js";
|
||||||
import { hlsExceptions } from "../processing/service-config.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 = {
|
const ffmpegArgs = {
|
||||||
webm: ["-c:v", "copy", "-c:a", "copy"],
|
webm: ["-c:v", "copy", "-c:a", "copy"],
|
||||||
|
@ -29,7 +29,7 @@ const convertMetadataToFFmpeg = (metadata) => {
|
||||||
|
|
||||||
for (const [ name, value ] of Object.entries(metadata)) {
|
for (const [ name, value ] of Object.entries(metadata)) {
|
||||||
if (metadataTags.includes(name)) {
|
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 {
|
} else {
|
||||||
throw `${name} metadata tag is not supported.`;
|
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;
|
let process;
|
||||||
const shutdown = () => (
|
const shutdown = () => (
|
||||||
killProcess(process),
|
killProcess(process),
|
||||||
|
@ -112,7 +112,7 @@ const merge = (streamInfo, res) => {
|
||||||
try {
|
try {
|
||||||
if (streamInfo.urls.length !== 2) return shutdown();
|
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 = [
|
let args = [
|
||||||
'-loglevel', '-8',
|
'-loglevel', '-8',
|
||||||
|
@ -152,6 +152,7 @@ const merge = (streamInfo, res) => {
|
||||||
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||||
|
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
|
||||||
|
|
||||||
pipe(muxOutput, res, shutdown);
|
pipe(muxOutput, res, shutdown);
|
||||||
|
|
||||||
|
@ -162,7 +163,7 @@ const merge = (streamInfo, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const remux = (streamInfo, res) => {
|
const remux = async (streamInfo, res) => {
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (
|
const shutdown = () => (
|
||||||
killProcess(process),
|
killProcess(process),
|
||||||
|
@ -196,7 +197,7 @@ const remux = (streamInfo, res) => {
|
||||||
args.push('-bsf:a', 'aac_adtstoasc');
|
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") {
|
if (format === "mp4") {
|
||||||
args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
|
args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
|
||||||
}
|
}
|
||||||
|
@ -215,6 +216,7 @@ const remux = (streamInfo, res) => {
|
||||||
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||||
|
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
|
||||||
|
|
||||||
pipe(muxOutput, res, shutdown);
|
pipe(muxOutput, res, shutdown);
|
||||||
|
|
||||||
|
@ -225,7 +227,7 @@ const remux = (streamInfo, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertAudio = (streamInfo, res) => {
|
const convertAudio = async (streamInfo, res) => {
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (
|
const shutdown = () => (
|
||||||
killProcess(process),
|
killProcess(process),
|
||||||
|
@ -284,6 +286,13 @@ const convertAudio = (streamInfo, res) => {
|
||||||
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||||
|
res.setHeader(
|
||||||
|
'Estimated-Content-Length',
|
||||||
|
await estimateTunnelLength(
|
||||||
|
streamInfo,
|
||||||
|
estimateAudioMultiplier(streamInfo) * 1.1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
pipe(muxOutput, res, shutdown);
|
pipe(muxOutput, res, shutdown);
|
||||||
res.on('finish', shutdown);
|
res.on('finish', shutdown);
|
||||||
|
@ -292,7 +301,7 @@ const convertAudio = (streamInfo, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertGif = (streamInfo, res) => {
|
const convertGif = async (streamInfo, res) => {
|
||||||
let process;
|
let process;
|
||||||
const shutdown = () => (killProcess(process), closeResponse(res));
|
const shutdown = () => (killProcess(process), closeResponse(res));
|
||||||
|
|
||||||
|
@ -321,6 +330,7 @@ const convertGif = (streamInfo, res) => {
|
||||||
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||||
|
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo, 60));
|
||||||
|
|
||||||
pipe(muxOutput, res, shutdown);
|
pipe(muxOutput, res, shutdown);
|
||||||
|
|
||||||
|
|
|
@ -16,5 +16,6 @@
|
||||||
"save": "save",
|
"save": "save",
|
||||||
"export": "export",
|
"export": "export",
|
||||||
"yes": "yes",
|
"yes": "yes",
|
||||||
"no": "no"
|
"no": "no",
|
||||||
|
"clear": "clear"
|
||||||
}
|
}
|
||||||
|
|
6
web/i18n/en/queue.json
Normal file
6
web/i18n/en/queue.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"title": "processing queue",
|
||||||
|
"stub": "nothing in the queue yet, only two of us.\ntry {{ value }} something!",
|
||||||
|
"stub.download": "downloading",
|
||||||
|
"stub.remux": "remuxing"
|
||||||
|
}
|
|
@ -113,6 +113,10 @@
|
||||||
|
|
||||||
"advanced.data": "data management",
|
"advanced.data": "data management",
|
||||||
|
|
||||||
|
"advanced.duck": "local processing",
|
||||||
|
"advanced.duck.title": "process everything on device",
|
||||||
|
"advanced.duck.description": "very wip, may cause critical issues or not work at all. this toggle will probably be gone by release.",
|
||||||
|
|
||||||
"processing.community": "community instances",
|
"processing.community": "community instances",
|
||||||
"processing.enable_custom.title": "use a custom processing server",
|
"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.",
|
"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.",
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
|
|
||||||
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
|
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
|
||||||
|
|
||||||
export let emotion: MeowbaltEmotions;
|
export let emotion: MeowbaltEmotions;
|
||||||
|
|
70
web/src/components/misc/PopoverContainer.svelte
Normal file
70
web/src/components/misc/PopoverContainer.svelte
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let id = "";
|
||||||
|
export let expanded = false;
|
||||||
|
export let popoverAction: () => void;
|
||||||
|
export let expandStart: "left" | "center" | "right" = "center";
|
||||||
|
|
||||||
|
$: renderPopover = false;
|
||||||
|
|
||||||
|
export 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 {
|
||||||
|
popoverAction();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover.center {
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover.right {
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover.expanded {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -8,6 +8,7 @@
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let sectionId: string;
|
export let sectionId: string;
|
||||||
export let beta = false;
|
export let beta = false;
|
||||||
|
export let nolink = false;
|
||||||
export let copyData = "";
|
export let copyData = "";
|
||||||
|
|
||||||
const sectionURL = `${$page.url.origin}${$page.url.pathname}#${sectionId}`;
|
const sectionURL = `${$page.url.origin}${$page.url.pathname}#${sectionId}`;
|
||||||
|
@ -32,18 +33,20 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
{#if !nolink}
|
||||||
class="link-copy"
|
<button
|
||||||
aria-label={copied
|
class="link-copy"
|
||||||
? $t("button.copied")
|
aria-label={copied
|
||||||
: $t(`button.copy${copyData ? "" : ".section"}`)}
|
? $t("button.copied")
|
||||||
on:click={() => {
|
: $t(`button.copy${copyData ? "" : ".section"}`)}
|
||||||
copied = true;
|
on:click={() => {
|
||||||
copyURL(copyData || sectionURL);
|
copied = true;
|
||||||
}}
|
copyURL(copyData || sectionURL);
|
||||||
>
|
}}
|
||||||
<CopyIcon check={copied} regularIcon={!!copyData} />
|
>
|
||||||
</button>
|
<CopyIcon check={copied} regularIcon={!!copyData} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -32,6 +32,7 @@
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: var(--padding);
|
margin: var(--padding);
|
||||||
|
margin-right: 71px;
|
||||||
margin-top: calc(env(safe-area-inset-top) + var(--padding));
|
margin-top: calc(env(safe-area-inset-top) + var(--padding));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
var(--button-box-shadow),
|
var(--button-box-shadow),
|
||||||
|
@ -90,6 +91,10 @@
|
||||||
animation: slide-in-bottom 0.4s;
|
animation: slide-in-bottom 0.4s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.update-button {
|
||||||
|
margin-right: var(--padding);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slide-in-bottom {
|
@keyframes slide-in-bottom {
|
||||||
from {
|
from {
|
||||||
transform: translateY(300px);
|
transform: translateY(300px);
|
||||||
|
|
187
web/src/components/queue/ProcessingQueue.svelte
Normal file
187
web/src/components/queue/ProcessingQueue.svelte
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n/translations";
|
||||||
|
import { onNavigate } from "$app/navigation";
|
||||||
|
|
||||||
|
import settings from "$lib/state/settings";
|
||||||
|
import { addToQueue, nukeEntireQueue, queue } from "$lib/state/queue";
|
||||||
|
|
||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
import type { QueueItem } from "$lib/types/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";
|
||||||
|
import IconPlus from "@tabler/icons-svelte/IconPlus.svelte";
|
||||||
|
|
||||||
|
import IconGif from "@tabler/icons-svelte/IconGif.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";
|
||||||
|
import IconVolume3 from "@tabler/icons-svelte/IconVolume3.svelte";
|
||||||
|
|
||||||
|
let popover: SvelteComponent;
|
||||||
|
$: expanded = false;
|
||||||
|
|
||||||
|
$: queueItems = Object.entries($queue) as [id: string, item: QueueItem][];
|
||||||
|
|
||||||
|
$: queueLength = Object.keys($queue).length;
|
||||||
|
$: indeterminate = false;
|
||||||
|
|
||||||
|
const itemIcons = {
|
||||||
|
video: IconMovie,
|
||||||
|
video_mute: IconVolume3,
|
||||||
|
audio: IconMusic,
|
||||||
|
audio_convert: IconMusic,
|
||||||
|
image: IconPhoto,
|
||||||
|
gif: IconGif,
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFakeQueueItem = () => {
|
||||||
|
return addToQueue({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
status: "waiting",
|
||||||
|
type: "video",
|
||||||
|
filename: "test.mp4",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
type: "video",
|
||||||
|
url: "https://",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
processingSteps: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const popoverAction = async () => {
|
||||||
|
expanded = !expanded;
|
||||||
|
};
|
||||||
|
|
||||||
|
onNavigate(() => {
|
||||||
|
expanded = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="processing-queue" class:expanded>
|
||||||
|
<ProcessingStatus {indeterminate} expandAction={popover?.showPopover} />
|
||||||
|
|
||||||
|
<PopoverContainer
|
||||||
|
bind:this={popover}
|
||||||
|
id="processing-popover"
|
||||||
|
{expanded}
|
||||||
|
{popoverAction}
|
||||||
|
expandStart="right"
|
||||||
|
>
|
||||||
|
<div id="processing-header">
|
||||||
|
<SectionHeading
|
||||||
|
title={$t("queue.title")}
|
||||||
|
sectionId="queue"
|
||||||
|
beta
|
||||||
|
nolink
|
||||||
|
/>
|
||||||
|
<div class="header-buttons">
|
||||||
|
{#if queueLength > 0}
|
||||||
|
<button class="clear-button" on:click={nukeEntireQueue}>
|
||||||
|
<IconX />
|
||||||
|
{$t("button.clear")}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<!-- button for ui debug -->
|
||||||
|
{#if $settings.advanced.debug}
|
||||||
|
<button class="test-button" on:click={addFakeQueueItem}>
|
||||||
|
<IconPlus />
|
||||||
|
add item
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="processing-list">
|
||||||
|
{#each queueItems as [id, item]}
|
||||||
|
<ProcessingQueueItem
|
||||||
|
{id}
|
||||||
|
filename={item.filename}
|
||||||
|
status={item.status}
|
||||||
|
icon={itemIcons[item.type]}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if queueLength === 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: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
#processing-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
max-height: 65vh;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 535px) {
|
||||||
|
#processing-queue {
|
||||||
|
--holder-padding: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
156
web/src/components/queue/ProcessingQueueItem.svelte
Normal file
156
web/src/components/queue/ProcessingQueueItem.svelte
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import IconX from "@tabler/icons-svelte/IconX.svelte";
|
||||||
|
import IconDownload from "@tabler/icons-svelte/IconDownload.svelte";
|
||||||
|
import { removeFromQueue } from "$lib/state/queue";
|
||||||
|
|
||||||
|
export let id: string;
|
||||||
|
export let filename: string;
|
||||||
|
export let status: string;
|
||||||
|
export let progress: number = 0;
|
||||||
|
export let icon: ConstructorOfATypedSvelteComponent;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="processing-item">
|
||||||
|
<div class="processing-info">
|
||||||
|
<div class="file-title">
|
||||||
|
<div class="processing-type">
|
||||||
|
<svelte:component this={icon} />
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-progress">
|
||||||
|
<div
|
||||||
|
class="progress"
|
||||||
|
style="width: {Math.min(100, progress)}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-status">{id}: {status}</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<button class="action-button">
|
||||||
|
<IconDownload />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
on:click={() => removeFromQueue(id)}
|
||||||
|
>
|
||||||
|
<IconX />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.processing-item,
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-progress {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--button-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-progress,
|
||||||
|
.file-progress .progress {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-progress .progress {
|
||||||
|
background-color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray);
|
||||||
|
line-break: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-item:hover .file-actions {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 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 IconRun from "@tabler/icons-svelte/IconRun.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: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">
|
||||||
|
<IconRun />
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#processing-status.completed {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--blue) inset,
|
||||||
|
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: spin 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-ring.indeterminate circle {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed #progress-ring {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,18 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from "$lib/i18n/translations";
|
import { t } from "$lib/i18n/translations";
|
||||||
import { getServerInfo } from "$lib/api/server-info";
|
|
||||||
import cachedInfo from "$lib/state/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 Skeleton from "$components/misc/Skeleton.svelte";
|
||||||
import IconPlus from "@tabler/icons-svelte/IconPlus.svelte";
|
import IconPlus from "@tabler/icons-svelte/IconPlus.svelte";
|
||||||
|
import PopoverContainer from "$components/misc/PopoverContainer.svelte";
|
||||||
|
|
||||||
let services: string[] = [];
|
let services: string[] = [];
|
||||||
|
|
||||||
let popover: HTMLDivElement;
|
let popover: SvelteComponent;
|
||||||
|
|
||||||
$: expanded = false;
|
$: expanded = false;
|
||||||
|
|
||||||
|
let servicesContainer: HTMLDivElement;
|
||||||
$: loaded = false;
|
$: loaded = false;
|
||||||
$: renderPopover = false;
|
|
||||||
|
|
||||||
const loadInfo = async () => {
|
const loadInfo = async () => {
|
||||||
await getServerInfo();
|
await getServerInfo();
|
||||||
|
@ -29,19 +32,7 @@
|
||||||
await loadInfo();
|
await loadInfo();
|
||||||
}
|
}
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
popover.focus();
|
servicesContainer.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();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -49,7 +40,7 @@
|
||||||
<div id="supported-services" class:expanded>
|
<div id="supported-services" class:expanded>
|
||||||
<button
|
<button
|
||||||
id="services-button"
|
id="services-button"
|
||||||
on:click={showPopover}
|
on:click={popover?.showPopover}
|
||||||
aria-label={$t(`save.services.title_${expanded ? "hide" : "show"}`)}
|
aria-label={$t(`save.services.title_${expanded ? "hide" : "show"}`)}
|
||||||
>
|
>
|
||||||
<div class="expand-icon">
|
<div class="expand-icon">
|
||||||
|
@ -58,33 +49,36 @@
|
||||||
<span class="title">{$t("save.services.title")}</span>
|
<span class="title">{$t("save.services.title")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if renderPopover}
|
<PopoverContainer
|
||||||
<div id="services-popover">
|
bind:this={popover}
|
||||||
<div
|
id="services-popover"
|
||||||
id="services-container"
|
{expanded}
|
||||||
bind:this={popover}
|
{popoverAction}
|
||||||
tabindex="-1"
|
>
|
||||||
data-focus-ring-hidden
|
<div
|
||||||
>
|
id="services-container"
|
||||||
{#if loaded}
|
bind:this={servicesContainer}
|
||||||
{#each services as service}
|
tabindex="-1"
|
||||||
<div class="service-item">{service}</div>
|
data-focus-ring-hidden
|
||||||
{/each}
|
>
|
||||||
{:else}
|
{#if loaded}
|
||||||
{#each { length: 17 } as _}
|
{#each services as service}
|
||||||
<Skeleton
|
<div class="service-item">{service}</div>
|
||||||
class="elevated"
|
{/each}
|
||||||
width={Math.random() * 44 + 50 + "px"}
|
{:else}
|
||||||
height="24.5px"
|
{#each { length: 17 } as _}
|
||||||
/>
|
<Skeleton
|
||||||
{/each}
|
class="elevated"
|
||||||
{/if}
|
width={Math.random() * 44 + 50 + "px"}
|
||||||
</div>
|
height="24.5px"
|
||||||
<div id="services-disclaimer" class="subtext">
|
/>
|
||||||
{$t("save.services.disclaimer")}
|
{/each}
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div id="services-disclaimer" class="subtext">
|
||||||
|
{$t("save.services.disclaimer")}
|
||||||
|
</div>
|
||||||
|
</PopoverContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@ -97,34 +91,6 @@
|
||||||
height: 35px;
|
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 {
|
#services-button {
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
padding: 7px 13px 7px 10px;
|
padding: 7px 13px 7px 10px;
|
||||||
|
|
|
@ -2,9 +2,10 @@ import { defaultLocale } from "$lib/i18n/translations";
|
||||||
import type { CobaltSettings } from "$lib/types/settings";
|
import type { CobaltSettings } from "$lib/types/settings";
|
||||||
|
|
||||||
const defaultSettings: CobaltSettings = {
|
const defaultSettings: CobaltSettings = {
|
||||||
schemaVersion: 4,
|
schemaVersion: 5,
|
||||||
advanced: {
|
advanced: {
|
||||||
debug: false,
|
debug: false,
|
||||||
|
duck: false,
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
theme: "auto",
|
theme: "auto",
|
||||||
|
|
70
web/src/lib/state/queue.ts
Normal file
70
web/src/lib/state/queue.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import { merge } from "ts-deepmerge";
|
||||||
|
import { get, readable, type Updater } from "svelte/store";
|
||||||
|
import type { OngoingQueueItem, QueueItem } from "$lib/types/queue";
|
||||||
|
|
||||||
|
type Queue = {
|
||||||
|
[id: string]: QueueItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OngoingQueue = {
|
||||||
|
[id: string]: OngoingQueueItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
let update: (_: Updater<Queue>) => void;
|
||||||
|
|
||||||
|
const queue = readable<Queue>(
|
||||||
|
{},
|
||||||
|
(_, _update) => { update = _update }
|
||||||
|
);
|
||||||
|
|
||||||
|
export function addToQueue(item: QueueItem) {
|
||||||
|
update(queueData => {
|
||||||
|
queueData[item.id] = item;
|
||||||
|
return queueData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFromQueue(id: string) {
|
||||||
|
update(queueData => {
|
||||||
|
delete queueData[id];
|
||||||
|
return queueData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateOngoing: (_: Updater<OngoingQueue>) => void;
|
||||||
|
|
||||||
|
const ongoingQueue = readable<OngoingQueue>(
|
||||||
|
{},
|
||||||
|
(_, _update) => { updateOngoing = _update }
|
||||||
|
);
|
||||||
|
|
||||||
|
export function updateOngoingQueue(id: string, itemInfo: Partial<OngoingQueueItem> = {}) {
|
||||||
|
updateOngoing(queueData => {
|
||||||
|
if (get(queue)?.id) {
|
||||||
|
queueData[id] = merge(queueData[id], {
|
||||||
|
id,
|
||||||
|
...itemInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return queueData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFromOngoingQueue(id: string) {
|
||||||
|
updateOngoing(queue => {
|
||||||
|
delete queue[id];
|
||||||
|
return queue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nukeEntireQueue() {
|
||||||
|
update(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
updateOngoing(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { queue, ongoingQueue };
|
34
web/src/lib/types/queue.ts
Normal file
34
web/src/lib/types/queue.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
type ProcessingStep = "mux" | "mux_hls" | "encode";
|
||||||
|
type ProcessingPreset = "mp4" | "webm" | "copy";
|
||||||
|
type ProcessingState = "completed" | "failed" | "canceled" | "waiting" | "downloading" | "muxing" | "converting";
|
||||||
|
type ProcessingType = "video" | "video_mute" | "audio" | "audio_convert" | "image" | "gif";
|
||||||
|
type QueueFileType = "video" | "audio" | "image" | "gif";
|
||||||
|
|
||||||
|
export type ProcessingStepItem = {
|
||||||
|
type: ProcessingStep,
|
||||||
|
preset?: ProcessingPreset,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueFile = {
|
||||||
|
type: QueueFileType,
|
||||||
|
url: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueItem = {
|
||||||
|
id: string,
|
||||||
|
status: ProcessingState,
|
||||||
|
type: ProcessingType,
|
||||||
|
filename: string,
|
||||||
|
files: QueueFile[],
|
||||||
|
processingSteps: ProcessingStepItem[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OngoingQueueItem = {
|
||||||
|
id: string,
|
||||||
|
currentStep?: ProcessingStep,
|
||||||
|
size?: {
|
||||||
|
expected: number,
|
||||||
|
current: number,
|
||||||
|
},
|
||||||
|
speed?: number,
|
||||||
|
}
|
|
@ -2,14 +2,16 @@ import type { RecursivePartial } from "$lib/types/generic";
|
||||||
import type { CobaltSettingsV2 } from "$lib/types/settings/v2";
|
import type { CobaltSettingsV2 } from "$lib/types/settings/v2";
|
||||||
import type { CobaltSettingsV3 } from "$lib/types/settings/v3";
|
import type { CobaltSettingsV3 } from "$lib/types/settings/v3";
|
||||||
import type { CobaltSettingsV4 } from "$lib/types/settings/v4";
|
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/v2";
|
||||||
export * from "$lib/types/settings/v3";
|
export * from "$lib/types/settings/v3";
|
||||||
export * from "$lib/types/settings/v4";
|
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>;
|
export type PartialSettings = RecursivePartial<CobaltSettings>;
|
||||||
|
|
||||||
|
|
8
web/src/lib/types/settings/v5.ts
Normal file
8
web/src/lib/types/settings/v5.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { type CobaltSettingsV4 } from "$lib/types/settings/v4";
|
||||||
|
|
||||||
|
export type CobaltSettingsV5 = Omit<CobaltSettingsV4, 'schemaVersion' | 'advanced'> & {
|
||||||
|
schemaVersion: 5,
|
||||||
|
advanced: CobaltSettingsV4['advanced'] & {
|
||||||
|
duck: boolean;
|
||||||
|
};
|
||||||
|
};
|
|
@ -24,6 +24,7 @@
|
||||||
import Turnstile from "$components/misc/Turnstile.svelte";
|
import Turnstile from "$components/misc/Turnstile.svelte";
|
||||||
import NotchSticker from "$components/misc/NotchSticker.svelte";
|
import NotchSticker from "$components/misc/NotchSticker.svelte";
|
||||||
import DialogHolder from "$components/dialog/DialogHolder.svelte";
|
import DialogHolder from "$components/dialog/DialogHolder.svelte";
|
||||||
|
import ProcessingQueue from "$components/queue/ProcessingQueue.svelte";
|
||||||
import UpdateNotification from "$components/misc/UpdateNotification.svelte";
|
import UpdateNotification from "$components/misc/UpdateNotification.svelte";
|
||||||
|
|
||||||
$: reduceMotion =
|
$: reduceMotion =
|
||||||
|
@ -81,15 +82,18 @@
|
||||||
data-reduce-motion={reduceMotion}
|
data-reduce-motion={reduceMotion}
|
||||||
data-reduce-transparency={reduceTransparency}
|
data-reduce-transparency={reduceTransparency}
|
||||||
>
|
>
|
||||||
{#if $updated}
|
|
||||||
<UpdateNotification />
|
|
||||||
{/if}
|
|
||||||
{#if device.is.iPhone && app.is.installed}
|
{#if device.is.iPhone && app.is.installed}
|
||||||
<NotchSticker />
|
<NotchSticker />
|
||||||
{/if}
|
{/if}
|
||||||
<DialogHolder />
|
<DialogHolder />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
{#if $updated}
|
||||||
|
<UpdateNotification />
|
||||||
|
{/if}
|
||||||
<div id="content">
|
<div id="content">
|
||||||
|
{#if $settings.advanced.duck}
|
||||||
|
<ProcessingQueue />
|
||||||
|
{/if}
|
||||||
{#if ($turnstileEnabled && $page.url.pathname === "/") || $turnstileCreated}
|
{#if ($turnstileEnabled && $page.url.pathname === "/") || $turnstileCreated}
|
||||||
<Turnstile />
|
<Turnstile />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -15,6 +15,15 @@
|
||||||
/>
|
/>
|
||||||
</SettingsCategory>
|
</SettingsCategory>
|
||||||
|
|
||||||
|
<SettingsCategory sectionId="local-processing" title={$t("settings.advanced.duck")} beta>
|
||||||
|
<SettingsToggle
|
||||||
|
settingContext="advanced"
|
||||||
|
settingId="duck"
|
||||||
|
title={$t("settings.advanced.duck.title")}
|
||||||
|
description={$t("settings.advanced.duck.description")}
|
||||||
|
/>
|
||||||
|
</SettingsCategory>
|
||||||
|
|
||||||
<SettingsCategory sectionId="data" title={$t("settings.advanced.data")}>
|
<SettingsCategory sectionId="data" title={$t("settings.advanced.data")}>
|
||||||
<ManageSettings />
|
<ManageSettings />
|
||||||
</SettingsCategory>
|
</SettingsCategory>
|
||||||
|
|
Loading…
Reference in a new issue