merge: cobalt 11 with local processing & better performance (#1287)

This commit is contained in:
wukko
2025-05-29 22:23:48 +06:00
committed by GitHub
178 changed files with 6254 additions and 2529 deletions

View File

@ -24,6 +24,8 @@ jobs:
node-version: 'lts/*'
- uses: pnpm/action-setup@v4
- run: .github/test.sh web
env:
WEB_DEFAULT_API: ${{ vars.WEB_DEFAULT_API }}
test-api:
name: api sanity check

View File

@ -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)!

View File

@ -1,7 +1,7 @@
{
"name": "@imput/cobalt-api",
"description": "save what you love",
"version": "10.9.4",
"version": "11.0",
"author": "imput",
"exports": "./src/cobalt.js",
"type": "module",
@ -34,6 +34,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",

View File

@ -1,92 +1,32 @@
import { Constants } from "youtubei.js";
import { getVersion } from "@imput/version-info";
import { services } from "./processing/service-config.js";
import { supportsReusePort } from "./misc/cluster.js";
import { loadEnvs, validateEnvs, setupEnvWatcher } from "./core/env.js";
import * as cluster from "./misc/cluster.js";
const version = await getVersion();
const disabledServices = process.env.DISABLED_SERVICES?.split(',') || [];
const enabledServices = new Set(Object.keys(services).filter(e => {
if (!disabledServices.includes(e)) {
return e;
}
}));
const env = {
apiURL: process.env.API_URL || '',
apiPort: process.env.API_PORT || 9000,
tunnelPort: process.env.API_PORT || 9000,
listenAddress: process.env.API_LISTEN_ADDRESS,
freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR,
corsWildcard: process.env.CORS_WILDCARD !== '0',
corsURL: process.env.CORS_URL,
cookiePath: process.env.COOKIE_PATH,
rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60,
rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20,
sessionRateLimitWindow: (process.env.SESSION_RATELIMIT_WINDOW && parseInt(process.env.SESSION_RATELIMIT_WINDOW)) || 60,
sessionRateLimit: (process.env.SESSION_RATELIMIT && parseInt(process.env.SESSION_RATELIMIT)) || 10,
durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800,
streamLifespan: (process.env.TUNNEL_LIFESPAN && parseInt(process.env.TUNNEL_LIFESPAN)) || 90,
processingPriority: process.platform !== 'win32'
&& process.env.PROCESSING_PRIORITY
&& parseInt(process.env.PROCESSING_PRIORITY),
externalProxy: process.env.API_EXTERNAL_PROXY,
turnstileSitekey: process.env.TURNSTILE_SITEKEY,
turnstileSecret: process.env.TURNSTILE_SECRET,
jwtSecret: process.env.JWT_SECRET,
jwtLifetime: process.env.JWT_EXPIRY || 120,
sessionEnabled: process.env.TURNSTILE_SITEKEY
&& process.env.TURNSTILE_SECRET
&& process.env.JWT_SECRET,
apiKeyURL: process.env.API_KEY_URL && new URL(process.env.API_KEY_URL),
authRequired: process.env.API_AUTH_REQUIRED === '1',
redisURL: process.env.API_REDIS_URL,
instanceCount: (process.env.API_INSTANCE_COUNT && parseInt(process.env.API_INSTANCE_COUNT)) || 1,
keyReloadInterval: 900,
enabledServices,
customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT,
ytSessionServer: process.env.YOUTUBE_SESSION_SERVER,
ytSessionReloadInterval: 300,
ytSessionInnertubeClient: process.env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
}
const env = loadEnvs();
const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
export const canonicalEnv = Object.freeze(structuredClone(process.env));
export const setTunnelPort = (port) => env.tunnelPort = port;
export const isCluster = env.instanceCount > 1;
export const updateEnv = (newEnv) => {
// tunnelPort is special and needs to get carried over here
newEnv.tunnelPort = env.tunnelPort;
if (env.sessionEnabled && env.jwtSecret.length < 16) {
throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)");
for (const key in env) {
env[key] = newEnv[key];
}
cluster.broadcast({ env_update: newEnv });
}
if (env.instanceCount > 1 && !env.redisURL) {
throw new Error("API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2");
} else if (env.instanceCount > 1 && !await supportsReusePort()) {
console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js');
console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux');
console.error('(or other OS that supports it). for more info, see `reusePort` option on');
console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback');
throw new Error('SO_REUSEPORT is not supported');
}
await validateEnvs(env);
if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) {
console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported.");
console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`);
throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT");
if (env.envFile) {
setupEnvWatcher();
}
export {

View File

@ -8,16 +8,17 @@ 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 { setupTunnelHandler } from "./itunnel.js";
import * as APIKeys from "../security/api-keys.js";
import * as Cookies from "../processing/cookie/manager.js";
@ -47,28 +48,31 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
const startTime = new Date();
const startTimestamp = startTime.getTime();
const serverInfo = JSON.stringify({
cobalt: {
version: version,
url: env.apiURL,
startTime: `${startTimestamp}`,
durationLimit: env.durationLimit,
turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined,
services: [...env.enabledServices].map(e => {
return friendlyServiceName(e);
}),
},
git,
})
const getServerInfo = () => {
return JSON.stringify({
cobalt: {
version: version,
url: env.apiURL,
startTime: `${startTimestamp}`,
turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined,
services: [...env.enabledServices].map(e => {
return friendlyServiceName(e);
}),
},
git,
});
}
const serverInfo = getServerInfo();
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');
@ -94,14 +98,14 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
});
const apiTunnelLimiter = rateLimit({
windowMs: env.rateLimitWindow * 1000,
limit: (req) => req.rateLimitMax || env.rateLimitMax,
windowMs: env.tunnelRateLimitWindow * 1000,
limit: env.tunnelRateLimitMax,
standardHeaders: 'draft-6',
legacyHeaders: false,
keyGenerator: req => req.rateLimitKey || keyGenerator(req),
keyGenerator: req => keyGenerator(req),
store: await createStore('tunnel'),
handler: (_, res) => {
return res.sendStatus(429)
return res.sendStatus(429);
}
});
@ -180,6 +184,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
}
req.rateLimitKey = hashHmac(token, 'rate');
req.isSession = true;
} catch {
return fail(res, "error.api.generic");
}
@ -244,6 +249,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
if (!parsed) {
return fail(res, "error.api.link.invalid");
}
if ("error" in parsed) {
let context;
if (parsed?.context) {
@ -257,13 +263,23 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
host: parsed.host,
patternMatch: parsed.patternMatch,
params: normalizedRequest,
isSession: req.isSession ?? false,
});
res.status(result.status).json(result.body);
} catch {
fail(res, "error.api.generic");
}
})
});
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);
@ -294,35 +310,11 @@ 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');
res.status(200).send(serverInfo);
res.status(200).send(env.envFile ? getServerInfo() : serverInfo);
})
app.get('/favicon.ico', (req, res) => {
@ -342,10 +334,6 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
if (env.externalProxy) {
if (env.freebindCIDR) {
throw new Error('Freebind is not available when external proxy is enabled')
}
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
}
@ -384,17 +372,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();
}

189
api/src/core/env.js Normal file
View File

@ -0,0 +1,189 @@
import { Constants } from "youtubei.js";
import { services } from "../processing/service-config.js";
import { updateEnv, canonicalEnv, env as currentEnv } from "../config.js";
import { FileWatcher } from "../misc/file-watcher.js";
import { isURL } from "../misc/utils.js";
import * as cluster from "../misc/cluster.js";
import { Yellow } from "../misc/console-text.js";
const forceLocalProcessingOptions = ["never", "session", "always"];
export const loadEnvs = (env = process.env) => {
const disabledServices = env.DISABLED_SERVICES?.split(',') || [];
const enabledServices = new Set(Object.keys(services).filter(e => {
if (!disabledServices.includes(e)) {
return e;
}
}));
return {
apiURL: env.API_URL || '',
apiPort: env.API_PORT || 9000,
tunnelPort: env.API_PORT || 9000,
listenAddress: env.API_LISTEN_ADDRESS,
freebindCIDR: process.platform === 'linux' && env.FREEBIND_CIDR,
corsWildcard: env.CORS_WILDCARD !== '0',
corsURL: env.CORS_URL,
cookiePath: env.COOKIE_PATH,
rateLimitWindow: (env.RATELIMIT_WINDOW && parseInt(env.RATELIMIT_WINDOW)) || 60,
rateLimitMax: (env.RATELIMIT_MAX && parseInt(env.RATELIMIT_MAX)) || 20,
tunnelRateLimitWindow: (env.TUNNEL_RATELIMIT_WINDOW && parseInt(env.TUNNEL_RATELIMIT_WINDOW)) || 60,
tunnelRateLimitMax: (env.TUNNEL_RATELIMIT_MAX && parseInt(env.TUNNEL_RATELIMIT_MAX)) || 40,
sessionRateLimitWindow: (env.SESSION_RATELIMIT_WINDOW && parseInt(env.SESSION_RATELIMIT_WINDOW)) || 60,
sessionRateLimit: (env.SESSION_RATELIMIT && parseInt(env.SESSION_RATELIMIT)) || 10,
durationLimit: (env.DURATION_LIMIT && parseInt(env.DURATION_LIMIT)) || 10800,
streamLifespan: (env.TUNNEL_LIFESPAN && parseInt(env.TUNNEL_LIFESPAN)) || 90,
processingPriority: process.platform !== 'win32'
&& env.PROCESSING_PRIORITY
&& parseInt(env.PROCESSING_PRIORITY),
externalProxy: env.API_EXTERNAL_PROXY,
turnstileSitekey: env.TURNSTILE_SITEKEY,
turnstileSecret: env.TURNSTILE_SECRET,
jwtSecret: env.JWT_SECRET,
jwtLifetime: env.JWT_EXPIRY || 120,
sessionEnabled: env.TURNSTILE_SITEKEY
&& env.TURNSTILE_SECRET
&& env.JWT_SECRET,
apiKeyURL: env.API_KEY_URL && new URL(env.API_KEY_URL),
authRequired: env.API_AUTH_REQUIRED === '1',
redisURL: env.API_REDIS_URL,
instanceCount: (env.API_INSTANCE_COUNT && parseInt(env.API_INSTANCE_COUNT)) || 1,
keyReloadInterval: 900,
enabledServices,
customInnertubeClient: env.CUSTOM_INNERTUBE_CLIENT,
ytSessionServer: env.YOUTUBE_SESSION_SERVER,
ytSessionReloadInterval: 300,
ytSessionInnertubeClient: env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
ytAllowBetterAudio: env.YOUTUBE_ALLOW_BETTER_AUDIO !== "0",
// "never" | "session" | "always"
forceLocalProcessing: env.FORCE_LOCAL_PROCESSING ?? "never",
envFile: env.API_ENV_FILE,
envRemoteReloadInterval: 300,
};
}
export const validateEnvs = async (env) => {
if (env.sessionEnabled && env.jwtSecret.length < 16) {
throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)");
}
if (env.instanceCount > 1 && !env.redisURL) {
throw new Error("API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2");
} else if (env.instanceCount > 1 && !await cluster.supportsReusePort()) {
console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js');
console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux');
console.error('(or other OS that supports it). for more info, see `reusePort` option on');
console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback');
throw new Error('SO_REUSEPORT is not supported');
}
if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) {
console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported.");
console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`);
throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT");
}
if (env.forceLocalProcessing && !forceLocalProcessingOptions.includes(env.forceLocalProcessing)) {
console.error("FORCE_LOCAL_PROCESSING is invalid.");
console.error(`Supported options are are: ${forceLocalProcessingOptions.join(', ')}\n`);
throw new Error("Invalid FORCE_LOCAL_PROCESSING");
}
if (env.externalProxy && env.freebindCIDR) {
throw new Error('freebind is not available when external proxy is enabled')
}
}
const reloadEnvs = async (contents) => {
const newEnvs = {};
for (let line of (await contents).split('\n')) {
line = line.trim();
if (line === '') {
continue;
}
let [ key, value ] = line.split(/=(.+)?/);
if (key) {
if (value.match(/^['"]/) && value.match(/['"]$/)) {
value = JSON.parse(value);
}
newEnvs[key] = value || '';
}
}
const candidate = {
...canonicalEnv,
...newEnvs,
};
const parsed = loadEnvs(candidate);
await validateEnvs(parsed);
updateEnv(parsed);
}
const wrapReload = (contents) => {
reloadEnvs(contents)
.catch((e) => {
console.error(`${Yellow('[!]')} Failed reloading environment variables at ${new Date().toISOString()}.`);
console.error('Error:', e);
});
}
let watcher;
const setupWatcherFromFile = (path) => {
const load = () => wrapReload(watcher.read());
if (isURL(path)) {
watcher = FileWatcher.fromFileProtocol(path);
} else {
watcher = new FileWatcher({ path });
}
watcher.on('file-updated', load);
load();
}
const setupWatcherFromFetch = (url) => {
const load = () => wrapReload(fetch(url).then(r => r.text()));
setInterval(load, currentEnv.envRemoteReloadInterval);
load();
}
export const setupEnvWatcher = () => {
if (cluster.isPrimary) {
const envFile = currentEnv.envFile;
const isFile = !isURL(envFile)
|| new URL(envFile).protocol === 'file:';
if (isFile) {
setupWatcherFromFile(envFile);
} else {
setupWatcherFromFetch(envFile);
}
} else if (cluster.isWorker) {
process.on('message', (message) => {
if ('env_update' in message) {
updateEnv(message.env_update);
}
});
}
}

61
api/src/core/itunnel.js Normal file
View 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);
});
}

View File

@ -0,0 +1,43 @@
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs/promises';
export class FileWatcher extends EventEmitter {
#path;
#hasWatcher = false;
#lastChange = new Date().getTime();
constructor({ path, ...rest }) {
super(rest);
this.#path = path;
}
async #setupWatcher() {
if (this.#hasWatcher)
return;
this.#hasWatcher = true;
const watcher = fs.watch(this.#path);
for await (const _ of watcher) {
if (new Date() - this.#lastChange > 50) {
this.emit('file-updated');
this.#lastChange = new Date().getTime();
}
}
}
read() {
this.#setupWatcher();
return fs.readFile(this.#path, 'utf8');
}
static fromFileProtocol(url_) {
const url = new URL(url_);
if (url.protocol !== 'file:') {
return;
}
const pathname = url.pathname === '/' ? '' : url.pathname;
const file_path = decodeURIComponent(url.host + pathname);
return new this({ path: file_path });
}
}

View File

@ -52,3 +52,12 @@ export function splitFilenameExtension(filename) {
export function zip(a, b) {
return a.map((value, i) => [ value, b[i] ]);
}
export function isURL(input) {
try {
new URL(input);
return true;
} catch {
return false;
}
}

View File

@ -1,10 +1,25 @@
const illegalCharacters = ['}', '{', '%', '>', '<', '^', ';', ':', '`', '$', '"', "@", '=', '?', '|', '*'];
// characters that are disallowed on windows:
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
const characterMap = {
'<': '',
'>': '',
':': '',
'"': '',
'/': '',
'\\': '',
'|': '',
'?': '',
'*': ''
};
const sanitizeString = (string) => {
for (const i in illegalCharacters) {
string = string.replaceAll("/", "_").replaceAll("\\", "_")
.replaceAll(illegalCharacters[i], '')
export const sanitizeString = (string) => {
// remove any potential control characters the string might contain
string = string.replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
for (const [ char, replacement ] of Object.entries(characterMap)) {
string = string.replaceAll(char, replacement);
}
return string;
}

View File

@ -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 }
);
}

View File

@ -32,7 +32,7 @@ import xiaohongshu from "./services/xiaohongshu.js";
let freebind;
export default async function({ host, patternMatch, params }) {
export default async function({ host, patternMatch, params, isSession }) {
const { url } = params;
assert(url instanceof URL);
let dispatcher, requestIP;
@ -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
});
@ -113,6 +113,10 @@ export default async function({ host, patternMatch, params }) {
fetchInfo.format = "vp9";
fetchInfo.isAudioOnly = true;
fetchInfo.isAudioMuted = false;
if (env.ytAllowBetterAudio && params.youtubeBetterAudio) {
fetchInfo.quality = "max";
}
}
r = await youtube(fetchInfo);
@ -131,7 +135,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;
@ -239,7 +243,7 @@ export default async function({ host, patternMatch, params }) {
case "xiaohongshu":
r = await xiaohongshu({
...patternMatch,
h265: params.tiktokH265,
h265: params.allowH265,
isAudioOnly,
dispatcher,
});
@ -267,7 +271,7 @@ export default async function({ host, patternMatch, params }) {
switch(r.error) {
case "content.too_long":
context = {
limit: env.durationLimit / 60,
limit: parseFloat((env.durationLimit / 60).toFixed(2)),
}
break;
@ -288,6 +292,14 @@ export default async function({ host, patternMatch, params }) {
})
}
let localProcessing = params.localProcessing;
const lpEnv = env.forceLocalProcessing;
if (lpEnv === "always" || (lpEnv === "session" && isSession)) {
localProcessing = true;
}
return matchAction({
r,
host,
@ -296,10 +308,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,
})
} catch {
return createResponse("error", {

View File

@ -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,

View File

@ -20,7 +20,7 @@ export const apiSchema = z.object({
filenameStyle: z.enum(
["classic", "pretty", "basic", "nerdy"]
).default("classic"),
).default("basic"),
youtubeVideoCodec: z.enum(
["h264", "av1", "vp9"]
@ -36,16 +36,20 @@ 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),
youtubeBetterAudio: z.boolean().default(false),
// temporarily kept for backwards compatibility with cobalt 10 schema
twitterGif: z.boolean().default(false),
tiktokH265: z.boolean().default(false),
})
.strict();

View File

@ -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
};
}

View File

@ -1,18 +1,18 @@
import { genericUserAgent } from "../../config.js";
export default async function({ id }) {
const craftHeaders = id => ({
"user-agent": genericUserAgent,
"content-type": "application/json",
origin: "https://www.loom.com",
referer: `https://www.loom.com/share/${id}`,
cookie: `loom_referral_video=${id};`,
"x-loom-request-source": "loom_web_be851af",
});
async function fromTranscodedURL(id) {
const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/transcoded-url`, {
method: "POST",
headers: {
"user-agent": genericUserAgent,
origin: "https://www.loom.com",
referer: `https://www.loom.com/share/${id}`,
cookie: `loom_referral_video=${id};`,
"apollographql-client-name": "web",
"apollographql-client-version": "14c0b42",
"x-loom-request-source": "loom_web_14c0b42",
},
headers: craftHeaders(id),
body: JSON.stringify({
force_original: false,
password: null,
@ -20,20 +20,47 @@ export default async function({ id }) {
deviceID: null
})
})
.then(r => r.status === 200 ? r.json() : false)
.then(r => r.status === 200 && r.json())
.catch(() => {});
if (!gql) return { error: "fetch.empty" };
if (gql?.url?.includes('.mp4?')) {
return gql.url;
}
}
const videoUrl = gql?.url;
async function fromRawURL(id) {
const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/raw-url`, {
method: "POST",
headers: craftHeaders(id),
body: JSON.stringify({
anonID: crypto.randomUUID(),
client_name: "web",
client_version: "be851af",
deviceID: null,
force_original: false,
password: null,
supported_mime_types: ["video/mp4"],
})
})
.then(r => r.status === 200 && r.json())
.catch(() => {});
if (videoUrl?.includes('.mp4?')) {
return {
urls: videoUrl,
filename: `loom_${id}.mp4`,
audioFilename: `loom_${id}_audio`
}
if (gql?.url?.includes('.mp4?')) {
return gql.url;
}
}
export default async function({ id }) {
let url = await fromTranscodedURL(id);
url ??= await fromRawURL(id);
if (!url) {
return { error: "fetch.empty" }
}
return { error: "fetch.empty" }
return {
urls: url,
filename: `loom_${id}.mp4`,
audioFilename: `loom_${id}_audio`
}
}

View File

@ -162,6 +162,19 @@ const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) =>
repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
}
if (tweetResult.card?.legacy?.binding_values?.length) {
const card = JSON.parse(tweetResult.card.legacy.binding_values[0].value?.string_value);
if (!["video_website", "image_website"].includes(card?.type) ||
!card?.media_entities ||
card?.component_objects?.media_1?.type !== "media") {
return;
}
const mediaId = card.component_objects?.media_1?.data?.id;
return [card.media_entities[mediaId]];
}
return (repostedTweet?.media || baseTweet?.extended_entities?.media);
}

View File

@ -1,8 +1,8 @@
import { env } from "../config.js";
import { readFile } from "node:fs/promises";
import { Green, Yellow } from "../misc/console-text.js";
import ip from "ipaddr.js";
import * as cluster from "../misc/cluster.js";
import { FileWatcher } from "../misc/file-watcher.js";
// this function is a modified variation of code
// from https://stackoverflow.com/a/32402438/14855621
@ -13,7 +13,7 @@ const generateWildcardRegex = rule => {
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
let keys = {};
let keys = {}, reader = null;
const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']);
@ -118,34 +118,39 @@ const formatKeys = (keyData) => {
}
const updateKeys = (newKeys) => {
validateKeys(newKeys);
cluster.broadcast({ api_keys: newKeys });
keys = formatKeys(newKeys);
}
const loadKeys = async (source) => {
let updated;
if (source.protocol === 'file:') {
const pathname = source.pathname === '/' ? '' : source.pathname;
updated = JSON.parse(
await readFile(
decodeURIComponent(source.host + pathname),
'utf8'
)
);
} else {
updated = await fetch(source).then(a => a.json());
}
const loadRemoteKeys = async (source) => {
updateKeys(
await fetch(source).then(a => a.json())
);
}
validateKeys(updated);
cluster.broadcast({ api_keys: updated });
updateKeys(updated);
const loadLocalKeys = async () => {
updateKeys(
JSON.parse(await reader.read())
);
}
const wrapLoad = (url, initial = false) => {
loadKeys(url)
.then(() => {
let load = loadRemoteKeys.bind(null, url);
if (url.protocol === 'file:') {
if (initial) {
reader = FileWatcher.fromFileProtocol(url);
reader.on('file-updated', () => wrapLoad(url));
}
load = loadLocalKeys;
}
load().then(() => {
if (initial || reader) {
console.log(`${Green('[✓]')} api keys loaded successfully!`)
}
})
@ -214,7 +219,7 @@ export const validateAuthorization = (req) => {
export const setup = (url) => {
if (cluster.isPrimary) {
wrapLoad(url, true);
if (env.keyReloadInterval > 0) {
if (env.keyReloadInterval > 0 && url.protocol !== 'file:') {
setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000);
}
} else if (cluster.isWorker) {

View File

@ -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;
}

View File

@ -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 {}
}

View File

@ -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);
}
}
@ -143,7 +180,7 @@ const transplantInternalTunnels = function(tunnelUrls, transplantUrls) {
for (const [ tun, url ] of zip(tunnelUrls, transplantUrls)) {
const id = getInternalTunnelId(tun);
const itunnel = getInternalStream(id);
const itunnel = getInternalTunnel(id);
if (!itunnel) continue;
itunnel.url = url;

View File

@ -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;
}

View File

@ -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);

View File

@ -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);

View File

@ -29,5 +29,32 @@
"code": 400,
"status": "error"
}
},
{
"name": "video with no transcodedUrl",
"url": "https://www.loom.com/share/aa3d8b08bee74d05af5b42989e9f33e9",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "video with title in url",
"url": "https://www.loom.com/share/Meet-AI-workflows-aa3d8b08bee74d05af5b42989e9f33e9",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "video with title in url (2)",
"url": "https://www.loom.com/share/Unlocking-Incredible-Organizational-Velocity-with-Async-Video-4a2a8baf124c4390954dcbb46a58cfd7",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}
]
]

View File

@ -217,5 +217,14 @@
"code": 200,
"status": "tunnel"
}
},
{
"name": "video in an ad card",
"url": "https://x.com/igorbrigadir/status/1611399816487084033?s=46",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
}
]

View File

@ -3,15 +3,17 @@ you can customize your processing instance's behavior using these environment va
this document is not final and will expand over time. feel free to improve it!
### general vars
| name | default | value example |
|:--------------------|:----------|:--------------------------------------|
| API_URL | | `https://api.url.example/` |
| API_PORT | `9000` | `1337` |
| COOKIE_PATH | | `/cookies.json` |
| PROCESSING_PRIORITY | | `10` |
| API_INSTANCE_COUNT | | `6` |
| API_REDIS_URL | | `redis://localhost:6379` |
| DISABLED_SERVICES | | `bilibili,youtube` |
| name | default | value example |
|:-----------------------|:--------|:--------------------------------------|
| API_URL | | `https://api.url.example/` |
| API_PORT | `9000` | `1337` |
| COOKIE_PATH | | `/cookies.json` |
| PROCESSING_PRIORITY | | `10` |
| API_INSTANCE_COUNT | | `6` |
| API_REDIS_URL | | `redis://localhost:6379` |
| DISABLED_SERVICES | | `bilibili,youtube` |
| FORCE_LOCAL_PROCESSING | `never` | `always` |
| API_ENV_FILE | | `/.env` |
[*view details*](#general)
@ -33,6 +35,8 @@ this document is not final and will expand over time. feel free to improve it!
| RATELIMIT_MAX | `20` | `30` |
| SESSION_RATELIMIT_WINDOW | `60` | `60` |
| SESSION_RATELIMIT | `10` | `10` |
| TUNNEL_RATELIMIT_WINDOW | `60` | `60` |
| TUNNEL_RATELIMIT | `40` | `10` |
[*view details*](#limits)
@ -56,6 +60,7 @@ this document is not final and will expand over time. feel free to improve it!
| CUSTOM_INNERTUBE_CLIENT | `IOS` |
| YOUTUBE_SESSION_SERVER | `http://localhost:8080/` |
| YOUTUBE_SESSION_INNERTUBE_CLIENT | `WEB_EMBEDDED` |
| YOUTUBE_ALLOW_BETTER_AUDIO | `1` |
[*view details*](#service-specific)
@ -100,6 +105,16 @@ comma-separated list which disables certain services from being used.
the value is a string of cobalt-supported services.
### FORCE_LOCAL_PROCESSING
the value is a string: `never` (default), `session`, or `always`.
when set to `session`, only requests from session (Bearer token) clients will be forced to use on-device processing.
when set to `always`, all requests will be forced to use on-device processing, no matter the preference.
### API_ENV_FILE
the URL or local path to a `key=value`-style environment variable file. this is used for dynamically reloading environment variables. **not all environment variables are able to be updated by this.** (e.g. the ratelimiters are instantiated when starting cobalt, and cannot be changed)
## networking
[*jump to the table*](#networking-vars)
@ -161,6 +176,16 @@ amount of session requests to be allowed within the time window of `SESSION_RATE
the value is a number.
### TUNNEL_RATELIMIT_WINDOW
rate limit time window for tunnel (proxy/stream) requests, in **seconds**.
the value is a number.
### TUNNEL_RATELIMIT
amount of tunnel requests to be allowed within the time window of `TUNNEL_RATELIMIT_WINDOW`.
the value is a number.
## security
[*jump to the table*](#security-vars)
@ -170,7 +195,7 @@ the value is a number.
### CORS_WILDCARD
defines whether cross-origin resource sharing is enabled. when enabled, your instance will be accessible from foreign web pages.
the value is a number. 0: disabled. 1: enabled.
the value is a number, either `0` or `1`.
### CORS_URL
configures the [cross-origin resource sharing origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin). your instance will be available only from this URL if `CORS_WILDCARD` is set to `0`.
@ -207,7 +232,7 @@ the value is a URL.
### API_AUTH_REQUIRED
when set to `1`, the user always needs to be authenticated in some way before they can access the API (either via an api key or via turnstile, if enabled).
the value is a number.
the value is a number, either `0` or `1`.
## service-specific
[*jump to the table*](#service-specific-vars)
@ -226,3 +251,8 @@ the value is a URL.
innertube client that's compatible with botguard's (web) `poToken` and `visitor_data`.
the value is a string.
### YOUTUBE_ALLOW_BETTER_AUDIO
when set to `1`, cobalt will try to use higher quality audio if user requests it via `youtubeBetterAudio`. will negatively impact the rate limit of a secondary youtube client with a session.
the value is a number, either `0` or `1`.

View File

@ -1,8 +1,15 @@
# cobalt api documentation
this document provides info about methods and acceptable variables for all cobalt api requests.
methods, acceptable values, headers, responses and everything else related to making and parsing requests from a cobalt api instance.
> [!IMPORTANT]
> hosted api instances (such as `api.cobalt.tools`) use bot protection and are **not** intended to be used in other projects without explicit permission. if you want to access the cobalt api reliably, you should [host your own instance](/docs/run-an-instance.md) or ask an instance owner for access.
> hosted api instances (such as `api.cobalt.tools`) use bot protection and are **not** intended to be used in other projects without explicit permission. if you want to use the cobalt api, you should [host your own instance](/docs/run-an-instance.md) or ask an instance owner for access.
- [POST /](#post)
- [POST /session](#post-session)
- [GET /](#get)
- [GET /tunnel](#get-tunnel)
all endpoints (except for `GET /`) are rate limited and return current rate limiting status in `RateLimit-*` headers, according to the ["RateLimit Header Fields for HTTP" spec](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html#name-header-specifications).
## authentication
an api instance may be configured to require you to authenticate yourself.
@ -40,14 +47,11 @@ challenge, if the instance has turnstile configured. the resulting token is pass
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## POST: `/`
## POST `/`
cobalt's main processing endpoint.
request body type: `application/json`
response body type: `application/json`
> [!IMPORTANT]
> you must include `Accept` and `Content-Type` headers with every `POST /` request.
> you must include correct `Accept` and `Content-Type` headers with every `POST /` request.
```
Accept: application/json
@ -55,103 +59,132 @@ Content-Type: application/json
```
### request body
| key | type | expected value(s) | default | description |
|:-----------------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
| `url` | `string` | URL to download | -- | **must** be included in every request. |
| `videoQuality` | `string` | `144 / ... / 2160 / 4320 / max` | `1080` | `720` quality is recommended for phones. |
| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | |
| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` | `128` | specifies the bitrate to use for the audio. applies only to audio conversion. |
| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `classic` | changes the way files are named. previews can be seen in the web app. |
| `downloadMode` | `string` | `auto / audio / mute` | `auto` | `audio` downloads only the audio, `mute` skips the audio track in videos. |
| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | `h264` is recommended for phones. |
| `youtubeDubLang` | `string` | `en / ru / cs / ja / es-US / ...` | -- | specifies the language of audio to download when a youtube video is dubbed. |
| `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 |
| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. |
body type: `application/json`
not a fan of reading tables of text?
you can read [the api schema](/api/src/processing/schema.js) directly from code instead!
### api schema
all keys except for `url` are optional. value options are separated by `/`.
#### general
| key | type | description/value | default |
|:-----------------------|:----------|:----------------------------------------------------------------|:-----------|
| `url` | `string` | source URL | *required* |
| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` (kbps) | `128` |
| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` |
| `downloadMode` | `string` | `auto / audio / mute` | `auto` |
| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `basic` |
| `videoQuality` | `string` | `max / 4320 / 2160 / 1440 / 1080 / 720 / 480 / 360 / 240 / 144` | `1080` |
| `disableMetadata` | `boolean` | title, artist, and other info will not be added to the file | `false` |
| `alwaysProxy` | `boolean` | always tunnel all files, even when not necessary | `false` |
| `localProcessing` | `boolean` | remux/transcode files locally instead of the server | `false` |
#### service-specific options
| key | type | description/value | default |
|:-----------------------|:----------|:--------------------------------------------------|:--------|
| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` |
| `youtubeDubLang` | `string` | any valid language code, such as: `en` or `zh-CN` | *none* |
| `convertGif` | `boolean` | convert twitter gifs to the actual GIF format | `true` |
| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu | `false` |
| `tiktokFullAudio` | `boolean` | download the original sound used in a video | `false` |
| `youtubeBetterAudio` | `boolean` | prefer higher quality youtube audio if possible | `false` |
| `youtubeHLS` | `boolean` | use HLS formats when downloading from youtube | `false` |
### response
the response will always be a JSON object containing the `status` key, which will be one of:
- `error` - something went wrong
- `picker` - we have multiple items to choose from
- `redirect` - you are being redirected to the direct service URL
- `tunnel` - cobalt is proxying the download for you
body type: `application/json`
the response will always be a JSON object containing the `status` key, which is one of:
- `tunnel`: cobalt is proxying and/or remuxing/transcoding the file for you.
- `local-processing`: cobalt is proxying the files for you, but you have to remux/transcode them locally.
- `redirect`: cobalt will redirect you to the direct service URL.
- `picker`: there are multiple items to choose from, a picker should be shown.
- `error`: something went wrong, here's an error code.
### tunnel/redirect response
| key | type | values |
|:-------------|:---------|:------------------------------------------------------------|
| `status` | `string` | `tunnel / redirect` |
| `url` | `string` | url for the cobalt tunnel, or redirect to an external link |
| `filename` | `string` | cobalt-generated filename for the file being downloaded |
| key | type | value |
|:-------------|:---------|:-----------------------------------------------------------|
| `status` | `string` | `tunnel / redirect` |
| `url` | `string` | url for the cobalt tunnel, or redirect to an external link |
| `filename` | `string` | cobalt-generated filename for the file being downloaded |
### local processing response
| key | type | value |
|:-------------|:-----------|:--------------------------------------------------------------|
| `status` | `string` | `local-processing` |
| `type` | `string` | `merge`, `mute`, `audio`, `gif`, or `remux` |
| `service` | `string` | origin service (`youtube`, `twitter`, `instagram`, etc) |
| `tunnel` | `string[]` | array of tunnel URLs |
| `output` | `object` | details about the output file ([see below](#output-object)) |
| `audio` | `object` | audio-specific details (optional, [see below](#audio-object)) |
| `isHLS` | `boolean` | whether the output is in HLS format (optional) |
#### output object
| key | type | value |
|:-----------|:---------|:----------------------------------------------------------------------------------|
| `type` | `string` | mime type of the output file |
| `filename` | `string` | filename of the output file |
| `metadata` | `object` | metadata associated with the file (optional, [see below](#outputmetadata-object)) |
#### output.metadata object
all keys in this table are optional.
| key | type | description |
|:------------|:---------|:-------------------------------------------|
| `album` | `string` | album name or collection title |
| `copyright` | `string` | copyright information or ownership details |
| `title` | `string` | title of the track or media file |
| `artist` | `string` | artist or creator name |
| `track` | `string` | track number or position in album |
| `date` | `string` | release date or creation date |
#### audio object
| key | type | value |
|:----------|:----------|:-------------------------------------------|
| `copy` | `boolean` | defines whether audio codec data is copied |
| `format` | `string` | output audio format |
| `bitrate` | `string` | preferred bitrate of audio format |
### picker response
| key | type | values |
|:----------------|:---------|:-------------------------------------------------------------------------------------------------|
| `status` | `string` | `picker` |
| `audio` | `string` | **optional** returned when an image slideshow (such as on tiktok) has a general background audio |
| `audioFilename` | `string` | **optional** cobalt-generated filename, returned if `audio` exists |
| `picker` | `array` | array of objects containing the individual media |
| key | type | value |
|:----------------|:---------|:-----------------------------------------------------------------------------------------------|
| `status` | `string` | `picker` |
| `audio` | `string` | returned when an image slideshow (such as on tiktok) has a general background audio (optional) |
| `audioFilename` | `string` | cobalt-generated filename, returned if `audio` exists (optional) |
| `picker` | `array` | array of objects containing the individual media |
#### picker object
| key | type | values |
|:-------------|:----------|:------------------------------------------------------------|
| `type` | `string` | `photo` / `video` / `gif` |
| `url` | `string` | |
| `thumb` | `string` | **optional** thumbnail url |
| key | type | value |
|:-------------|:----------|:--------------------------|
| `type` | `string` | `photo` / `video` / `gif` |
| `url` | `string` | |
| `thumb` | `string` | thumbnail url (optional) |
### error response
| key | type | values |
|:-------------|:---------|:------------------------------------------------------------|
| `status` | `string` | `error` |
| `error` | `object` | contains more context about the error |
| key | type | value |
|:-------------|:---------|:------------------------------|
| `status` | `string` | `error` |
| `error` | `object` | error code & optional context |
#### error object
| key | type | values |
|:-------------|:---------|:------------------------------------------------------------|
| `code` | `string` | machine-readable error code explaining the failure reason |
| `context` | `object` | **optional** container for providing more context |
| key | type | value |
|:-------------|:---------|:----------------------------------------------------------|
| `code` | `string` | machine-readable error code explaining the failure reason |
| `context` | `object` | additional error context (optional) |
#### error.context object
| key | type | values |
|:-------------|:---------|:---------------------------------------------------------------------------------------------------------------|
| `service` | `string` | **optional**, stating which service was being downloaded from |
| `limit` | `number` | **optional** number providing the ratelimit maximum number of requests, or maximum downloadable video duration |
## GET: `/`
returns current basic server info.
response body type: `application/json`
### response body
| key | type | variables |
|:------------|:---------|:---------------------------------------------------------|
| `cobalt` | `object` | information about the cobalt instance |
| `git` | `object` | information about the codebase that is currently running |
#### cobalt object
| key | type | description |
|:----------------|:-----------|:-----------------------------------------------|
| `version` | `string` | current version |
| `url` | `string` | server url |
| `startTime` | `string` | server start time in unix milliseconds |
| `durationLimit` | `number` | maximum downloadable video length in seconds |
| `services` | `string[]` | array of services which this instance supports |
#### git object
| key | type | variables |
|:------------|:---------|:------------------|
| `commit` | `string` | commit hash |
| `branch` | `string` | git branch |
| `remote` | `string` | git remote |
## POST: `/session`
| key | type | value |
|:-------------|:---------|:----------------------------------------------------------------------------|
| `service` | `string` | origin service (optional) |
| `limit` | `number` | the maximum downloadable video duration or the rate limit window (optional) |
## POST `/session`
used for generating JWT tokens, if enabled. currently, cobalt only supports
generating tokens when a [turnstile](run-an-instance.md#list-of-all-environment-variables) challenge solution
is submitted by the client.
the turnstile challenge response is submitted via the `cf-turnstile-response` header.
### response body
| key | type | description |
|:----------------|:-----------|:-------------------------------------------------------|
@ -159,3 +192,48 @@ the turnstile challenge response is submitted via the `cf-turnstile-response` he
| `exp` | `number` | number in seconds indicating the token lifetime |
on failure, an [error response](#error-response) is returned.
## GET `/`
provides basic instance info.
### response
body type: `application/json`
| key | type | description |
|:------------|:---------|:---------------------------------------------------------|
| `cobalt` | `object` | information about the cobalt instance |
| `git` | `object` | information about the codebase that is currently running |
#### cobalt object
| key | type | description |
|:-------------------|:-----------|:-----------------------------------------------|
| `version` | `string` | cobalt version |
| `url` | `string` | instance url |
| `startTime` | `string` | instance start time in unix milliseconds |
| `turnstileSitekey` | `string` | site key for a turnstile widget (optional) |
| `services` | `string[]` | array of services which this instance supports |
#### git object
| key | type | description |
|:------------|:---------|:------------|
| `commit` | `string` | commit hash |
| `branch` | `string` | git branch |
| `remote` | `string` | git remote |
## GET `/tunnel`
endpoint for file tunnels (proxy/remux/transcode). the response is a file stream. all errors are reported via
[HTTP status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status).
### returned headers
- `Content-Length`: file size, in bytes. returned when exact final file size is known.
- `Estimated-Content-Length`: estimated file size, in bytes. returned when real `Content-Length` is not known.
a rough estimate which should NOT be used for strict size verification.
can be used to show approximate download progress in UI.
### possible HTTP status codes
- 200: OK
- 401: Unauthorized
- 403: Bad Request
- 404: Not Found
- 429: Too Many Requests (rate limit exceeded, check [RateLimit-* headers](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html#name-header-specifications))
- 500: Internal Server Error.

View File

@ -1,11 +1,11 @@
services:
cobalt-api:
image: ghcr.io/imputnet/cobalt:10
cobalt:
image: ghcr.io/imputnet/cobalt:11
init: true
read_only: true
restart: unless-stopped
container_name: cobalt-api
container_name: cobalt
ports:
- 9000:9000/tcp

547
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -3,22 +3,35 @@ the cobalt frontend is a static web app built with
[sveltekit](https://kit.svelte.dev/) + [vite](https://vitejs.dev/).
## configuring
- to run a dev environment, run `pnpm run dev`.
- to make a release build of the frontend, run `pnpm run build`.
- to run the dev environment, run `pnpm run dev`.
- to make the release build of the frontend, run `pnpm run build`.
## environment variables
the frontend has several build-time environment variables for configuring various features. to use
them, you must specify them when building the frontend (or running a vite server for development).
| name | example | description |
|:---------------------|:----------------------------|:---------------------------------------------------------------------------------------------------------|
| `WEB_HOST` | `cobalt.tools` | domain on which the frontend will be running. used for meta tags and configuring plausible. |
| `WEB_PLAUSIBLE_HOST` | `plausible.io`* | enables plausible analytics with provided hostname as receiver backend. |
| `WEB_DEFAULT_API` | `https://api.cobalt.tools/` | changes url which is used for api requests by frontend clients. |
`WEB_DEFAULT_API` is **required** to run cobalt frontend.
| name | example | description |
|:---------------------|:----------------------------|:--------------------------------------------------------------------------------------------|
| `WEB_HOST` | `cobalt.tools` | domain on which the frontend will be running. used for meta tags and configuring plausible. |
| `WEB_PLAUSIBLE_HOST` | `plausible.io`* | enables plausible analytics with provided hostname as receiver backend. |
| `WEB_DEFAULT_API` | `https://api.cobalt.tools/` | changes url which is used for api requests by frontend clients. |
\* don't use plausible.io as receiver backend unless you paid for their cloud service.
use your own domain when hosting community edition of plausible. refer to their [docs](https://plausible.io/docs) when needed.
## link prefill
to prefill the link into the input box & start the download automatically, you can pass the URL in the `#` parameter, like this:
```
https://cobalt.tools/#https://www.youtube.com/watch?v=dQw4w9WgXcQ
```
the link can also be URI-encoded, like this:
```
https://cobalt.tools/#https%3A//www.youtube.com/watch%3Fv=dQw4w9WgXcQ
```
## license
cobalt web code is licensed under [CC-BY-NC-SA-4.0](LICENSE).
@ -38,7 +51,29 @@ you are allowed to host an ***unmodified*** instance of cobalt with branding for
when making an alternative version of the project, please replace or remove all branding (including the name).
## 3rd party licenses
- [Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji) (used in cobalt) is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license.
- [Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) fonts (used in cobalt) are licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license.
## open source acknowledgments
### svelte + sveltekit
the cobalt frontend is built using [svelte](https://svelte.dev) and [sveltekit](https://svelte.dev/docs/kit/introduction), a really efficient and badass framework, we love it a lot.
### libav.js
our remux and encode workers rely on [libav.js](https://github.com/imputnet/libav.js), which is an optimized build of ffmpeg for the browser. the ffmpeg builds are made up of many components, whose licenses can be found here: [encode](https://github.com/imputnet/libav.js/blob/main/configs/configs/encode/license.js), [remux](https://github.com/imputnet/libav.js/blob/main/configs/configs/remux/license.js).
you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
### fonts, icons and assets
the cobalt frontend uses several different fonts and icon sets.
- [Tabler Icons](https://tabler.io/icons), released under the [MIT](https://github.com/tabler/tabler-icons?tab=MIT-1-ov-file) license.
- [Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji), released under the [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license.
- [Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) used for the download button, is licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license.
- [IBM Plex Mono](https://fonts.google.com/specimen/IBM+Plex+Mono/) used for all other text, is licensed under the [OFL](https://fonts.google.com/specimen/IBM+Plex+Mono/license) license.
- and the [Redaction](https://redaction.us/) font, which is licensed under the [OFL](https://github.com/fontsource/font-files/blob/main/fonts/other/redaction-10/LICENSE) license (as well as LGPL-2.1).
- many update banners were taken from [tenor.com](https://tenor.com/).
### other packages
- [mdsvex](https://github.com/pngwn/MDsveX) to convert the changelogs into svelte components.
- [compare-versions](https://github.com/omichelsen/compare-versions) for sorting the changelogs.
- [svelte-sitemap](https://github.com/bartholomej/svelte-sitemap) for generating a sitemap for the frontend.
- [sveltekit-i18n](https://github.com/sveltekit-i18n/lib) for displaying cobalt in many different languages.
- [vite](https://github.com/vitejs/vite) for building the frontend.
...and many other packages that these packages rely on.

View File

@ -31,8 +31,6 @@ we're back to the battlefield against youtube's scraper flattener, but we're win
- rutube video is region locked.
- vk video is region locked.
*~ still reading? that's impressive. ~*
## web app (and ui/ux) improvements
- added support for [instance access keys](/settings/instances#access-key)! now you can access private cobalt instances with no turnstile, directly from the web app.
- redesigned the [remux page](/remux) to indicate better what remuxing does and what it's for.
@ -56,7 +54,6 @@ we're back to the battlefield against youtube's scraper flattener, but we're win
*~ 🦆🔜 ~*
## processing instance improvements
*(mostly nerd talk)*
- added support for one more way of youtube authentication: web cookies with poToken & visitorData, so that everyone can access youtube on their instances again!
- significantly refactored the cookie system for better error resistance.
- added success and error console messages to indicate whether cookies/keys were loaded successfully.

151
web/changelogs/11.0.md Normal file
View File

@ -0,0 +1,151 @@
---
title: "local media processing, better performance, and a lot of polish"
date: "29 May, 2025"
banner:
file: "meowth_beach.webp"
alt: "meowth plush with obnoxious sunglasses on foreground, very close to the camera. sunset and beach in background."
---
long time no see! it's almost summer, the perfect time to create or discover something new. we've been busy working in the background to make cobalt better than ever, but now we're finally ready to share the new major version.
as a part of the major update, we revised our [terms of use](/about/terms) & [privacy policy](/about/privacy) to reflect new privacy-enhancing features & to improve readability; you can compare what exactly changed in [this commit](https://github.com/imputnet/cobalt/commit/be84f66) on github. **nothing changed about our principles or dedication to privacy**, but we still thought it'd be good to let you know.
here are the highlights of what's new in cobalt 11 and what else has changed since the last changelog in december:
## on-device media processing (beta)
cobalt can now perform all media processing tasks *directly in your browser*. we enabled it by default on all desktop browsers & firefox on android, but if you want to try it on your device before we're sure it works the way we expect, you can do it in a new [local processing page in settings](/settings/local)!
here's what it means for you:
- **best file compatibility**, because all processed files now have proper headers. there's **no need to remux anything manually** anymore; all editing software should support cobalt files right away! vegas pro, logic pro, many DAWs, windows media player, whatsapp, etc, — all of them now support files from cobalt *(but only if they were processed on-device)*.
- **detailed progress** of file processing. cobalt now displays all steps and the current progress of all of them. no more guessing when's the file gonna be ready.
- **faster processing** for all tasks that require remuxing or transcoding, such as downloading youtube videos, transcoding audio, muting videos, or converting gifs from twitter.
- **better reliability** of all processing tasks. cobalt can finally catch all processing errors properly, meaning that the corrupted file rate will drop significantly. if anything ever goes wrong, cobalt will let you know, and you'll be able to retry right away!
- **reduced load on public instances**, which makes cobalt faster than ever for everyone. servers will no longer be busy transcoding someone's 10 hour audio of "beats to vibe and study to" — because now their own device is responsible for this work. it's really cool!
we're also introducing the processing queue, which allows you to schedule many tasks at once! it's always present on the screen, in the top right corner. the button for it displays precise progress across all tasks, so you know when tasks are done at a glance.
all processed videos are temporarily stored on your device and are automatically wiped when you reload the page. no need to rush saving them; they'll be there as long as you don't close cobalt or delete them from the queue.
on modern ios (18.0+), we made it possible to download, process, and export giant files. the limit is now your device's storage, so go wild!
processing queue & local processing may not be perfect as-is, so please let us know about any frustrations you face when using them! this is just the beginning of an on-device era of cobalt. we hope to explore even more cool local processing features in the future.
## web app improvements: ui/ux upgrade and svelte 5
aside from local processing, we put in a ton of effort to make the cobalt web app faster and even more comfortable for everyone. we fixed all minor ui nicks, polished it, and improved turnstile behavior!
- **svelte 5:** many parts of cobalt's frontend have been migrated to svelte 5. this is mostly an internal change, but it majorly improves the performance and reduces extra ui renders, making the overall experience snappier.
- **downloading flow:**
- cobalt will now start the downloading task right away, even if turnstile is not finished verifying the browser yet. it will wait for turnstile's solution instead of showing an annoying dialog.
- pressing "paste" before turnstile is finished now starts the download right away.
- prefilled links via url parameters (`#urlhere` or `?u=urlhere`) are now downloaded right away. one less button press!
- replaced an invasive turnstile dialog with a dynamic tooltip.
- ideally, you should no longer know that cloudflare turnstile is even there.
- **remux**:
- remux is now a part of the processing queue! the remux page now serves as an importer. no need to stay on the same page for remux to complete.
- you can now remux several files at once.
- cobalt now automatically filters out unsupported files on import, so you can drag and drop whatever.
- **visuals & animations:**
- the dialog animation & visual effects have been optimized to improve performance. the picker dialog no longer lags like hell!
- all images now fade in smoothly on load.
- the update notification now has a new, springier animation.
- enhanced focus rings across the whole app for better accessibility, a cleaner look, and ease of internal maintenance.
- sidebar is now bright in light theme mode on desktop, and is more visible in dark mode.
- sidebar buttons are now more compact.
- the status bar color on desktop (primarily safari) now adapts to the current theme.
- fixed many various rendering quirks in webkit and blink.
- all input bars are now pressable everywhere.
- popovers (such as supported services & queue) are now rendered only when needed.
- the font on the about/changelog pages is now consistent with the rest of the ui (IBM Plex Mono).
- all image assets have been re-compressed for even faster loading.
- the download button now uses a super tiny custom font instead of a full noto sans mono font.
- countless padding, margin, and alignment tweaks for overall consistency and a fresh vibe.
- **accessibility & usability:**
- created a dedicated [accessibility settings page](/settings/accessibility) and moved relevant settings there.
- improved screen reader accessibility & tab navigation across ui.
- added an option to prevent the processing queue from opening automatically in [accessibility settings](/settings/accessibility#behavior).
- files now save properly on desktop in pwa mode (when using local processing).
- **ios-specific improvements**:
- added haptic feedback to toggles, switchers, buttons, dropdowns, and error dialogs. not a fan of haptics? disable them in [accessibility settings](/settings/accessibility/haptics).
- made it possible to process giant files without crashing on ios 18.0+. the cobalt tab/pwa no longer crashes if a file is too big for safari to handle. *(previously anything >384mb lol)*
- improved file saving, now cobalt selects the most comfortable way to save a file automatically.
- **settings page:**
- sensitive inputs (like api keys) are now hidden by default with an option to reveal them.
- added an option to hide the remux tab on mobile devices in [appearance settings](/settings/appearance#navigation).
- filename previews in settings now more accurately reflect the actual output.
- improved the toggle animation.
- redesigned settings page icons.
- updated some descriptions to be more accurate.
- all [about](/about) pages have been revised for improved readability and clarity.
- the web instance now requires the `WEB_DEFAULT_API` env variable to run. it's enforced to avoid any confusion.
- the plausible script is no longer loaded when anonymous analytics are disabled.
## general improvements
- filenames can now include a wider range of characters, thanks to relaxed sanitization & use of fullwidth replacements.
- "basic" is now the default filename style.
## processing instance improvements
- env variables can now be loaded & updated dynamically. this allows for configuration changes without downtime!
- tunnels now provide an `Estimated-Content-Length` header when exact file size isn't available.
- many internal tunnel improvements.
- the api now returns a `429` http status code when rate limits are hit.
- the `allowH265` (formerly `tiktokH265`) and `convertGif` (formerly `twitterGif`) api parameters have been renamed for clarity as they apply (or will apply) to more services.
- added a bunch of new [api instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md): `FORCE_LOCAL_PROCESSING`, `API_ENV_FILE`, `SESSION_RATELIMIT_WINDOW`, `SESSION_RATELIMIT`, `TUNNEL_RATELIMIT_WINDOW`, `TUNNEL_RATELIMIT`, `CUSTOM_INNERTUBE_CLIENT`, `YOUTUBE_SESSION_SERVER`, `YOUTUBE_SESSION_INNERTUBE_CLIENT`, `YOUTUBE_ALLOW_BETTER_AUDIO`.
## youtube improvements
- added a new option in [audio settings](/settings/audio#youtube-better-audio) to prefer better audio quality from youtube when available.
- near infinite amount of changes and improvements on cobalt & infrastructure levels to improve reliability and recover youtube functionality.
- cobalt now returns a more appropriate error message if a youtube video is locked behind drm.
- added itunnel transplating to allow in-place tunnel resume after an [intentional] error from origin's side.
## other service improvements
- added support for **xiaohongshu**.
- **twitter:**
- added support for saving media from ad cards.
- added fallback to the syndication api for better reliability due to constant twitter downtimes & lockdowns.
- **reddit:**
- expanded support for various link types, including mobile (e.g., `m.reddit.com`) and many other short link formats.
- **instagram:**
- added support for more links, including the new `share` format.
- implemented more specific errors for age-restricted and private content.
- fixed an issue where posts might have not correctly fallen back to a photo if a video URL was missing.
- **tiktok:**
- added support for tiktok lite urls.
- fixed parsing of some mobile tiktok links.
- updated the primary tiktok domain used by the api due to previous dns issues.
- **snapchat:**
- fixed an issue where story extraction could fail if certain profile parameters were missing.
- added support for new link patterns.
- **pinterest:**
- fixed video parsing for certain types of pins.
- **bluesky:**
- added support for downloading tenor gifs from bluesky posts.
- **odnoklassniki (ok):**
- fixed an issue where author information wasn't handled properly.
- **loom:**
- added support for links with video titles.
- fixed support for more video types.
- **facebook:**
- fixed issues caused by rate limiting.
## documentation improvements
- created a new document for [api instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md) with detailed & up-to-date info about each variable.
- rewrote [api docs](https://github.com/imputnet/cobalt/blob/main/docs/api.md) to be easier to read and added all new & previously missing info.
- updated the list of dependencies & open-source shoutouts in [api](https://github.com/imputnet/cobalt/blob/main/api/README.md) & [web](https://github.com/imputnet/cobalt/blob/main/web/README.md) readme docs.
- added an example for setting up `yt-session-generator` in the docker compose documentation.
- updated the "run an instance" guide with a more prominent note about abuse prevention.
## more internal improvements
- introduced an abstract storage class and implemented opfs (origin private file system) and memory storage backends. this is the foundation of the new local processing features, and makes it possible to operate on devices with low RAM.
- session tokens are now bound to ip hashes, locking down their usage and improving security.
- lots of other refactoring and code cleanups across both api and web components.
- numerous test fixes, additions, and ci pipeline improvements.
- removed unused packages & updated many dependencies.
## all changes are on github
like always, you can check [all commits since the 10.5 release on github](https://github.com/imputnet/cobalt/compare/41430ff...main) for even more details, if you're curious.
this update was made with a lot of love and care, so we hope you enjoy it as much as we enjoyed making it.
that's all for now, we wish you an amazing summer!
\~ your friends at imput ❤️

View File

@ -0,0 +1,5 @@
{
"status.default": "processing queue",
"status.completed": "processing queue. all tasks are completed.",
"status.ongoing": "processing queue. ongoing tasks."
}

View File

@ -11,9 +11,9 @@
"heading.general": "general terms",
"heading.licenses": "licenses",
"heading.summary": "best way to save what you love",
"heading.privacy": "leading privacy",
"heading.privacy_efficiency": "leading privacy & efficiency",
"heading.community": "open community",
"heading.local": "on-device processing",
"heading.local": "local processing",
"heading.saving": "saving",
"heading.encryption": "encryption",
"heading.plausible": "anonymous traffic analytics",
@ -22,6 +22,7 @@
"heading.abuse": "reporting abuse",
"heading.motivation": "motivation",
"heading.testers": "beta testers",
"heading.partners": "partners",
"support.github": "check out cobalt's source code, contribute changes, or report issues",
"support.discord": "chat with the community and developers about cobalt or ask for help",

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { contacts, docs } from "$lib/env";
import { contacts, docs, partners } from "$lib/env";
import { t } from "$lib/i18n/translations";
import SectionHeading from "$components/misc/SectionHeading.svelte";
@ -12,9 +12,10 @@
sectionId="imput"
/>
cobalt is made with love and care by the [imput](https://imput.net/) research and development team.
cobalt is made with love and care by [imput](https://imput.net/) ❤️
you can support us on the [donate page](/donate)!
we're a small team of two guys, but we work really hard to make great software that benefits everyone.
if you like our work, please consider supporting it on the [donate page](/donate)!
</section>
<section id="testers">
@ -23,27 +24,46 @@ you can support us on the [donate page](/donate)!
sectionId="testers"
/>
huge shoutout to our thing breakers for testing updates early and making sure they're stable.
huge shout-out to our testers for testing updates early and making sure they're stable.
they also helped us ship cobalt 10!
<BetaTesters />
all links are external and lead to their personal websites or social media.
</section>
<section id="partners">
<SectionHeading
title={$t("about.heading.partners")}
sectionId="partners"
/>
a portion of cobalt's processing infrastructure
is provided by our long-standing partner, [royalehosting.net]({partners.royalehosting})!
</section>
<section id="meowbalt">
<SectionHeading
title={$t("general.meowbalt")}
sectionId="meowbalt"
/>
meowbalt is cobalt's speedy mascot. he is an extremely expressive cat that loves fast internet.
meowbalt is cobalt's speedy mascot, a very expressive cat who loves fast internet.
all amazing drawings of meowbalt that you see in cobalt were made by [GlitchyPSI](https://glitchypsi.xyz/).
he is also the original designer of the character.
all amazing art of meowbalt that you see in cobalt
was made by [GlitchyPSI](https://glitchypsi.xyz/).
he's also the original creator of the character.
you cannot use or modify GlitchyPSI's artworks of meowbalt without his explicit permission.
imput holds legal rights to meowbalt's character design,
but not specific artworks that were created by GlitchyPSI.
you cannot use or modify the meowbalt character design commercially or in any form that isn't fan art.
we love meowbalt, so we have to set a few rules in place to protect him:
- you cannot use meowbalt's character design in any form that isn't fan art.
- you cannot use meowbalt's design or artworks commercially.
- you cannot use meowbalt's design or artworks in your own projects.
- you cannot use or modify GlitchyPSI's artworks of meowbalt in any form.
if you create fan art of meowbalt, please share it in
[our discord server](/about/community), we'd love to see it!
</section>
<section id="licenses">
@ -52,12 +72,14 @@ you cannot use or modify the meowbalt character design commercially or in any fo
sectionId="licenses"
/>
cobalt processing server is open source and licensed under [AGPL-3.0]({docs.apiLicense}).
cobalt api (processing server) code is open source and licensed under [AGPL-3.0]({docs.apiLicense}).
cobalt frontend is [source first](https://sourcefirst.com/) and licensed under [CC-BY-NC-SA 4.0]({docs.webLicense}).
we decided to use this license to stop grifters from profiting off our work
cobalt frontend code is [source first](https://sourcefirst.com/) and is licensed under [CC-BY-NC-SA 4.0]({docs.webLicense}).
we had to make frontend source first to stop grifters from profiting off our work
& from creating malicious clones that deceive people and hurt our public identity.
other than commercial use, it follows same principles as many open source licenses.
we rely on many open source libraries, create & distribute our own.
you can see the full list of dependencies on [github]({contacts.github}).
we rely on many open source libraries, but also create & distribute our own.
you can see the full list of dependencies on [github]({contacts.github})!
</section>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import { partners, contacts, docs } from "$lib/env";
import { contacts, docs } from "$lib/env";
import SectionHeading from "$components/misc/SectionHeading.svelte";
</script>
@ -22,27 +22,27 @@ no ads, trackers, paywalls, or other nonsense. just a convenient web app that wo
sectionId="motivation"
/>
cobalt was created for public benefit, to protect people from ads and malware pushed by its alternatives.
we believe that the best software is safe, open, and accessible.
a part of our infrastructure is provided by our long-standing partner, [royalehosting.net]({partners.royalehosting})!
cobalt was created for public benefit, to protect people from ads and malware pushed by alternative downloaders.
we believe that the best software is safe, open, and accessible. all imput project follow these basic principles.
</section>
<section id="privacy">
<section id="privacy-efficiency">
<SectionHeading
title={$t("about.heading.privacy")}
sectionId="privacy"
title={$t("about.heading.privacy_efficiency")}
sectionId="privacy-efficiency"
/>
all requests to the backend are anonymous and all information about tunnels is encrypted.
we have a strict zero log policy and don't track *anything* about individual people.
all requests to the backend are anonymous and all information about potential file tunnels is encrypted.
we have a strict zero log policy and don't store or track *anything* about individual people.
when a request needs additional processing, cobalt processes files on-the-fly.
it's done by tunneling processed parts directly to the client, without ever saving anything to disk.
for example, this method is used when the source service provides video and audio channels as separate files.
if a request requires additional processing, such as remuxing or transcoding, cobalt processes media
directly on your device. this ensures best efficiency and privacy.
additionally, you can [enable forced tunneling](/settings/privacy#tunnel) to protect your privacy.
when enabled, cobalt will tunnel all downloaded files.
if your device doesn't support local processing, then server-based live processing is used instead.
in this scenario, processed media is streamed directly to client, without ever being stored on server's disk.
you can [enable forced tunneling](/settings/privacy#tunnel) to boost privacy even further.
when enabled, cobalt will tunnel all downloaded files, not just those that require it.
no one will know where you download something from, even your network provider.
all they'll see is that you're using a cobalt instance.
</section>
@ -65,14 +65,3 @@ if your friend hosts a processing instance, just ask them for a domain and [add
you can check the source code and contribute [on github]({contacts.github}) at any time.
we welcome all contributions and suggestions!
</section>
<section id="local">
<SectionHeading
title={$t("about.heading.local")}
sectionId="local"
/>
newest features, such as [remuxing](/remux), work locally on your device.
on-device processing is efficient and never sends anything over the internet.
it perfectly aligns with our future goal of moving as much processing as possible to the client.
</section>

View File

@ -11,9 +11,11 @@
sectionId="general"
/>
cobalt's privacy policy is simple: we don't collect or store anything about you. what you do is solely your business, not ours or anyone else's.
cobalt's privacy policy is simple: we don't collect or store anything about you.
what you do is solely your business, not ours or anyone else's.
these terms are applicable only when using the official cobalt instance. in other cases, you may need to contact the hoster for accurate info.
these terms are applicable only when using the official cobalt instance.
in other cases, you may need to contact the instance hoster for accurate info.
</section>
<section id="local">
@ -22,7 +24,9 @@ these terms are applicable only when using the official cobalt instance. in othe
sectionId="local"
/>
tools that use on-device processing work offline, locally, and never send any data anywhere. they are explicitly marked as such whenever applicable.
tools that use on-device processing work offline, locally,
and never send any processed data anywhere.
they are explicitly marked as such whenever applicable.
</section>
<section id="saving">
@ -31,9 +35,33 @@ tools that use on-device processing work offline, locally, and never send any da
sectionId="saving"
/>
when using saving functionality, in some cases cobalt will encrypt & temporarily store information needed for tunneling. it's stored in processing server's RAM for 90 seconds and irreversibly purged afterwards. no one has access to it, even instance owners, as long as they don't modify the official cobalt image.
when using saving functionality, cobalt may need to proxy or remux/transcode files.
if that's the case, then a temporary tunnel is created for this purpose
and minimal required information about the media is stored for 90 seconds.
processed/tunneled files are never cached anywhere. everything is tunneled live. cobalt's saving functionality is essentially a fancy proxy service.
on an unmodified & official cobalt instance,
**all tunnel data is encrypted with a key that only the end user has access to**.
encrypted tunnel data may include:
- origin service's name.
- original URLs for media files.
- internal arguments needed to differentiate between types of processing.
- minimal file metadata (generated filename, title, author, creation year, copyright info).
- minimal information about the original request that may be used in case of an URL failure during the tunnelling process.
this data is irreversibly purged from server's RAM after 90 seconds.
no one has access to cached tunnel data, even instance owners,
as long as cobalt's source code is not modified.
media data from tunnels is never stored/cached anywhere.
everything is processed live, even during remuxing and transcoding.
cobalt tunnels function like an anonymous proxy.
if your device supports local processing,
then encrypted tunnel info includes way less info, because it's returned to client instead.
see the [related source code on github](https://github.com/imputnet/cobalt/tree/main/api/src/stream)
to learn more about how it works.
</section>
<section id="encryption">
@ -42,7 +70,10 @@ processed/tunneled files are never cached anywhere. everything is tunneled live.
sectionId="encryption"
/>
temporarily stored tunnel data is encrypted using the AES-256 standard. decryption keys are only included in the access link and never logged/cached/stored anywhere. only the end user has access to the link & encryption keys. keys are generated uniquely for each requested tunnel.
temporarily stored tunnel data is encrypted using the AES-256 standard.
decryption keys are only included in the access link and never logged/cached/stored anywhere.
only the end user has access to the link & encryption keys.
keys are generated uniquely for each requested tunnel.
</section>
{#if env.PLAUSIBLE_ENABLED}
@ -52,13 +83,18 @@ temporarily stored tunnel data is encrypted using the AES-256 standard. decrypti
sectionId="plausible"
/>
for sake of privacy, we use [plausible's anonymous traffic analytics](https://plausible.io/) to get an approximate number of active cobalt users. no identifiable information about you or your requests is ever stored. all data is anonymized and aggregated. the plausible instance we use is hosted & managed by us.
we use [plausible](https://plausible.io/) for anonymous traffic analytics,
to get an approximate number of active cobalt users.
no identifiable information about you or your requests is ever stored.
all data is anonymized and aggregated.
we self-host and manage the plausible instance that cobalt uses.
plausible doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.
[learn more about plausible's dedication to privacy.](https://plausible.io/privacy-focused-web-analytics)
if you wish to opt out of anonymous analytics, you can do it in [privacy settings](/settings/privacy#analytics).
if you opt out, the plausible script will not be loaded at all.
[learn more about plausible's dedication to privacy](https://plausible.io/privacy-focused-web-analytics).
</section>
{/if}
@ -68,9 +104,15 @@ if you wish to opt out of anonymous analytics, you can do it in [privacy setting
sectionId="cloudflare"
/>
we use cloudflare services for ddos & bot protection. we also use cloudflare pages for deploying & hosting the static web app. all of these are required to provide the best experience for everyone. it's the most private & reliable provider that we know of.
we use cloudflare services for:
- ddos & abuse protection.
- bot protection (cloudflare turnstile).
- hosting & deploying the statically rendered web app (cloudflare pages).
all of these are required to provide the best experience for everyone.
cloudflare is the most private & reliable provider for all mentioned solutions that we know of.
cloudflare is fully compliant with GDPR and HIPAA.
[learn more about cloudflare's dedication to privacy.](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/)
[learn more about cloudflare's dedication to privacy](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/).
</section>

View File

@ -10,7 +10,7 @@
/>
these terms are applicable only when using the official cobalt instance.
in other cases, you may need to contact the hoster for accurate info.
in other cases, you may need to contact the instance hoster for accurate info.
</section>
<section id="saving">
@ -19,12 +19,14 @@ in other cases, you may need to contact the hoster for accurate info.
sectionId="saving"
/>
saving functionality simplifies downloading content from the internet and takes zero liability for what the saved content is used for.
processing servers work like advanced proxies and don't ever write any content to disk.
everything is handled in RAM and permanently purged once the tunnel is done.
we have no downloading logs and can't identify anyone.
saving functionality simplifies downloading content from the internet
and we take zero liability for what the saved content is used for.
[you can read more about how tunnels work in our privacy policy.](/about/privacy)
processing servers operate like advanced proxies and don't ever write any requested content to disk.
everything is handled in RAM and permanently purged once the tunnel is completed.
we have no downloading logs and cannot identify anyone.
you can learn more about how tunnels work in [privacy policy](/about/privacy).
</section>
<section id="responsibility">
@ -48,10 +50,10 @@ fair use and credits benefit everyone.
sectionId="abuse"
/>
we have no way of detecting abusive behavior automatically because cobalt is 100% anonymous.
we have no way of detecting abusive behavior automatically because cobalt is fully anonymous.
however, you can report such activities to us via email and we'll do our best to comply manually: abuse[at]imput.net
**this email is not intended for user support, you will not get a response if your concern is not related to abuse.**
if you're experiencing issues, contact us via any preferred method on [the support page](/about/community).
if you're experiencing issues, you can reach out for support via any preferred method on [the community page](/about/community).
</section>

View File

@ -16,5 +16,14 @@
"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",
"remove": "remove",
"retry": "retry",
"delete": "delete"
}

View File

@ -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."
}

View File

@ -3,71 +3,9 @@
"import.invalid": "this file doesn't have valid cobalt settings to import. are you sure it's the right one?",
"import.unknown": "couldn't load data from the file. it may be corrupted or of wrong format. here's the error i got:\n\n{{ value }}",
"remux.corrupted": "couldn't read the metadata from this file, it may be corrupted.",
"remux.out_of_resources": "cobalt ran out of resources and can't continue with on-device processing. this is caused by your browser's limitations. refresh or reopen the app and try again!",
"tunnel.probe": "couldn't test this tunnel. your browser or network configuration may be blocking access to one of cobalt servers. are you sure you don't have any weird browser extensions?",
"captcha_ongoing": "cloudflare turnstile is still checking if you're not a bot. if it takes too long, you can try: disabling weird browser extensions, changing networks, using a different browser, or checking your device for malware.",
"captcha_too_long": "cloudflare turnstile is taking too long to check if you're not a bot. try again, but if it takes way too long again, you can try: disabling weird browser extensions, changing networks, using a different browser, or checking your device for malware.",
"api.auth.jwt.missing": "couldn't authenticate with the processing instance because the access token is missing. try again in a few seconds or reload the page!",
"api.auth.jwt.invalid": "couldn't authenticate with the processing instance because the access token is invalid. try again in a few seconds or reload the page!",
"api.auth.turnstile.missing": "couldn't authenticate with the processing instance because the captcha solution is missing. try again in a few seconds or reload the page!",
"api.auth.turnstile.invalid": "couldn't authenticate with the processing instance because the captcha solution is invalid. try again in a few seconds or reload the page!",
"api.auth.key.missing": "an access key is required to use this processing instance but it's missing. add it in instance settings!",
"api.auth.key.not_api_key": "an access key is required to use this processing instance but it's missing. add it in instance settings!",
"api.auth.key.invalid": "the access key is invalid. reset it in instance settings and use a proper one!",
"api.auth.key.not_found": "the access key you used couldn't be found. are you sure this instance has your key?",
"api.auth.key.invalid_ip": "your ip address couldn't be parsed. something went very wrong. report this issue!",
"api.auth.key.ip_not_allowed": "your ip address is not allowed to use this access key. use a different instance or network!",
"api.auth.key.ua_not_allowed": "your user agent is not allowed to use this access key. use a different client or device!",
"api.unreachable": "couldn't connect to the processing instance. check your internet connection and try again!",
"api.timed_out": "the processing instance took too long to respond. it may be overwhelmed at the moment, try again in a few seconds!",
"api.rate_exceeded": "you're making too many requests. try again in {{ limit }} seconds.",
"api.capacity": "cobalt is at capacity and can't process your request at the moment. try again in a few seconds!",
"api.generic": "something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!",
"api.unknown_response": "couldn't read the response from the processing instance. this is probably caused by the web app being out of date. reload the app and try again!",
"api.invalid_body": "couldn't send the request to the processing instance. this is probably caused by the web app being out of date. reload the app and try again!",
"api.service.unsupported": "this service is not supported yet. have you pasted the right link?",
"api.service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!",
"api.service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!",
"api.link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?",
"api.link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?",
"api.fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!",
"api.fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!",
"api.fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?",
"api.fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!",
"api.fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!",
"api.content.too_long": "media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!",
"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.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!",
"api.content.paid": "this content requires purchase. cobalt can't download paid content. try a different link!",
"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.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.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!",
"api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!",
"api.youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!",
"api.youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!"
"pipeline.missing_response_data": "the processing instance didn't return required file info, so i can't create a local processing pipeline for you. try again in a few seconds and report the issue if it sticks!"
}

View File

@ -0,0 +1,62 @@
{
"auth.jwt.missing": "couldn't authenticate with the processing instance because the access token is missing. try again in a few seconds or reload the page!",
"auth.jwt.invalid": "couldn't authenticate with the processing instance because the access token is invalid. try again in a few seconds or reload the page!",
"auth.turnstile.missing": "couldn't authenticate with the processing instance because the captcha solution is missing. try again in a few seconds or reload the page!",
"auth.turnstile.invalid": "couldn't authenticate with the processing instance because the captcha solution is invalid. try again in a few seconds or reload the page!",
"auth.key.missing": "an access key is required to use this processing instance but it's missing. add it in instance settings!",
"auth.key.not_api_key": "an access key is required to use this processing instance but it's missing. add it in instance settings!",
"auth.key.invalid": "the access key is invalid. reset it in instance settings and use a proper one!",
"auth.key.not_found": "the access key you used couldn't be found. are you sure this instance has your key?",
"auth.key.invalid_ip": "your ip address couldn't be parsed. something went very wrong. report this issue!",
"auth.key.ip_not_allowed": "your ip address is not allowed to use this access key. use a different instance or network!",
"auth.key.ua_not_allowed": "your user agent is not allowed to use this access key. use a different client or device!",
"unreachable": "couldn't connect to the processing instance. check your internet connection and try again!",
"timed_out": "the processing instance took too long to respond. it may be overwhelmed at the moment, try again in a few seconds!",
"rate_exceeded": "you're making too many requests. try again in {{ limit }} seconds.",
"capacity": "cobalt is at capacity and can't process your request at the moment. try again in a few seconds!",
"generic": "something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!",
"unknown_response": "couldn't read the response from the processing instance. this is probably caused by the web app being out of date. reload the app and try again!",
"invalid_body": "couldn't send the request to the processing instance. this is probably caused by the web app being out of date. reload the app and try again!",
"service.unsupported": "this service is not supported yet. have you pasted the right link?",
"service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!",
"service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!",
"link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?",
"link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?",
"fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!",
"fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!",
"fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?",
"fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!",
"fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!",
"content.too_long": "media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!",
"content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!",
"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!",
"content.video.private": "this video is private, so i can't access it. change its visibility or try another one!",
"content.video.age": "this video is age-restricted, so i can't access it anonymously. try again or try a different link!",
"content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!",
"content.region": "this content is region locked, and the processing instance is in a different location. try a different link!",
"content.paid": "this content requires purchase. cobalt can't download paid content. try a different link!",
"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!",
"content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!",
"content.post.age": "this post is age-restricted, so i can't access it anonymously. try again or try a different link!",
"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!",
"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!",
"youtube.login": "couldn't get this video because youtube asked the processing instance to prove that it's not a bot. try again in a few seconds, but if it still doesn't work, please report this issue!",
"youtube.token_expired": "couldn't get this video because the youtube token expired and wasn't refreshed. try again in a few seconds, but if it still doesn't work, please report this issue!",
"youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
"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!",
"youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!",
"youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!",
"youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!"
}

View File

@ -0,0 +1,18 @@
{
"no_final_file": "no final file output",
"worker_didnt_start": "couldn't start a processing worker",
"fetch.crashed": "fetch worker crashed, see console for details",
"fetch.bad_response": "couldn't access the file tunnel",
"fetch.no_file_reader": "couldn't write a file to cache",
"fetch.empty_tunnel": "file tunnel is empty, try again",
"fetch.corrupted_file": "file wasn't downloaded fully, try again",
"ffmpeg.probe_failed": "couldn't probe this file, it may be unsupported or corrupted",
"ffmpeg.out_of_memory": "not enough available memory, can't continue",
"ffmpeg.no_input_format": "the file's format isn't supported",
"ffmpeg.no_input_type": "the file's type isn't supported",
"ffmpeg.crashed": "ffmpeg worker crashed, see console for details",
"ffmpeg.no_render": "ffmpeg render is empty, something very odd happened",
"ffmpeg.no_args": "ffmpeg worker didn't get required arguments"
}

16
web/i18n/en/queue.json Normal file
View File

@ -0,0 +1,16 @@
{
"title": "processing queue",
"stub": "nothing here yet, just the two of us.\ntry downloading something!",
"state.waiting": "queued",
"state.retrying": "retrying",
"state.starting": "starting",
"state.starting.fetch": "starting downloading",
"state.starting.remux": "starting remuxing",
"state.starting.encode": "starting transcoding",
"state.running.remux": "remuxing",
"state.running.fetch": "downloading",
"state.running.encode": "transcoding"
}

View File

@ -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 }}."
}

View File

@ -21,5 +21,7 @@
"tutorial.shortcut.photos": "to photos",
"tutorial.shortcut.files": "to files",
"label.community_instance": "community instance"
"label.community_instance": "community instance",
"tooltip.captcha": "cloudflare turnstile is checking if you're not a bot, please wait!"
}

View File

@ -3,10 +3,12 @@
"page.privacy": "privacy",
"page.video": "video",
"page.audio": "audio",
"page.download": "downloading",
"page.metadata": "metadata",
"page.advanced": "advanced",
"page.debug": "info for nerds",
"page.instances": "instances",
"page.local": "local processing",
"page.accessibility": "accessibility",
"section.general": "general",
"section.save": "save",
@ -30,11 +32,11 @@
"video.quality.description": "if preferred video quality isn't available, next best is picked instead.",
"video.youtube.codec": "youtube codec and container",
"video.youtube.codec.description": "h264: best compatibility, average quality. max quality is 1080p. \nav1: best quality and efficiency. supports 8k & HDR. \nvp9: same quality as av1, but file is ~2x bigger. supports 4k & HDR.\n\nav1 and vp9 aren't as widely supported as h264. if av1 or vp9 isn't available, h264 is used instead.",
"video.youtube.codec.description": "h264: best compatibility, average quality. max quality is 1080p. \nav1: best quality and efficiency. supports 8k & HDR. \nvp9: same quality as av1, but file is ~2x bigger. supports 4k & HDR.\n\nav1 and vp9 aren't widely supported, you might have to use additional software to play/edit them. cobalt picks next best codec if preferred one isn't available.",
"video.youtube.hls": "youtube hls formats",
"video.youtube.hls.title": "prefer hls for video & audio",
"video.youtube.hls.description": "files download faster and are less prone to errors or getting abruptly cut off. only h264 and vp9 codecs are available in this mode. original audio codec is aac, it's re-encoded for compatibility, audio quality may be slightly worse than the non-HLS counterpart.\n\nthis option is experimental, it may go away or change in the future.",
"video.youtube.hls.description": "only h264 and vp9 codecs are available in this mode. original audio codec is aac, it's re-encoded for compatibility, audio quality may be slightly worse than the non-HLS counterpart.\n\nthis option is experimental, it may go away or change in the future.",
"video.twitter.gif": "twitter/x",
"video.twitter.gif.title": "convert looping videos to GIF",
@ -61,6 +63,10 @@
"audio.youtube.dub.description": "cobalt will use a dubbed audio track for selected language if it's available. if not, original will be used instead.",
"youtube.dub.original": "original",
"audio.youtube.better_audio": "youtube audio quality",
"audio.youtube.better_audio.title": "prefer better quality",
"audio.youtube.better_audio.description": "cobalt will try to pick highest quality audio in audio mode. it may not be available depending on youtube's response, current traffic, and server status. custom instances may not support this option.",
"audio.tiktok.original": "tiktok",
"audio.tiktok.original.title": "download original sound",
"audio.tiktok.original.description": "cobalt will download the sound from the video without any changes by the post's author.",
@ -72,7 +78,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 +92,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 +124,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 +133,20 @@
"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": "download & process media locally",
"local.saving.description": "when downloading media, remuxing and transcoding will be done on-device instead of the cloud. you'll see detailed progress in the processing queue. processing instances may enforce this feature to save resources.\n\nexclusive on-device 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.",
"tabs": "navigation",
"tabs.hide_remux": "hide the remux tab",
"tabs.hide_remux.description": "if you don't use the remux tool, you can hide it from the navigation bar."
}

View File

@ -1,6 +1,6 @@
{
"name": "@imput/cobalt-web",
"version": "10.9",
"version": "11.0",
"type": "module",
"private": true,
"scripts": {
@ -25,14 +25,14 @@
"homepage": "https://cobalt.tools/",
"devDependencies": {
"@eslint/js": "^9.5.0",
"@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-encode-cli": "6.7.7",
"@imput/libav.js-remux-cli": "^6.5.7",
"@imput/version-info": "workspace:^",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.1",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltejs/kit": "^2.20.7",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tabler/icons-svelte": "3.6.0",
"@types/eslint__js": "^8.42.3",
"@types/fluent-ffmpeg": "^2.1.25",
@ -44,16 +44,16 @@
"glob": "^11.0.0",
"mdsvex": "^0.11.2",
"mime": "^4.0.4",
"svelte": "^4.2.19",
"svelte-check": "^3.6.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.2",
"svelte-sitemap": "2.6.0",
"sveltekit-i18n": "^2.4.2",
"ts-deepmerge": "^7.0.1",
"tslib": "^2.4.1",
"turnstile-types": "^1.2.2",
"typescript": "^5.4.5",
"typescript": "^5.5.0",
"typescript-eslint": "^8.18.0",
"vite": "^5.3.6"
"vite": "^5.4.4"
}
}

474
web/src/app.css Normal file
View File

@ -0,0 +1,474 @@
:root {
--primary: #ffffff;
--secondary: #000000;
--white: #ffffff;
--gray: #75757e;
--red: #ed2236;
--medium-red: #ce3030;
--dark-red: #d61c2e;
--green: #30bd1b;
--blue: #2f8af9;
--magenta: #eb445a;
--purple: #5857d4;
--orange: #f19a38;
--focus-ring: solid 2px var(--blue);
--focus-ring-offset: -2px;
--button: #f4f4f4;
--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 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);
--popup-bg: #f1f1f1;
--popup-stroke: rgba(0, 0, 0, 0.08);
--dialog-backdrop: rgba(255, 255, 255, 0.3);
--sidebar-bg: var(--button);
--sidebar-highlight: var(--secondary);
--sidebar-stroke: rgba(0, 0, 0, 0.04);
--content-border: rgba(0, 0, 0, 0.03);
--content-border-thickness: 1px;
--input-border: #adadb7;
--toggle-bg: var(--input-border);
--toggle-bg-enabled: var(--secondary);
--padding: 12px;
--border-radius: 11px;
--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(
env(safe-area-inset-bottom) - 5px *
sign(env(safe-area-inset-bottom))
)
);
--safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--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) 5%,
rgba(0, 0, 0, 0) 50%,
rgba(0, 0, 0, 0) 95%,
rgba(0, 0, 0, 0.9) 100%
);
--skeleton-gradient: linear-gradient(
90deg,
var(--button-hover),
var(--button),
var(--button-hover)
);
--skeleton-gradient-elevated: linear-gradient(
90deg,
var(--button-elevated),
var(--button-elevated-shimmer),
var(--button-elevated)
);
}
[data-theme="dark"] {
--primary: #000000;
--secondary: #e1e1e1;
--gray: #818181;
--blue: #2a7ce1;
--green: #37aa42;
--button: #191919;
--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 1px var(--button-stroke) inset;
--button-elevated: #282828;
--button-elevated-hover: #2f2f2f;
--button-elevated-press: #343434;
--popover-glow: rgba(135, 135, 135, 0.12);
--popup-bg: #191919;
--popup-stroke: rgba(255, 255, 255, 0.08);
--dialog-backdrop: rgba(0, 0, 0, 0.3);
--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;
--toggle-bg: var(--input-border);
--toggle-bg-enabled: #8a8a8a;
--sidebar-mobile-gradient: linear-gradient(
90deg,
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(
90deg,
var(--button),
var(--button-hover),
var(--button)
);
--skeleton-gradient-elevated: linear-gradient(
90deg,
var(--button-elevated),
var(--button-elevated-hover),
var(--button-elevated)
);
}
/* fall back to less pretty value cuz chrome doesn't support sign() */
[data-chrome="true"] {
--sidebar-height-mobile: calc(50px + env(safe-area-inset-bottom));
}
[data-theme="light"] [data-reduce-transparency="true"] {
--dialog-backdrop: rgba(255, 255, 255, 0.6);
}
[data-theme="dark"] [data-reduce-transparency="true"] {
--dialog-backdrop: rgba(0, 0, 0, 0.5);
}
html,
body {
margin: 0;
height: 100vh;
overflow: hidden;
overscroll-behavior-y: none;
}
* {
font-family: "IBM Plex Mono", monospace;
user-select: none;
scrollbar-width: none;
-webkit-user-select: none;
-webkit-user-drag: none;
-webkit-tap-highlight-color: transparent;
}
::-webkit-scrollbar {
display: none;
}
::selection {
color: var(--primary);
background: var(--secondary);
}
a {
color: inherit;
text-underline-offset: 3px;
-webkit-touch-callout: none;
}
a:visited {
color: inherit;
}
svg,
img {
pointer-events: none;
}
button, .button {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 13px;
gap: 6px;
border: none;
border-radius: var(--border-radius);
font-size: 14.5px;
cursor: pointer;
background-color: var(--button);
color: var(--button-text);
box-shadow: var(--button-box-shadow);
}
:focus-visible {
outline: none;
}
button:focus-visible,
a:focus-visible,
select:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
a:not(.sidebar-tab):not(.subnav-tab):focus-visible {
outline-offset: 3px;
border-radius: 2px;
}
.button.elevated {
background-color: var(--button-elevated);
}
.button.active {
color: var(--primary);
background-color: var(--secondary);
}
/* important is used because active class is toggled by state */
/* and added to the end of the list, taking priority */
.button.active:focus-visible,
a.active:focus-visible {
color: var(--white) !important;
background-color: var(--blue) !important;
}
@media (hover: hover) {
.button:hover {
background-color: var(--button-hover);
}
.button.elevated:not(.color):hover {
background-color: var(--button-elevated-hover);
}
.button.active:not(.color):hover {
background-color: var(--button-active-hover);
}
}
.button:active {
background-color: var(--button-press);
}
.button.elevated:not(.color):active {
background-color: var(--button-elevated-press);
}
.button.elevated {
box-shadow: none;
}
.button.active:not(.color):active {
background-color: var(--button-active-hover);
}
button[disabled] {
cursor: default;
}
/* workaround for typing into inputs being ignored on iPadOS 15 */
input {
user-select: text;
-webkit-user-select: text;
}
.center-column-container {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
}
button {
font-weight: 500;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 500;
margin-block: 0;
}
h1 {
font-size: 24px;
letter-spacing: -1px;
}
h2 {
font-size: 20px;
letter-spacing: -1px;
}
h3 {
font-size: 16px;
}
h4 {
font-size: 14.5px;
}
h5 {
font-size: 12px;
}
h6 {
font-size: 11px;
}
.subtext {
font-size: 12.5px;
font-weight: 500;
color: var(--gray);
line-height: 1.4;
padding: 0 var(--padding);
white-space: pre-line;
user-select: text;
-webkit-user-select: text;
}
.long-text,
.long-text *:not(h1, h2, h3, h4, h5, h6) {
line-height: 1.8;
font-size: 14.5px;
font-family: "IBM Plex Mono", monospace;
user-select: text;
-webkit-user-select: text;
}
.long-text,
.long-text *:not(h1, h2, h3, h4, h5, h6, strong, em, del) {
font-weight: 400;
}
.long-text ul {
padding-inline-start: 30px;
}
.long-text li {
padding-left: 3px;
}
.long-text:not(.about) h1,
.long-text:not(.about) h2,
.long-text:not(.about) h3 {
user-select: text;
-webkit-user-select: text;
letter-spacing: 0;
margin-block-start: 1rem;
}
.long-text h3 {
font-size: 17px;
}
.long-text h2 {
font-size: 19px;
}
.long-text:not(.about) h3 {
margin-block-end: -0.5rem;
}
.long-text:not(.about) h2 {
font-size: 19px;
line-height: 1.3;
margin-block-end: -0.3rem;
padding: 6px 0;
border-bottom: 1.5px solid var(--button-elevated-hover);
}
.long-text img {
border-radius: 6px;
}
table,
td,
th {
border-spacing: 0;
border-style: solid;
border-width: 1px;
border-collapse: collapse;
text-align: center;
padding: 3px 8px;
}
code {
background: var(--button-elevated);
padding: 1px 4px;
border-radius: 4px;
}
tr td:first-child,
tr th:first-child {
text-align: right;
}
.long-text.about section p:first-of-type {
margin-block-start: 0.3em;
}
.long-text.about .heading-container {
padding-top: calc(var(--padding) / 2);
}
.long-text.about section:first-of-type .heading-container {
padding-top: 0;
}
@media screen and (max-width: 535px) {
.long-text,
.long-text *:not(h1, h2, h3, h4, h5, h6) {
font-size: 14px;
}
}
[data-reduce-motion="true"] * {
animation: none !important;
transition: none !important;
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -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">

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -75,11 +75,8 @@
.switcher.big :global(.button) {
width: 100%;
/* [base button height] - ([switcher padding] * [padding factor to accommodate for]) */
height: calc(40px - var(--switcher-padding) * 1.5);
border-radius: calc(var(--border-radius) - var(--switcher-padding));;
}
.switcher.big :global(.button:not(:focus-visible)) {
height: calc(40px - var(--switcher-padding) * 2);
border-radius: calc(var(--border-radius) - var(--switcher-padding));
box-shadow: none;
}
@ -87,12 +84,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>

View File

@ -1,17 +1,22 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, type Snippet } from "svelte";
import type { Optional } from "$lib/types/generic";
import Skeleton from "$components/misc/Skeleton.svelte";
export let version: string;
export let title: string = "";
export let date: string = "";
export let banner: Optional<{ file: string; alt: string }> = undefined;
export let skeleton = false;
type Props = {
version: string;
title?: string;
date?: string;
banner?: Optional<{ file: string; alt: string }>;
skeleton?: boolean;
children?: Snippet
};
let bannerLoaded = false;
const { version, title, date, banner, skeleton, children }: Props = $props();
let bannerLoaded = $state(false);
let hideSkeleton = $state(false);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
@ -26,6 +31,15 @@
].join(" ");
};
const loaded = () => {
bannerLoaded = true;
// remove the skeleton after the image is done fading in
setTimeout(() => {
hideSkeleton = true;
}, 200)
}
onMount(() => {
const to_focus: HTMLElement | null =
document.querySelector("[data-first-focus]");
@ -39,7 +53,6 @@
<div
class="changelog-version"
data-first-focus
data-focus-ring-hidden
tabindex="-1"
>
{version}
@ -47,7 +60,7 @@
<div class="changelog-date">
{#if skeleton}
<Skeleton width="8em" height="16px" />
{:else}
{:else if date}
{formatDate(date)}
{/if}
</div>
@ -60,21 +73,22 @@
</div>
<div class="changelog-content">
{#if banner}
<img
src={`/update-banners/${banner.file}`}
alt={banner.alt}
class:loading={!bannerLoaded}
on:load={() => bannerLoaded = true}
class="changelog-banner"
/>
<Skeleton class="big changelog-banner" hidden={bannerLoaded} />
<div class="changelog-banner-container">
<img
src={`/update-banners/${banner.file}`}
alt={banner.alt}
class:loading={!bannerLoaded}
onload={loaded}
class="changelog-banner"
/>
<Skeleton class="big changelog-banner" hidden={hideSkeleton} />
</div>
{/if}
{#if skeleton}
<Skeleton class="big changelog-banner" width="100%" />
{/if}
<div class="changelog-body long-text-noto">
<div class="changelog-body long-text">
{#if skeleton}
{#each { length: 3 + Math.random() * 5 } as _}
<p>
@ -85,7 +99,7 @@
</p>
{/each}
{:else}
<slot></slot>
{@render children?.()}
{/if}
</div>
</div>
@ -138,19 +152,37 @@
-webkit-user-select: text;
}
:global(.changelog-banner) {
display: block;
.changelog-banner-container {
object-fit: cover;
max-height: 350pt;
min-height: 180pt;
width: 100%;
aspect-ratio: 16/9;
position: relative;
}
:global(.changelog-banner) {
object-fit: cover;
width: 100%;
height: 100%;
aspect-ratio: 16/9;
border-radius: var(--padding);
pointer-events: all;
opacity: 1;
transition: opacity 0.15s;
position: absolute;
z-index: 2;
}
:global(.skeleton.changelog-banner) {
z-index: 1;
position: relative;
}
.changelog-banner.loading {
display: none;
opacity: 0;
}
.changelog-content {

View File

@ -1,17 +1,24 @@
<!-- Workaround for https://github.com/pngwn/MDsveX/issues/116 -->
<script lang="ts" context="module">
<script lang="ts" module>
import a from "$components/misc/OuterLink.svelte";
export { a };
</script>
<script lang="ts">
import ChangelogEntry from "$components/changelog/ChangelogEntry.svelte";
export let version = '';
export let title = '';
export let date = '';
export let banner = undefined;
import type { Snippet } from "svelte";
type Props = {
version: string;
title: string;
date: string;
banner?: any;
children: Snippet;
}
const { version, title, date, banner, children }: Props = $props();
</script>
<ChangelogEntry {version} {title} {date} {banner}>
<slot></slot>
{@render children?.()}
</ChangelogEntry>

View File

@ -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);
}
};

View File

@ -85,6 +85,8 @@
-webkit-backdrop-filter: blur(7px);
opacity: 0;
will-change: opacity;
transition: opacity 0.2s;
}
@ -105,19 +107,21 @@
align-items: center;
background: var(--popup-bg);
box-shadow:
0 0 0 2px var(--popup-stroke) inset,
0 0 60px 10px var(--popup-bg);
box-shadow: 0 0 0 2px var(--popup-stroke) inset;
border-radius: 29px;
filter: drop-shadow(0 0 40px var(--button));
padding: var(--dialog-padding);
position: relative;
will-change: transform;
will-change: transform, opacity, filter;
}
:global(dialog.open .dialog-body) {
animation: modal-in 0.35s;
animation-delay: 0.06s;
animation-fill-mode: backwards;
}
:global(dialog.closing .dialog-body) {
@ -136,7 +140,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;
}
@ -147,11 +155,11 @@
transform: scale(0.8);
opacity: 0;
}
30% {
35% {
opacity: 1;
}
50% {
transform: scale(1.005);
transform: scale(1.01);
}
100% {
transform: scale(1);
@ -177,7 +185,7 @@
1% {
transform: translateY(200px);
}
30% {
35% {
opacity: 1;
}
50% {

View File

@ -1,28 +1,31 @@
<script lang="ts">
import { browser } from "$app/environment";
import SmallDialog from "$components/dialog/SmallDialog.svelte";
</script>
<noscript style="display: contents">
<div id="nojs-ack">
<SmallDialog
id="nojs-dialog"
meowbalt="error"
bodyText={
"cobalt uses javascript for api requests and ui interactions, but it's not available in your browser. "
+ "you can still navigate around cobalt, but most functionality won't work."
}
buttons={[
{
text: "got it",
main: true,
action: () => {},
link: "#nojs-ack"
},
]}
/>
<div id="nojs-dialog-backdrop"></div>
</div>
</noscript>
{#if !browser}
<noscript style="display: contents">
<div id="nojs-ack">
<SmallDialog
id="nojs-dialog"
meowbalt="error"
bodyText={
"cobalt uses javascript for api requests and ui interactions, but it's not available in your browser. "
+ "you can still navigate around cobalt, but most functionality won't work."
}
buttons={[
{
text: "got it",
main: true,
action: () => {},
link: "#nojs-ack"
},
]}
/>
<div id="nojs-dialog-backdrop"></div>
</div>
</noscript>
{/if}
<style>
:global(#nojs-ack) {

View File

@ -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 {
@ -103,15 +106,12 @@
padding: 0;
}
.popup-title:focus-visible {
box-shadow: none !important;
}
.picker-body {
overflow-y: scroll;
display: grid;
justify-items: center;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: var(--picker-item-gap);
}
.three-columns .picker-body {
@ -119,7 +119,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 +133,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,

View File

@ -10,10 +10,17 @@
import IconPhoto from "@tabler/icons-svelte/IconPhoto.svelte";
import IconGif from "@tabler/icons-svelte/IconGif.svelte";
export let item: DialogPickerItem;
export let number: number;
type Props = {
item: DialogPickerItem;
number: number;
};
let imageLoaded = false;
const { item, number }: Props = $props();
const itemType = $derived(item.type ?? "photo");
let imageLoaded = $state(false);
let hideSkeleton = $state(false);
let validUrl = false;
try {
@ -23,12 +30,19 @@
const isTunnel = validUrl && new URL(item.url).pathname === "/tunnel";
$: itemType = item.type ?? "photo";
const loaded = () => {
imageLoaded = true;
// remove the skeleton after the image is done fading in
setTimeout(() => {
hideSkeleton = true;
}, 200)
}
</script>
<button
class="picker-item"
on:click={() => {
onclick={() => {
if (validUrl) {
downloadFile({
url: item.url,
@ -52,23 +66,32 @@
src={item.thumb ?? item.url}
class:loading={!imageLoaded}
class:video-thumbnail={["video", "gif"].includes(itemType)}
on:load={() => (imageLoaded = true)}
onload={loaded}
alt="{$t(`a11y.dialog.picker.item.${itemType}`)} {number}"
/>
<Skeleton class="picker-image elevated" hidden={imageLoaded} />
<Skeleton class="picker-image elevated" hidden={hideSkeleton} />
</button>
<style>
.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;
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
border-radius: inherit;
}
:global(.picker-image) {
display: block;
width: 100%;
height: 100%;
@ -76,11 +99,22 @@
pointer-events: all;
object-fit: cover;
border-radius: calc(var(--border-radius) / 2);
border-radius: inherit;
position: absolute;
z-index: 2;
opacity: 1;
transition: opacity 0.2s;
}
:global(.skeleton.picker-image) {
z-index: 1;
position: relative;
}
.picker-image.loading {
display: none;
opacity: 0;
}
.picker-image.video-thumbnail {
@ -88,12 +122,12 @@
}
:global(.picker-item:active .picker-image) {
opacity: 0.7;
opacity: 0.75;
}
@media (hover: hover) {
:global(.picker-item:hover .picker-image) {
opacity: 0.7;
:global(.picker-item:hover:not(:active) .picker-image) {
opacity: 0.8;
}
}
@ -103,7 +137,7 @@
background: rgba(0, 0, 0, 0.5);
width: 24px;
height: 24px;
z-index: 9;
z-index: 3;
display: flex;
flex-direction: row;

View File

@ -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") : ""}
>
@ -144,10 +148,6 @@
gap: var(--padding);
}
.dialog-inner-container:focus-visible {
box-shadow: none!important;
}
.dialog-inner-container {
overflow-y: scroll;
gap: 8px;
@ -187,10 +187,6 @@
font-size: 19px;
}
.popup-title:focus-visible {
box-shadow: none !important;
}
.action-buttons {
display: flex;
flex-direction: row;

View File

@ -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,13 @@
export let leftAligned = false;
let close: () => void;
// error meowbalt art is not used in dialogs unless it's an error
if (meowbalt === "error") {
setTimeout(() => {
hapticError();
}, 150)
}
</script>
<DialogContainer {id} {dismissable} bind:close>
@ -31,7 +39,10 @@
>
{#if meowbalt}
<div class="meowbalt-container">
<Meowbalt emotion={meowbalt} />
<Meowbalt
emotion={meowbalt}
forceLoaded={id === 'nojs-dialog'}
/>
</div>
{/if}
<div class="dialog-inner-container">
@ -123,11 +134,6 @@
-webkit-user-select: text;
}
.body-text:focus-visible,
.popup-title:focus-visible {
box-shadow: none !important;
}
.popup-subtext {
opacity: 0.7;
padding: 0;

View File

@ -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;
}

View File

@ -18,7 +18,6 @@
class="redaction"
tabindex="-1"
data-first-focus
data-focus-ring-hidden
>
{$t("donate.banner.title")}
</div>

View File

@ -43,26 +43,27 @@
letter-spacing: -0.3px;
}
:global(.donate-card button:not(:focus-visible)) {
:global(.donate-card button) {
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.selected) {
background: rgba(255, 255, 255, 0.15);
:global(.donate-card button:active:not(.selected):not(.scroll-button)) {
background: rgba(255, 255, 255, 0.125);
}
:global(.donate-card button.selected:not(:focus-visible)) {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1) inset !important;
:global(.donate-card button.selected) {
background: rgba(255, 255, 255, 0.15);
cursor: default;
}
:global(.donate-card button.selected) {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1) inset;
}
:global(.donate-card-subtitle) {

View File

@ -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,
@ -136,12 +136,7 @@
</button>
</div>
<div
id="donation-options-container"
class:mask-both={!device.is.mobile && showLeftScroll && showRightScroll}
class:mask-left={!device.is.mobile && showLeftScroll && !showRightScroll}
class:mask-right={!device.is.mobile && showRightScroll && !showLeftScroll}
>
<div id="donation-options-container">
{#if !device.is.mobile}
<div id="donation-options-scroll" aria-hidden="true">
<button
@ -168,6 +163,9 @@
<div
id="donation-options"
bind:this={donateList}
class:mask-both={!device.is.mobile && showLeftScroll && showRightScroll}
class:mask-left={!device.is.mobile && showLeftScroll && !showRightScroll}
class:mask-right={!device.is.mobile && showRightScroll && !showLeftScroll}
on:wheel={() => {
const currentPos = donateList.scrollLeft;
const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width - 5;
@ -285,10 +283,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 {
@ -326,17 +331,12 @@
opacity: 0.5;
}
#donation-custom-input:focus-visible {
box-shadow: unset !important;
}
#input-container.focused {
box-shadow: 0 0 0 2px var(--white) inset;
}
#donation-custom-submit {
color: var(--white);
background-color: rgba(255, 255, 255, 0.1);
aspect-ratio: 1/1;
padding: 0px 10px;
}
@ -357,6 +357,53 @@
flex-direction: column;
gap: calc(var(--donate-card-main-padding) / 2);
position: relative;
&:hover {
& > #donation-options-scroll {
opacity: 1;
}
& > #donation-options {
&.mask-both {
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 20%,
rgba(0, 0, 0, 1) 50%,
rgba(0, 0, 0, 1) 80%,
rgba(0, 0, 0, 0) 100%
);
}
&.mask-left {
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 20%,
rgba(0, 0, 0, 1) 50%,
rgba(0, 0, 0, 1) 97%,
rgba(0, 0, 0, 0) 100%
);
}
&.mask-right {
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 3%,
rgba(0, 0, 0, 1) 50%,
rgba(0, 0, 0, 1) 80%,
rgba(0, 0, 0, 0) 100%
);
}
}
}
&:not(:hover) {
& > #donation-options-scroll .scroll-button {
visibility: hidden;
}
}
}
#donation-options-scroll {
@ -380,49 +427,10 @@
padding: 0 16px;
background-color: transparent;
height: 100%;
transition: opacity 0.2s;
}
#donation-options-container:hover #donation-options-scroll {
opacity: 1;
}
.scroll-button.hidden {
opacity: 0;
visibility: hidden;
}
#donation-options-container.mask-both:hover #donation-options {
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 20%,
rgba(0, 0, 0, 1) 50%,
rgba(0, 0, 0, 1) 80%,
rgba(0, 0, 0, 0) 100%
);
}
#donation-options-container.mask-left:hover #donation-options {
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 20%,
rgba(0, 0, 0, 1) 50%,
rgba(0, 0, 0, 1) 97%,
rgba(0, 0, 0, 0) 100%
);
}
#donation-options-container.mask-right:hover #donation-options {
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 3%,
rgba(0, 0, 0, 1) 50%,
rgba(0, 0, 0, 1) 80%,
rgba(0, 0, 0, 0) 100%
);
&.hidden {
visibility: hidden;
}
}
@media screen and (max-width: 550px) {

View File

@ -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") : ""}
>
@ -158,12 +162,9 @@
box-shadow: 0 0 0 2px rgba(255, 255, 255, var(--donate-border-opacity));
}
#share-qr:focus-visible {
box-shadow: none !important;
}
#share-qr:focus-visible :global(svg) {
box-shadow: 0 0 0 2px var(--blue);
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
#action-buttons {
@ -176,7 +177,7 @@
.action-button {
align-items: center;
width: 100%;
padding: 0 10px;
padding: 0 6px;
font-size: 13px;
gap: 2px;
}

View File

@ -4,6 +4,6 @@
export { a };
</script>
<div class="long-text-noto about">
<div class="long-text about">
<slot></slot>
</div>

View File

@ -1,17 +1,29 @@
<script lang="ts">
export let id: string;
export let classes = "";
import type { Snippet } from "svelte";
export let draggedOver = false;
export let file: File | undefined;
type Props = {
id: string;
draggedOver?: boolean;
files: FileList | undefined;
onDrop: () => {};
children?: Snippet;
};
let {
id,
draggedOver = $bindable(false),
files = $bindable(),
onDrop,
children,
}: Props = $props();
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;
onDrop();
}
};
@ -23,16 +35,15 @@
<div
{id}
class={classes}
role="region"
on:drop={(ev) => dropHandler(ev)}
on:dragover={(ev) => dragOverHandler(ev)}
on:dragend={() => {
ondrop={(ev) => dropHandler(ev)}
ondragover={(ev) => dragOverHandler(ev)}
ondragend={() => {
draggedOver = false;
}}
on:dragleave={() => {
ondragleave={() => {
draggedOver = false;
}}
>
<slot></slot>
{@render children?.()}
</div>

View File

@ -5,22 +5,46 @@
import IconFileImport from "@tabler/icons-svelte/IconFileImport.svelte";
import IconUpload from "@tabler/icons-svelte/IconUpload.svelte";
export let file: File | undefined;
export let draggedOver = false;
export let acceptTypes: string[];
export let acceptExtensions: string[];
type Props = {
files: FileList | undefined;
draggedOver?: boolean;
acceptTypes: string[];
acceptExtensions: string[];
maxFileNumber?: number;
onImport: () => {};
}
let {
files = $bindable(),
draggedOver = $bindable(false),
acceptTypes,
acceptExtensions,
maxFileNumber = 100,
onImport,
}: Props = $props();
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);
}
files = userFiles;
onImport();
}
};
};
@ -29,7 +53,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" onclick={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 +71,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">
@ -70,7 +94,7 @@
transition: box-shadow 0.2s;
}
.open-file-button:not(:focus-visible) {
.open-file-button {
box-shadow: none;
}
@ -121,13 +145,18 @@
stroke: var(--blue);
}
.open-file-button:focus-visible {
outline: none;
}
.open-file-container :global(.meowbalt) {
z-index: 2;
clip-path: inset(0px 0px 16px 0px);
margin-bottom: -16px;
transition:
clip-path 0.2s,
margin-bottom 0.2s;
margin-bottom 0.2s,
opacity 0.15s;
}
.dragged-over :global(.meowbalt) {
@ -161,5 +190,7 @@
max-width: 250px;
font-size: 14px;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
</style>

View File

@ -1,16 +1,24 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
export let emotion: MeowbaltEmotions;
type Props = {
emotion: MeowbaltEmotions;
forceLoaded?: boolean;
};
const { emotion, forceLoaded }: Props = $props();
let loaded = $state(false);
</script>
<img
class="meowbalt {emotion}"
class:loaded={loaded || forceLoaded}
onload={() => (loaded = true)}
src="/meowbalt/{emotion}.png"
height="152"
alt={$t('general.meowbalt')}
alt={$t("general.meowbalt")}
aria-hidden="true"
/>
@ -19,6 +27,12 @@
display: block;
margin: 0;
object-fit: cover;
opacity: 0;
transition: opacity 0.15s;
}
.meowbalt.loaded {
opacity: 1;
}
.error {

View File

@ -6,7 +6,7 @@
<div id="placeholder-container" class="center-column-container">
<Meowbalt emotion="smile" />
<div tabindex="-1" data-first-focus data-focus-ring-hidden>
<div tabindex="-1" data-first-focus>
{`${pageName} page is not ready yet!`}
</div>
</div>

View File

@ -0,0 +1,74 @@
<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);
filter: drop-shadow(0 0 8px var(--popover-glow))
drop-shadow(0 0 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);
will-change: transform, opacity;
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>

View File

@ -1,28 +1,34 @@
<script lang="ts">
import { page } from "$app/stores";
import { t } from "$lib/i18n/translations";
import { page } from "$app/state";
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 copyData = "";
type Props = {
title: string;
sectionId: string;
beta?: boolean;
nolink?: boolean;
copyData?: string;
};
const sectionURL = `${$page.url.origin}${$page.url.pathname}#${sectionId}`;
let {
title,
sectionId,
beta = false,
nolink = false,
copyData = "",
}: Props = $props();
let copied = false;
const sectionURL = `${page.url.origin}${page.url.pathname}#${sectionId}`;
$: if (copied) {
setTimeout(() => {
copied = false;
}, 1500);
}
let copied = $state(false);
</script>
<div class="heading-container">
<h3 class="content-title">
<h3 id="{sectionId}-title" class="content-title">
{title}
</h3>
@ -32,18 +38,26 @@
</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"}`)}
onclick={() => {
if (!copied) {
copyURL(copyData || sectionURL);
hapticConfirm();
copied = true;
setTimeout(() => {
copied = false;
}, 1500);
}
}}
>
<CopyIcon check={copied} regularIcon={!!copyData} />
</button>
{/if}
</div>
<style>
@ -68,6 +82,7 @@
.link-copy:focus-visible {
opacity: 1;
outline-offset: 0;
}
.link-copy :global(.copy-animation) {
@ -90,7 +105,7 @@
color: var(--primary);
font-size: 11px;
font-weight: 500;
line-height: 1.9;
line-height: 1.86;
text-transform: uppercase;
}

View File

@ -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>

View File

@ -1,11 +1,26 @@
<script lang="ts">
import { onMount } from "svelte";
import { t } from "$lib/i18n/translations";
import IconComet from "@tabler/icons-svelte/IconComet.svelte";
let dismissed = true;
onMount(() => {
setTimeout(() => {
dismissed = false;
}, 200)
});
</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>
@ -31,13 +46,19 @@
padding: 8px 12px 8px 8px;
pointer-events: all;
gap: 8px;
margin: var(--padding);
margin-top: calc(env(safe-area-inset-top) + var(--padding));
margin-right: 71px;
margin-top: calc(env(safe-area-inset-top) + 8px);
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 +95,16 @@
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) + 16px);
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: 0;
transition: transform 0.55s cubic-bezier(0.53, 0.05, 0.23, 1.15);
}
}
</style>

View File

@ -0,0 +1,175 @@
<script lang="ts">
import { onMount } from "svelte";
import { t } from "$lib/i18n/translations";
import { beforeNavigate, onNavigate } from "$app/navigation";
import { clearFileStorage } from "$lib/storage/opfs";
import { getProgress } from "$lib/task-manager/queue";
import { queueVisible } from "$lib/state/queue-visibility";
import { currentTasks } from "$lib/state/task-manager/current-tasks";
import { clearQueue, queue as readableQueue } from "$lib/state/task-manager/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";
const popoverAction = () => {
$queueVisible = !$queueVisible;
};
let queue = $derived(Object.entries($readableQueue));
let totalProgress = $derived(queue.length ? queue.map(
([, item]) => getProgress(item, $currentTasks) * 100
).reduce((a, b) => a + b) / (100 * queue.length) : 0);
let indeterminate = $derived(queue.length > 0 && totalProgress === 0);
onNavigate(() => {
$queueVisible = false;
});
onMount(() => {
// clear old files from storage on first page load
clearFileStorage();
});
beforeNavigate((event) => {
if (event.type === "leave" && (totalProgress > 0 && totalProgress < 1)) {
event.cancel();
}
});
</script>
<div id="processing-queue">
<ProcessingStatus
progress={totalProgress * 100}
{indeterminate}
expandAction={popoverAction}
/>
<PopoverContainer
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"
onclick={clearQueue}
tabindex={!$queueVisible ? -1 : undefined}
>
<IconX />
{$t("button.clear")}
</button>
{/if}
</div>
</div>
</div>
<div id="processing-list" role="list" aria-labelledby="queue-title">
{#each queue as [id, item]}
<ProcessingQueueItem {id} info={item} />
{/each}
{#if queue.length === 0}
<ProcessingQueueStub />
{/if}
</div>
</PopoverContainer>
</div>
<style>
#processing-queue {
--holder-padding: 12px;
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: 3px;
}
.header-top {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.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;
outline-offset: 5px;
}
.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>

View File

@ -0,0 +1,442 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import { formatFileSize } from "$lib/util";
import { downloadFile } from "$lib/download";
import { getProgress } from "$lib/task-manager/queue";
import { savingHandler } from "$lib/api/saving-handler";
import { removeItem } from "$lib/state/task-manager/queue";
import { queueVisible } from "$lib/state/queue-visibility";
import { currentTasks } from "$lib/state/task-manager/current-tasks";
import type { CobaltQueueItem, UUID } from "$lib/types/queue";
import type { CobaltCurrentTasks } from "$lib/types/task-manager";
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,
};
type Props = {
id: UUID;
info: CobaltQueueItem;
}
let { id, info }: Props = $props();
let retrying = $state(false);
let downloading = $state(false);
const retry = async (info: CobaltQueueItem) => {
if (info.canRetry && info.originalRequest) {
retrying = true;
await savingHandler({
request: info.originalRequest,
oldTaskId: id,
});
retrying = false;
}
};
const download = (file: File) => {
downloading = true;
downloadFile({
file: new File([file], info.filename, {
type: info.mimeType,
}),
});
setTimeout(() => {
/*
fake timeout to prevent download button spam,
because there's no real way to await the real
saving process via object url (blob), which
takes some time on some devices depending on file size.
if you know of a way to do it in
lib/download.ts -> openFile(), please make a PR!
*/
downloading = false;
}, 3000)
};
type StatusText = {
info: CobaltQueueItem;
currentTasks: CobaltCurrentTasks;
retrying: boolean;
};
const generateStatusText = ({ info, retrying, currentTasks }: StatusText) => {
switch (info.state) {
case "running":
const progress = getProgress(info, currentTasks);
const runningWorkers = info.pipeline.filter(w => w.workerId in currentTasks);
const running = new Set(runningWorkers.map(task => task.worker));
const progresses = runningWorkers.map(w => currentTasks[w.workerId])
.map(t => t.progress)
.filter(p => p);
let totalSize = progresses.reduce((s, p) => s + (p?.size ?? 0), 0);
// if only fetch workers are running, then we should
// show the sum of all running & completed fetch workers
if (running.size === 1 && running.has("fetch")) {
totalSize += Object.values(info.pipelineResults)
.reduce((s, p) => s + (p?.size ?? 0), 0);
}
const runningText = [...running].map(task => $t(`queue.state.running.${task}`)).join(", ");
if (runningWorkers.length && totalSize > 0) {
const formattedSize = formatFileSize(totalSize);
return `${runningText}: ${Math.floor(progress * 100)}%, ${formattedSize}`;
}
const firstUnstarted = info.pipeline.find(w => {
if (info.pipelineResults[w.workerId])
return false;
const task = currentTasks[w.workerId];
if (!task || !task.progress) {
return true;
}
});
if (firstUnstarted) {
return $t(`queue.state.starting.${firstUnstarted.worker}`);
}
return runningText;
case "done":
return formatFileSize(info.resultFile?.size);
case "error":
return !retrying ? $t(`error.${info.errorCode}`) : $t("queue.state.retrying");
case "waiting":
return $t("queue.state.waiting");
}
};
const getWorkerProgress = (item: CobaltQueueItem, workerId: UUID): number | undefined => {
if (item.state === 'running' && item.pipelineResults[workerId]) {
return 100;
}
const workerIndex = item.pipeline.findIndex(w => w.workerId === workerId);
if (workerIndex === -1) {
return;
}
const worker = item.pipeline[workerIndex];
const task = $currentTasks[worker.workerId];
if (task?.progress?.percentage) {
return Math.max(0, Math.min(100, task.progress.percentage));
}
}
/*
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
*/
let statusText = $derived(generateStatusText({
info,
retrying,
currentTasks: $currentTasks
}));
const MediaTypeIcon = $derived(itemIcons[info.mediaType]);
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="processing-item"
role="listitem"
tabindex={$queueVisible ? 0 : -1}
class:queue-hidden={!$queueVisible}
>
<div class="processing-info">
<div class="file-title">
<div class="processing-type">
<MediaTypeIcon />
</div>
<span class="filename">
{info.filename}
</span>
</div>
{#if info.state === "running"}
<div class="progress-holder">
{#each info.pipeline as task}
<ProgressBar
percentage={getWorkerProgress(info, task.workerId) || 0}
workerId={task.workerId}
pipelineResults={info.pipelineResults}
/>
{/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"
aria-label={$t("button.download")}
onclick={() => download(info.resultFile)}
disabled={downloading}
class:downloading
>
{#if !downloading}
<IconDownload />
{:else}
<IconLoader2 />
{/if}
</button>
{/if}
{#if !retrying}
{#if info.state === "error" && info?.canRetry}
<button
class="button action-button"
aria-label={$t("button.retry")}
onclick={() => retry(info)}
>
<IconReload />
</button>
{/if}
<button
class="button action-button"
aria-label={$t(`button.${info.state === "done" ? "delete" : "remove"}`)}
onclick={() => removeItem(id)}
disabled={downloading}
>
<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;
}
.status-text {
line-break: normal;
}
/*
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;
}
.file-actions {
gap: 4px;
}
@media (hover: hover) {
.file-actions {
position: absolute;
right: 0;
background-color: var(--button);
height: 90%;
padding-left: 18px;
transform: translateX(5px);
opacity: 0;
transition: opacity 0.15s, transform 0.15s;
mask-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(0, 0, 0, 1) 20%
);
}
.queue-hidden .file-actions {
visibility: hidden;
}
:global([dir="rtl"]) .file-actions {
left: 0;
right: unset;
padding-left: 0;
padding-right: 18px;
transform: translateX(-5px);
mask-image: linear-gradient(
-90deg,
rgba(255, 255, 255, 0) 0%,
rgba(0, 0, 0, 1) 20%
);
}
.processing-item:hover .file-actions,
.processing-item:focus-within .file-actions {
opacity: 1;
transform: none;
}
}
@media (hover: none) {
.processing-info {
overflow: hidden;
flex: 1;
}
}
.action-button {
padding: 8px;
height: auto;
box-shadow: none;
transition: opacity 0.2s;
}
.action-button :global(svg) {
width: 18px;
height: 18px;
stroke-width: 1.5px;
}
.action-button:disabled {
cursor: progress;
opacity: 0.5;
}
.status-spinner :global(svg),
.action-button.downloading :global(svg) {
animation: spinner 0.7s infinite linear;
will-change: transform;
}
.processing-item:first-child {
padding-top: 0;
}
.processing-item:last-child {
padding-bottom: 16px;
border: none;
}
</style>

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import Meowbalt from "$components/misc/Meowbalt.svelte";
</script>
<div class="queue-stub">
<Meowbalt emotion="think" />
<span class="subtext stub-text">
{$t("queue.stub", {
value: $t("queue.stub"),
})}
</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>

View File

@ -0,0 +1,152 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import IconArrowDown from "@tabler/icons-svelte/IconArrowDown.svelte";
type Props = {
indeterminate?: boolean;
progress?: number;
expandAction: () => void;
}
let {
indeterminate = false,
progress = $bindable(0),
expandAction
}: Props = $props();
let progressStroke = $derived(`${progress}, 100`);
const indeterminateStroke = "15, 5";
let ariaState = $derived(
progress > 0 && progress < 100
? "ongoing"
: progress >= 100
? "completed"
: "default"
)
</script>
<button
id="processing-status"
onclick={expandAction}
class="button"
class:completed={progress >= 100}
aria-label={$t(`a11y.queue.status.${ariaState}`)}
>
<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 {
pointer-events: all;
padding: 7px;
border-radius: 30px;
filter: drop-shadow(0 0 3px var(--button-elevated-hover));
transition:
background-color 0.2s,
transform 0.2s;
will-change: transform, background-color;
}
#processing-status:focus-visible {
outline: 2px solid var(--secondary);
outline-offset: 2px;
}
#processing-status:active {
transform: scale(0.9);
}
#processing-status.completed {
box-shadow: 0 0 0 2px var(--blue) inset;
}
: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;
will-change: transform;
}
#progress-ring.indeterminate circle {
transition: none;
}
.completed #progress-ring {
opacity: 0;
}
</style>

View File

@ -0,0 +1,54 @@
<script lang="ts">
import Skeleton from "$components/misc/Skeleton.svelte";
import type { CobaltQueueItemRunning, UUID } from "$lib/types/queue";
type Props = {
percentage?: number;
workerId: UUID;
pipelineResults: CobaltQueueItemRunning['pipelineResults'];
}
let { percentage = 0, workerId, pipelineResults }: Props = $props();
</script>
<div class="file-progress">
{#if percentage}
<div
class="progress"
style="width: {Math.min(100, percentage)}%"
></div>
{:else if pipelineResults[workerId]}
<div
class="progress"
style="width: 100%"
></div>
{:else}
<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>

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
type Props = {
visible: boolean;
};
let { visible }: Props = $props();
</script>
<div class="tooltip-holder" class:visible aria-hidden="true">
<div class="tooltip-body">
<div class="tooltip-content subtext">
{$t("save.tooltip.captcha")}
</div>
<div class="tooltip-pointer border"></div>
<div class="tooltip-pointer"></div>
</div>
</div>
<style>
.tooltip-holder {
position: absolute;
bottom: calc(100% + 10px);
opacity: 0;
transform: scale(0.5) translateX(10px) translateY(15px);
transform-origin: bottom left;
transition:
transform 0.2s cubic-bezier(0.53, 0.05, 0.23, 1.15),
opacity 0.2s cubic-bezier(0.53, 0.05, 0.23, 0.99);
will-change: transform, opacity;
}
.tooltip-holder.visible {
opacity: 1;
transform: none;
}
.tooltip-body {
max-width: 180px;
position: relative;
pointer-events: none;
padding: 8px 14px;
border-radius: 11px;
background: var(--button);
box-shadow: var(--button-box-shadow);
filter: drop-shadow(0 0 8px var(--popover-glow));
}
.tooltip-content {
padding: 0;
}
.tooltip-pointer {
position: absolute;
top: calc(100% - 7px);
left: 14px;
transform: rotate(45deg);
background: var(--button);
z-index: 2;
height: 10px;
width: 10px;
}
.tooltip-pointer.border {
box-shadow: var(--button-box-shadow);
z-index: 1;
margin-top: 2px;
}
</style>

View File

@ -1,16 +1,18 @@
<script lang="ts">
import env from "$lib/env";
import env, { officialApiURL } from "$lib/env";
import { page } from "$app/stores";
import { tick } from "svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { browser } from "$app/environment";
import { SvelteComponent, tick } from "svelte";
import { t } from "$lib/i18n/translations";
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";
@ -23,6 +25,7 @@
import Switcher from "$components/buttons/Switcher.svelte";
import OmniboxIcon from "$components/save/OmniboxIcon.svelte";
import ActionButton from "$components/buttons/ActionButton.svelte";
import CaptchaTooltip from "$components/save/CaptchaTooltip.svelte";
import SettingsButton from "$components/buttons/SettingsButton.svelte";
import IconMute from "$components/icons/Mute.svelte";
@ -31,14 +34,6 @@
import IconClipboard from "$components/icons/Clipboard.svelte";
let linkInput: Optional<HTMLInputElement>;
let downloadButton: SvelteComponent;
let isFocused = false;
let isDisabled = false;
let isLoading = false;
$: isBotCheckOngoing = $turnstileEnabled && !$turnstileSolved;
const validLink = (url: string) => {
try {
@ -46,25 +41,49 @@
} catch {}
};
$: linkFromHash = $page.url.hash.replace("#", "") || "";
$: linkFromQuery = (browser ? $page.url.searchParams.get("u") : 0) || "";
let isFocused = $state(false);
let isDisabled = $state(false);
let isLoading = $state(false);
$: if (linkFromHash || linkFromQuery) {
if (validLink(linkFromHash)) {
$link = linkFromHash;
} else if (validLink(linkFromQuery)) {
$link = linkFromQuery;
let isHovered = $state(false);
let isBotCheckOngoing = $derived($turnstileEnabled && !$turnstileSolved);
let linkPrefill = $derived(
page.url.hash.replace("#", "")
|| (browser ? page.url.searchParams.get("u") : "")
|| ""
);
let downloadable = $derived(validLink($link));
let clearVisible = $derived($link && !isLoading);
$effect (() => {
if (linkPrefill) {
// prefilled link may be uri encoded
linkPrefill = decodeURIComponent(linkPrefill);
if (validLink(linkPrefill)) {
$link = linkPrefill;
}
// clear hash and query to prevent bookmarking unwanted links
if (browser) goto("/", { replaceState: true });
// clear link prefill to avoid extra effects
linkPrefill = "";
savingHandler({ url: $link });
}
// clear hash and query to prevent bookmarking unwanted links
goto("/", { replaceState: true });
}
});
const pasteClipboard = async () => {
if ($dialogs.length > 0 || isDisabled || isLoading) {
return;
}
hapticSwitch();
const pastedData = await pasteLinkFromClipboard();
if (!pastedData) return;
@ -73,10 +92,8 @@
if (linkMatch) {
$link = linkMatch[0].split('')[0];
if (!isBotCheckOngoing) {
await tick(); // wait for button to render
downloadButton.download($link);
}
await tick(); // wait for button to render
savingHandler({ url: $link });
}
};
@ -94,7 +111,7 @@
}
if (e.key === "Enter" && validLink($link) && isFocused) {
downloadButton.download($link);
savingHandler({ url: $link });
}
if (["Escape", "Clear"].includes(e.key) && isFocused) {
@ -124,32 +141,42 @@
};
</script>
<svelte:window on:keydown={handleKeydown} />
<svelte:window onkeydown={handleKeydown} />
<!--
if you want to remove the community instance label,
refer to the license first https://github.com/imputnet/cobalt/tree/main/web#license
-->
{#if env.DEFAULT_API || (!$page.url.host.endsWith(".cobalt.tools") && $page.url.host !== "cobalt.tools")}
{#if env.DEFAULT_API !== officialApiURL}
<div id="instance-label">
{$t("save.label.community_instance")}
</div>
{/if}
<div id="omnibox">
{#if $turnstileEnabled}
<CaptchaTooltip
visible={isBotCheckOngoing && (isHovered || isFocused)}
/>
{/if}
<div
id="input-container"
class:focused={isFocused}
class:downloadable={validLink($link)}
class:downloadable
class:clear-visible={clearVisible}
>
<OmniboxIcon loading={isLoading || isBotCheckOngoing} />
<input
id="link-area"
bind:value={$link}
bind:this={linkInput}
on:input={() => (isFocused = true)}
on:focus={() => (isFocused = true)}
on:blur={() => (isFocused = false)}
oninput={() => (isFocused = true)}
onfocus={() => (isFocused = true)}
onblur={() => (isFocused = false)}
onmouseover={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
spellcheck="false"
autocomplete="off"
autocapitalize="off"
@ -162,17 +189,12 @@
disabled={isDisabled}
/>
{#if $link && !isLoading}
<ClearButton click={() => ($link = "")} />
{/if}
{#if validLink($link)}
<DownloadButton
url={$link}
bind:this={downloadButton}
bind:disabled={isDisabled}
bind:loading={isLoading}
/>
{/if}
<ClearButton click={() => ($link = "")} />
<DownloadButton
url={$link}
bind:disabled={isDisabled}
bind:loading={isLoading}
/>
</div>
<div id="action-container">
@ -217,33 +239,54 @@
flex-direction: column;
max-width: 640px;
width: 100%;
gap: 8px;
gap: 6px;
position: relative;
}
#input-container {
--input-padding: 10px;
display: flex;
box-shadow: 0 0 0 1.5px var(--input-border) inset;
/* webkit can't render the 1.5px box shadow properly,
so we duplicate the border as outline to fix it visually */
outline: 1.5px solid var(--input-border);
outline-offset: -1.5px;
border-radius: var(--border-radius);
padding: 0 var(--input-padding);
align-items: center;
gap: var(--input-padding);
font-size: 14px;
flex: 1;
}
#input-container:not(.clear-visible) :global(#clear-button) {
display: none;
}
#input-container:not(.downloadable) :global(#download-button) {
display: none;
}
#input-container.clear-visible {
padding-right: var(--input-padding);
}
:global([dir="rtl"]) #input-container.clear-visible {
padding-right: unset;
padding-left: var(--input-padding);
}
#input-container.downloadable {
padding-right: 0;
}
#input-container.downloadable:dir(rtl) {
padding-right: var(--input-padding);
padding-left: 0;
}
#input-container.focused {
box-shadow: 0 0 0 1.5px var(--secondary) inset;
outline: var(--secondary) 0.5px solid;
box-shadow: none;
outline: var(--secondary) 2px solid;
outline-offset: -1px;
}
#input-container.focused :global(#input-icons svg) {
@ -259,6 +302,7 @@
width: 100%;
margin: 0;
padding: var(--input-padding) 0;
padding-left: calc(var(--input-padding) + 28px);
height: 18px;
align-items: center;
@ -275,10 +319,14 @@
/* workaround for safari */
font-size: inherit;
/* prevents input from poking outside of rounded corners */
border-radius: var(--border-radius);
}
#link-area:focus-visible {
box-shadow: unset !important;
:global([dir="rtl"]) #link-area {
padding-left: unset;
padding-right: calc(var(--input-padding) + 28px);
}
#link-area::placeholder {

View File

@ -2,11 +2,41 @@
import IconLink from "@tabler/icons-svelte/IconLink.svelte";
import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte";
export let loading: boolean;
type Props = {
loading: boolean;
};
let { loading }: Props = $props();
let animated = $state(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
ontransitionstart={() => (animated = true)}
ontransitionend={() => (animated = loading)}
>
<IconLoader2 />
</div>
<div class="input-icon link-icon">
@ -15,24 +45,32 @@
</div>
<style>
#input-icons {
display: flex;
position: relative;
align-items: center;
justify-content: center;
#input-icons,
#input-icons :global(svg),
.input-icon {
width: 18px;
height: 18px;
}
#input-icons {
display: flex;
position: absolute;
margin-left: var(--input-padding);
pointer-events: none;
}
:global([dir="rtl"]) #input-icons {
margin-left: unset;
margin-right: var(--input-padding);
}
#input-icons :global(svg) {
stroke: var(--gray);
width: 18px;
height: 18px;
stroke-width: 2px;
will-change: transform;
}
.input-icon {
display: flex;
position: absolute;
transition:
transform 0.25s,
@ -49,12 +87,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 +104,4 @@
transform: none;
opacity: 1;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,18 +1,18 @@
<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 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;
$: expanded = false;
let servicesContainer: HTMLDivElement;
$: loaded = false;
$: renderPopover = false;
const loadInfo = async () => {
await getServerInfo();
@ -29,19 +29,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 +37,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 +47,30 @@
<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 id="services-popover" {expanded}>
<div
id="services-container"
bind:this={servicesContainer}
tabindex="-1"
>
{#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 +83,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 +93,12 @@
font-size: 13px;
font-weight: 500;
background: none;
transition:
background 0.2s,
box-shadow 0.1s;
}
#services-button:not(:focus-visible) {
#services-button:not(:active) {
box-shadow: none;
}
@ -151,19 +112,39 @@
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;

View File

@ -7,6 +7,7 @@
<button
id="clear-button"
class="button"
on:click={click}
aria-label={$t("a11y.save.clear_input")}
>

View File

@ -1,12 +1,11 @@
<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 +14,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 +71,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;
@ -191,16 +95,14 @@
border-right: 1.5px var(--input-border) solid;
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
}
#download-button:focus-visible {
box-shadow: 0 0 0 2px var(--blue) inset;
direction: ltr;
padding: 0 12px 0 15px;
}
#download-state {
font-size: 24px;
font-family: "Noto Sans Mono Variable", "Noto Sans Mono",
"IBM Plex Mono", monospace;
font-family: "Noto Sans Mono", "IBM Plex Mono", monospace;
font-weight: 400;
text-align: center;
@ -212,7 +114,7 @@
#download-button:disabled {
cursor: unset;
opacity: 0.7;
color: var(--gray);
}
:global(#input-container.focused) #download-button {
@ -225,11 +127,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>

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import { createDialog } from "$lib/state/dialogs";
import { clearQueue } from "$lib/state/task-manager/queue";
import { clearFileStorage } from "$lib/storage/opfs";
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();
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map(key => caches.delete(key)));
}
},
},
],
});
};
</script>
<DataSettingsButton id="clear-cache" click={clearDialog} danger>
<IconFileShredder />
{$t("button.clear_cache")}
</DataSettingsButton>

View 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>

View File

@ -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) {

View File

@ -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}

View File

@ -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>

View File

@ -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;
@ -41,7 +39,7 @@
.settings-content {
display: flex;
flex-direction: column;
gap: var(--padding);
gap: 10px;
padding: calc(var(--subnav-padding) / 2);
border-radius: 18px;
transition: opacity 0.2s;
@ -52,6 +50,14 @@
pointer-events: none;
}
/*
for some weird reason parent's transition
breaks final opacity of children on ios
*/
:global([data-iphone="true"]) .settings-content {
transition: none;
}
.settings-content.focus {
animation: highlight 2s;
}

View File

@ -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>

View File

@ -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 IconEyeOff from "@tabler/icons-svelte/IconEyeOff.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;
@ -60,47 +95,88 @@
<div id="settings-input-holder">
<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}
<IconEyeOff />
{/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">
<div class="input-outer-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 +187,6 @@
}
#input-container {
padding: 0 18px;
border-radius: var(--border-radius);
color: var(--secondary);
background-color: var(--button);
@ -124,26 +199,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,34 +222,58 @@
white-space: nowrap;
}
#input-box:focus-visible {
box-shadow: unset !important;
.input-box,
.input-placeholder {
padding-left: 16px;
}
#input-container.focused {
box-shadow: 0 0 0 2px var(--secondary) inset;
}
#settings-input-buttons {
.input-outer-buttons {
display: flex;
flex-direction: row;
gap: 6px;
}
.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;
/* 3px is visual padding outside of the button */
border-radius: calc(var(--border-radius) - 3px);
z-index: 1;
}
.input-inner-button:last-child {
margin-right: 3px;
}
.input-inner-button :global(svg) {
height: 18px;
width: 18px;
stroke-width: 1.8px;
}
:global(svg) {
will-change: transform;
}
</style>

View File

@ -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) {

View File

@ -1,4 +1,6 @@
<script lang="ts">
import settings from "$lib/state/settings";
import { t } from "$lib/i18n/translations";
import { defaultNavPage } from "$lib/subnav";
@ -30,7 +32,9 @@
<div id="sidebar-tabs" role="tablist">
<div id="sidebar-actions" class="sidebar-inner-container">
<SidebarTab name="save" path="/" icon={IconDownload} />
<SidebarTab name="remux" path="/remux" icon={IconRepeat} beta />
{#if !$settings.appearance.hideRemuxTab}
<SidebarTab name="remux" path="/remux" icon={IconRepeat} beta />
{/if}
</div>
<div id="sidebar-info" class="sidebar-inner-container">
<SidebarTab name="settings" path={settingsLink} icon={IconSettings} />
@ -60,7 +64,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 +83,7 @@
justify-content: center;
align-items: flex-start;
z-index: 3;
padding: var(--sidebar-inner-padding) 0;
}
#sidebar::before {
@ -95,27 +100,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);
}
}

View File

@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show More