mirror of
https://github.com/imputnet/cobalt.git
synced 2025-07-10 15:28:29 +00:00
app: clipping prototype
This commit is contained in:
parent
25042742ad
commit
d965cf4da3
@ -32,6 +32,7 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^7.4.1",
|
||||||
"ffmpeg-static": "^5.1.0",
|
"ffmpeg-static": "^5.1.0",
|
||||||
|
"ffprobe-static": "^3.1.0",
|
||||||
"hls-parser": "^0.10.7",
|
"hls-parser": "^0.10.7",
|
||||||
"ipaddr.js": "2.2.0",
|
"ipaddr.js": "2.2.0",
|
||||||
"mime": "^4.0.4",
|
"mime": "^4.0.4",
|
||||||
|
@ -278,21 +278,27 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
|||||||
|
|
||||||
app.post('/metadata', apiLimiter);
|
app.post('/metadata', apiLimiter);
|
||||||
app.post('/metadata', async (req, res) => {
|
app.post('/metadata', async (req, res) => {
|
||||||
|
try {
|
||||||
const request = req.body;
|
const request = req.body;
|
||||||
|
|
||||||
if (!request.url) {
|
if (!request.url) {
|
||||||
return fail(res, "error.api.link.missing");
|
return fail(res, "error.api.link.missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { success, data: normalizedRequest } = await normalizeRequest(request);
|
const { success, data: normalizedRequest } = await normalizeRequest(request);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return fail(res, "error.api.invalid_body");
|
return fail(res, "error.api.invalid_body");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = extract(
|
const parsed = extract(
|
||||||
normalizedRequest.url,
|
normalizedRequest.url,
|
||||||
APIKeys.getAllowedServices(req.rateLimitKey),
|
APIKeys.getAllowedServices(req.rateLimitKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return fail(res, "error.api.link.invalid");
|
return fail(res, "error.api.link.invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("error" in parsed) {
|
if ("error" in parsed) {
|
||||||
let context;
|
let context;
|
||||||
if (parsed?.context) {
|
if (parsed?.context) {
|
||||||
@ -301,21 +307,42 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
|||||||
return fail(res, `error.api.${parsed.error}`, context);
|
return fail(res, `error.api.${parsed.error}`, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.host === "youtube") {
|
if (parsed.host !== "youtube") {
|
||||||
const youtube = (await import("../processing/services/youtube.js")).default;
|
return res.status(501).json({
|
||||||
const info = await youtube({ id: parsed.patternMatch.id });
|
status: "error",
|
||||||
if (info.error) {
|
code: "not_implemented",
|
||||||
return fail(res, info.error);
|
message: "Metadata endpoint is only implemented for YouTube."
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const meta = {
|
|
||||||
title: info.fileMetadata?.title,
|
const youtube = (await import("../processing/services/youtube.js")).default;
|
||||||
duration: info.duration || null,
|
|
||||||
thumbnail: info.cover || null,
|
const fetchInfo = {
|
||||||
author: info.fileMetadata?.artist || null,
|
id: parsed.patternMatch.id.slice(0, 11),
|
||||||
|
metadataOnly: true,
|
||||||
};
|
};
|
||||||
return res.json({ status: "success", metadata: meta });
|
|
||||||
} else {
|
const result = await youtube(fetchInfo);
|
||||||
return res.status(501).json({ status: "error", code: "not_implemented", message: "Metadata endpoint is only implemented for YouTube." });
|
|
||||||
|
if (result.error) {
|
||||||
|
return fail(res, `error.api.${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
title: result.fileMetadata?.title || null,
|
||||||
|
author: result.fileMetadata?.artist || null,
|
||||||
|
duration: result.duration || null,
|
||||||
|
thumbnail: result.cover || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
status: "success",
|
||||||
|
metadata: metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Metadata endpoint error:', error);
|
||||||
|
return fail(res, "error.api.generic");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -36,6 +36,8 @@ export default function({
|
|||||||
subtitles: r.subtitles,
|
subtitles: r.subtitles,
|
||||||
cover: !disableMetadata ? r.cover : false,
|
cover: !disableMetadata ? r.cover : false,
|
||||||
cropCover: !disableMetadata ? r.cropCover : false,
|
cropCover: !disableMetadata ? r.cropCover : false,
|
||||||
|
clipStart: r.clipStart,
|
||||||
|
clipEnd: r.clipEnd,
|
||||||
},
|
},
|
||||||
params = {};
|
params = {};
|
||||||
|
|
||||||
|
@ -123,6 +123,21 @@ export default async function({ host, patternMatch, params, authType }) {
|
|||||||
subtitleLang,
|
subtitleLang,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof params.clipStart === 'number') {
|
||||||
|
fetchInfo.clipStart = params.clipStart;
|
||||||
|
}
|
||||||
|
if (typeof params.clipEnd === 'number') {
|
||||||
|
fetchInfo.clipEnd = params.clipEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchInfo.clipStart !== undefined && fetchInfo.clipEnd !== undefined) {
|
||||||
|
if (fetchInfo.clipStart >= fetchInfo.clipEnd) {
|
||||||
|
return createResponse("error", {
|
||||||
|
code: "error.api.clip.invalid_range"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (url.hostname === "music.youtube.com" || isAudioOnly) {
|
if (url.hostname === "music.youtube.com" || isAudioOnly) {
|
||||||
fetchInfo.quality = "1080";
|
fetchInfo.quality = "1080";
|
||||||
fetchInfo.codec = "vp9";
|
fetchInfo.codec = "vp9";
|
||||||
|
@ -60,5 +60,8 @@ export const apiSchema = z.object({
|
|||||||
|
|
||||||
youtubeHLS: z.boolean().default(false),
|
youtubeHLS: z.boolean().default(false),
|
||||||
youtubeBetterAudio: z.boolean().default(false),
|
youtubeBetterAudio: z.boolean().default(false),
|
||||||
|
|
||||||
|
clipStart: z.number().min(0).optional(),
|
||||||
|
clipEnd: z.number().min(0).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
@ -291,6 +291,13 @@ export default async function (o) {
|
|||||||
return { error: "content.too_long" };
|
return { error: "content.too_long" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof o.clipStart === 'number' && o.clipStart >= basicInfo.duration) {
|
||||||
|
return { error: "clip.start_exceeds_duration" };
|
||||||
|
}
|
||||||
|
if (typeof o.clipEnd === 'number' && o.clipEnd > basicInfo.duration) {
|
||||||
|
return { error: "clip.end_exceeds_duration" };
|
||||||
|
}
|
||||||
|
|
||||||
// return a critical error if returned video is "Video Not Available"
|
// return a critical error if returned video is "Video Not Available"
|
||||||
// or a similar stub by youtube
|
// or a similar stub by youtube
|
||||||
if (basicInfo.id !== o.id) {
|
if (basicInfo.id !== o.id) {
|
||||||
@ -495,6 +502,27 @@ export default async function (o) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (o.metadataOnly) {
|
||||||
|
let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`;
|
||||||
|
try {
|
||||||
|
const testMaxCover = await fetch(cover, { dispatcher: o.dispatcher })
|
||||||
|
.then(r => r.status === 200)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!testMaxCover) {
|
||||||
|
cover = basicInfo.thumbnail?.[0]?.url || null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
cover = basicInfo.thumbnail?.[0]?.url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileMetadata,
|
||||||
|
duration: basicInfo.duration,
|
||||||
|
cover,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (subtitles) {
|
if (subtitles) {
|
||||||
fileMetadata.sublanguage = subtitles.language;
|
fileMetadata.sublanguage = subtitles.language;
|
||||||
}
|
}
|
||||||
@ -553,6 +581,8 @@ export default async function (o) {
|
|||||||
|
|
||||||
cover,
|
cover,
|
||||||
cropCover: basicInfo.author.endsWith("- Topic"),
|
cropCover: basicInfo.author.endsWith("- Topic"),
|
||||||
|
clipStart: o.clipStart,
|
||||||
|
clipEnd: o.clipEnd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -599,6 +629,8 @@ export default async function (o) {
|
|||||||
isHLS: useHLS,
|
isHLS: useHLS,
|
||||||
originalRequest,
|
originalRequest,
|
||||||
duration: basicInfo.duration,
|
duration: basicInfo.duration,
|
||||||
|
clipStart: o.clipStart,
|
||||||
|
clipEnd: o.clipEnd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,9 +99,18 @@ const render = async (res, streamInfo, ffargs, estimateMultiplier) => {
|
|||||||
const remux = async (streamInfo, res) => {
|
const remux = async (streamInfo, res) => {
|
||||||
const format = streamInfo.filename.split('.').pop();
|
const format = streamInfo.filename.split('.').pop();
|
||||||
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
|
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
|
||||||
const args = urls.flatMap(url => ['-i', url]);
|
const isClipping = typeof streamInfo.clipStart === 'number' || typeof streamInfo.clipEnd === 'number';
|
||||||
|
let args = [];
|
||||||
|
|
||||||
|
args.push(...urls.flatMap(url => ['-i', url]));
|
||||||
|
|
||||||
|
if (typeof streamInfo.clipStart === 'number') {
|
||||||
|
args.push('-ss', streamInfo.clipStart.toString());
|
||||||
|
}
|
||||||
|
if (typeof streamInfo.clipEnd === 'number') {
|
||||||
|
args.push('-to', streamInfo.clipEnd.toString());
|
||||||
|
}
|
||||||
|
|
||||||
// if the stream type is merge, we expect two URLs
|
|
||||||
if (streamInfo.type === 'merge' && urls.length !== 2) {
|
if (streamInfo.type === 'merge' && urls.length !== 2) {
|
||||||
return closeResponse(res);
|
return closeResponse(res);
|
||||||
}
|
}
|
||||||
@ -126,10 +135,19 @@ const remux = async (streamInfo, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isClipping) {
|
||||||
|
const vcodec = format === 'webm' ? 'libvpx-vp9' : 'libx264';
|
||||||
|
const acodec = format === 'webm' ? 'libopus' : 'aac';
|
||||||
|
args.push(
|
||||||
|
'-c:v', vcodec,
|
||||||
|
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', acodec])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
args.push(
|
args.push(
|
||||||
'-c:v', 'copy',
|
'-c:v', 'copy',
|
||||||
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
|
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (format === 'mp4') {
|
if (format === 'mp4') {
|
||||||
args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
|
args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
|
||||||
@ -149,15 +167,24 @@ const remux = async (streamInfo, res) => {
|
|||||||
|
|
||||||
args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3');
|
args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3');
|
||||||
|
|
||||||
await render(res, streamInfo, args);
|
await render(res, streamInfo, args, estimateAudioMultiplier(streamInfo) * 1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertAudio = async (streamInfo, res) => {
|
const convertAudio = async (streamInfo, res) => {
|
||||||
const args = [
|
let args = [];
|
||||||
|
|
||||||
|
args.push(
|
||||||
'-i', streamInfo.urls,
|
'-i', streamInfo.urls,
|
||||||
'-vn',
|
'-vn',
|
||||||
...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),
|
...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),
|
||||||
];
|
);
|
||||||
|
|
||||||
|
if (typeof streamInfo.clipStart === 'number') {
|
||||||
|
args.push('-ss', streamInfo.clipStart.toString());
|
||||||
|
}
|
||||||
|
if (typeof streamInfo.clipEnd === 'number') {
|
||||||
|
args.push('-to', streamInfo.clipEnd.toString());
|
||||||
|
}
|
||||||
|
|
||||||
if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') {
|
if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') {
|
||||||
args.push('-ar', '12000');
|
args.push('-ar', '12000');
|
||||||
|
@ -45,6 +45,9 @@ export function createStream(obj) {
|
|||||||
|
|
||||||
// url to a subtitle file
|
// url to a subtitle file
|
||||||
subtitles: obj.subtitles,
|
subtitles: obj.subtitles,
|
||||||
|
|
||||||
|
clipStart: obj.clipStart,
|
||||||
|
clipEnd: obj.clipEnd,
|
||||||
};
|
};
|
||||||
|
|
||||||
// FIXME: this is now a Promise, but it is not awaited
|
// FIXME: this is now a Promise, but it is not awaited
|
||||||
|
@ -37,6 +37,9 @@ importers:
|
|||||||
ffmpeg-static:
|
ffmpeg-static:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.0
|
version: 5.2.0
|
||||||
|
ffprobe-static:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
hls-parser:
|
hls-parser:
|
||||||
specifier: ^0.10.7
|
specifier: ^0.10.7
|
||||||
version: 0.10.9
|
version: 0.10.9
|
||||||
@ -1254,6 +1257,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==}
|
resolution: {integrity: sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
ffprobe-static@3.1.0:
|
||||||
|
resolution: {integrity: sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@ -3189,6 +3195,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
ffprobe-static@3.1.0: {}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
|
348
web/src/components/save/ClipRangeSlider.svelte
Normal file
348
web/src/components/save/ClipRangeSlider.svelte
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
min = 0,
|
||||||
|
max = 1,
|
||||||
|
start = $bindable(0),
|
||||||
|
end = $bindable(1),
|
||||||
|
step = 0.001,
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let track: HTMLDivElement;
|
||||||
|
let dragging = $state<"start" | "end" | null>(null);
|
||||||
|
let focused = $state<"start" | "end" | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
function handleDocumentClick(e: MouseEvent) {
|
||||||
|
if (track && !track.contains(e.target as Node)) {
|
||||||
|
focused = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', handleDocumentClick);
|
||||||
|
return () => document.removeEventListener('click', handleDocumentClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
function percent(val: number) {
|
||||||
|
return ((val - min) / (max - min)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(val: number, minVal: number, maxVal: number) {
|
||||||
|
return Math.max(minVal, Math.min(maxVal, val));
|
||||||
|
}
|
||||||
|
|
||||||
|
function posToValue(clientX: number) {
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
|
||||||
|
return min + ratio * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(e: PointerEvent, which: "start" | "end") {
|
||||||
|
dragging = which;
|
||||||
|
focused = which;
|
||||||
|
track.setPointerCapture(e.pointerId);
|
||||||
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
|
window.addEventListener("pointerup", handlePointerUp);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging) return;
|
||||||
|
let val = posToValue(e.clientX);
|
||||||
|
val = Math.round(val / step) * step;
|
||||||
|
if (dragging === "start") {
|
||||||
|
start = clamp(val, min, end);
|
||||||
|
if (start > end) start = end;
|
||||||
|
} else {
|
||||||
|
end = clamp(val, start, max);
|
||||||
|
if (end < start) end = start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(e: PointerEvent) {
|
||||||
|
if (dragging) {
|
||||||
|
track.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
dragging = null;
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", handlePointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackClick(e: MouseEvent) {
|
||||||
|
if (dragging) return;
|
||||||
|
const val = posToValue(e.clientX);
|
||||||
|
if (Math.abs(val - start) < Math.abs(val - end)) {
|
||||||
|
start = clamp(val, min, end);
|
||||||
|
focused = "start";
|
||||||
|
} else {
|
||||||
|
end = clamp(val, start, max);
|
||||||
|
focused = "end";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key.startsWith("Arrow") || e.key === "Home" || e.key === "End") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleKeydown(e, focused || "start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent, which: "start" | "end") {
|
||||||
|
const bigStep = (max - min) / 20;
|
||||||
|
const smallStep = step;
|
||||||
|
let newVal: number;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
newVal = (which === "start" ? start : end) - (e.shiftKey ? bigStep : smallStep);
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
newVal = (which === "start" ? start : end) + (e.shiftKey ? bigStep : smallStep);
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault();
|
||||||
|
newVal = which === "start" ? min : start;
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
e.preventDefault();
|
||||||
|
newVal = which === "start" ? end : max;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (which === "start") {
|
||||||
|
start = clamp(newVal, min, end);
|
||||||
|
} else {
|
||||||
|
end = clamp(newVal, start, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="slider-container">
|
||||||
|
<div
|
||||||
|
class="slider"
|
||||||
|
bind:this={track}
|
||||||
|
onclick={handleTrackClick}
|
||||||
|
onkeydown={handleTrackKeydown}
|
||||||
|
role="slider"
|
||||||
|
aria-valuenow={start}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-label="Clip range selector"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="slider-track"></div>
|
||||||
|
<div
|
||||||
|
class="slider-range"
|
||||||
|
style="left: {percent(start)}%; right: {100 - percent(end)}%"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="slider-handle start"
|
||||||
|
class:focused={focused === "start"}
|
||||||
|
class:dragging={dragging === "start"}
|
||||||
|
style="left: {percent(start)}%"
|
||||||
|
tabindex="0"
|
||||||
|
role="slider"
|
||||||
|
aria-label="Start time"
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={end}
|
||||||
|
aria-valuenow={start}
|
||||||
|
onpointerdown={(e) => handlePointerDown(e, 'start')}
|
||||||
|
onkeydown={(e) => handleKeydown(e, 'start')}
|
||||||
|
onfocus={() => focused = "start"}
|
||||||
|
onblur={() => focused = null}
|
||||||
|
>
|
||||||
|
<div class="handle-tooltip" class:visible={dragging === "start" || focused === "start"}>
|
||||||
|
{Math.floor(start / 60)}:{String(Math.floor(start % 60)).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="slider-handle end"
|
||||||
|
class:focused={focused === "end"}
|
||||||
|
class:dragging={dragging === "end"}
|
||||||
|
style="left: {percent(end)}%"
|
||||||
|
tabindex="0"
|
||||||
|
role="slider"
|
||||||
|
aria-label="End time"
|
||||||
|
aria-valuemin={start}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={end}
|
||||||
|
onpointerdown={(e) => handlePointerDown(e, 'end')}
|
||||||
|
onkeydown={(e) => handleKeydown(e, 'end')}
|
||||||
|
onfocus={() => focused = "end"}
|
||||||
|
onblur={() => focused = null}
|
||||||
|
>
|
||||||
|
<div class="handle-tooltip" class:visible={dragging === "end" || focused === "end"}>
|
||||||
|
{Math.floor(end / 60)}:{String(Math.floor(end % 60)).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.slider-container {
|
||||||
|
position: relative;
|
||||||
|
margin: 16px 0 12px 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: relative;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:hover .slider-track {
|
||||||
|
background: var(--input-border);
|
||||||
|
transform: translateY(-50%) scaleY(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--button-hover);
|
||||||
|
border-radius: 3px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-range {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
height: 8px;
|
||||||
|
background: linear-gradient(90deg, var(--secondary) 0%);
|
||||||
|
border-radius: 4px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:hover .slider-range {
|
||||||
|
height: 10px;
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--primary);
|
||||||
|
border: 2.5px solid var(--secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 3;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-handle:hover {
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-handle.focused {
|
||||||
|
border-color: var(--secondary);
|
||||||
|
transform: translate(-50%, -50%) scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-handle.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: translate(-50%, -50%) scale(1.15);
|
||||||
|
border-color: var(--secondary);
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-handle.start {
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, #f8f8f8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-handle.end {
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, #f0f0f0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-8px);
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-tooltip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-top-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-tooltip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(-50%) translateY(-12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.slider {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-handle {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-range {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:hover .slider-range {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.slider,
|
||||||
|
.slider-track,
|
||||||
|
.slider-range,
|
||||||
|
.slider-handle,
|
||||||
|
.handle-tooltip {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import type { Optional } from "$lib/types/generic";
|
import type { Optional } from "$lib/types/generic";
|
||||||
import type { DownloadModeOption } from "$lib/types/settings";
|
import type { DownloadModeOption } from "$lib/types/settings";
|
||||||
|
import type { CobaltSaveRequestBody } from "$lib/types/api";
|
||||||
|
|
||||||
import ClearButton from "$components/save/buttons/ClearButton.svelte";
|
import ClearButton from "$components/save/buttons/ClearButton.svelte";
|
||||||
import DownloadButton from "$components/save/buttons/DownloadButton.svelte";
|
import DownloadButton from "$components/save/buttons/DownloadButton.svelte";
|
||||||
@ -33,6 +34,10 @@
|
|||||||
import IconSparkles from "$components/icons/Sparkles.svelte";
|
import IconSparkles from "$components/icons/Sparkles.svelte";
|
||||||
import IconClipboard from "$components/icons/Clipboard.svelte";
|
import IconClipboard from "$components/icons/Clipboard.svelte";
|
||||||
|
|
||||||
|
import API from "$lib/api/api";
|
||||||
|
import ClipRangeSlider from "./ClipRangeSlider.svelte";
|
||||||
|
import ClipCheckbox from "./buttons/ClipCheckbox.svelte";
|
||||||
|
|
||||||
let linkInput: Optional<HTMLInputElement>;
|
let linkInput: Optional<HTMLInputElement>;
|
||||||
|
|
||||||
const validLink = (url: string) => {
|
const validLink = (url: string) => {
|
||||||
@ -58,6 +63,40 @@
|
|||||||
let downloadable = $derived(validLink($link));
|
let downloadable = $derived(validLink($link));
|
||||||
let clearVisible = $derived($link && !isLoading);
|
let clearVisible = $derived($link && !isLoading);
|
||||||
|
|
||||||
|
let clipMode = $state(false);
|
||||||
|
let metadata: { title?: string; author?: string; duration?: number } | null = $state(null);
|
||||||
|
let metaLoading = $state(false);
|
||||||
|
let metaError: string | null = $state(null);
|
||||||
|
let clipStart = $state(0);
|
||||||
|
let clipEnd = $state(0);
|
||||||
|
|
||||||
|
async function fetchMetadata() {
|
||||||
|
if (!validLink($link)) return;
|
||||||
|
metaLoading = true;
|
||||||
|
metaError = null;
|
||||||
|
metadata = null;
|
||||||
|
try {
|
||||||
|
const res = await API.getMetadata($link);
|
||||||
|
if (res.status === "success") {
|
||||||
|
metadata = res.metadata;
|
||||||
|
clipStart = 0;
|
||||||
|
clipEnd = metadata?.duration || 0;
|
||||||
|
} else {
|
||||||
|
metaError = res.message || "Failed to fetch metadata.";
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
metaError = e.message || "Failed to fetch metadata.";
|
||||||
|
} finally {
|
||||||
|
metaLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (clipMode && validLink($link)) {
|
||||||
|
fetchMetadata();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$effect (() => {
|
$effect (() => {
|
||||||
if (linkPrefill) {
|
if (linkPrefill) {
|
||||||
// prefilled link may be uri encoded
|
// prefilled link may be uri encoded
|
||||||
@ -139,6 +178,12 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = (seconds % 60).toFixed(3).padStart(6, '0');
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
@ -192,6 +237,9 @@
|
|||||||
<ClearButton click={() => ($link = "")} />
|
<ClearButton click={() => ($link = "")} />
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
url={$link}
|
url={$link}
|
||||||
|
clipMode={clipMode}
|
||||||
|
clipStart={clipStart}
|
||||||
|
clipEnd={clipEnd}
|
||||||
bind:disabled={isDisabled}
|
bind:disabled={isDisabled}
|
||||||
bind:loading={isLoading}
|
bind:loading={isLoading}
|
||||||
/>
|
/>
|
||||||
@ -225,12 +273,72 @@
|
|||||||
</SettingsButton>
|
</SettingsButton>
|
||||||
</Switcher>
|
</Switcher>
|
||||||
|
|
||||||
|
|
||||||
|
<ClipCheckbox checked={clipMode} onclick={() => clipMode = !clipMode} />
|
||||||
|
|
||||||
<ActionButton id="paste" click={pasteClipboard}>
|
<ActionButton id="paste" click={pasteClipboard}>
|
||||||
<IconClipboard />
|
<IconClipboard />
|
||||||
<span id="paste-desktop-text">{$t("save.paste")}</span>
|
<span id="paste-desktop-text">{$t("save.paste")}</span>
|
||||||
<span id="paste-mobile-text">{$t("save.paste.long")}</span>
|
<span id="paste-mobile-text">{$t("save.paste.long")}</span>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if clipMode && validLink($link)}
|
||||||
|
<div class="clip-controls">
|
||||||
|
{#if metaLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>Loading video metadata...</span>
|
||||||
|
</div>
|
||||||
|
{:else if metaError}
|
||||||
|
<div class="error-state">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||||
|
</svg>
|
||||||
|
<span>{metaError}</span>
|
||||||
|
</div>
|
||||||
|
{:else if metadata}
|
||||||
|
<div class="clip-metadata">
|
||||||
|
<div class="metadata-header">
|
||||||
|
<h4>Clip</h4>
|
||||||
|
<div class="duration-badge">
|
||||||
|
{typeof metadata.duration === 'number' ? formatTime(metadata.duration) : 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-info">
|
||||||
|
<div class="video-title" title={metadata.title}>
|
||||||
|
{metadata.title}
|
||||||
|
</div>
|
||||||
|
{#if metadata.author}
|
||||||
|
<div class="video-author">
|
||||||
|
by {metadata.author}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClipRangeSlider
|
||||||
|
min={0}
|
||||||
|
max={metadata.duration || 0}
|
||||||
|
step={0.001}
|
||||||
|
bind:start={clipStart}
|
||||||
|
bind:end={clipEnd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="clip-time-display">
|
||||||
|
<div class="time-indicators">
|
||||||
|
<span class="start-time">{formatTime(clipStart)}</span>
|
||||||
|
<span class="duration-selected">
|
||||||
|
{formatTime(Math.max(0, clipEnd - clipStart))} selected
|
||||||
|
</span>
|
||||||
|
<span class="end-time">{formatTime(clipEnd)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -359,6 +467,140 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clip-controls {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--button);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px solid var(--button-stroke);
|
||||||
|
animation: slideIn 0.3s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
color: var(--gray);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--button-hover);
|
||||||
|
border-top: 2px solid var(--secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(237, 34, 54, 0.1);
|
||||||
|
border: 1px solid rgba(237, 34, 54, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-metadata {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-badge {
|
||||||
|
background: var(--button-elevated);
|
||||||
|
color: var(--button-text);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-author {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-time-display {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-indicators {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-time, .end-time {
|
||||||
|
color: var(--gray);
|
||||||
|
background: var(--button-hover);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-selected {
|
||||||
|
grid-area: duration;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 440px) {
|
@media screen and (max-width: 440px) {
|
||||||
#action-container {
|
#action-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -376,5 +618,36 @@
|
|||||||
#paste-desktop-text {
|
#paste-desktop-text {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clip-controls {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-indicators {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"start end"
|
||||||
|
"duration duration";
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-time {
|
||||||
|
grid-area: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-time {
|
||||||
|
grid-area: end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
100
web/src/components/save/buttons/ClipCheckbox.svelte
Normal file
100
web/src/components/save/buttons/ClipCheckbox.svelte
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { hapticSwitch } from "$lib/haptics";
|
||||||
|
|
||||||
|
export let checked: boolean;
|
||||||
|
export let onclick: () => void;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
hapticSwitch();
|
||||||
|
onclick();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="button-clip-checkbox"
|
||||||
|
class="clip-toggle button"
|
||||||
|
class:active={checked}
|
||||||
|
on:click={handleClick}
|
||||||
|
aria-pressed={checked}
|
||||||
|
aria-label="Toggle clip mode"
|
||||||
|
>
|
||||||
|
<div class="toggle-icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||||
|
<polyline points="8,12 12,16 16,8" opacity={checked ? 1 : 0.3} stroke-width={checked ? 2.5 : 2}></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="clip-label">Clip</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.clip-toggle {
|
||||||
|
position: relative;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
border: 1.5px solid transparent;
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-toggle:not(.active) {
|
||||||
|
background: var(--button);
|
||||||
|
color: var(--button-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-toggle.active {
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-toggle.active .toggle-icon {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon svg {
|
||||||
|
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-toggle.active .toggle-icon svg {
|
||||||
|
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-label {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.clip-toggle:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-toggle.active:hover {
|
||||||
|
background: var(--button-active-hover);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-toggle:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 440px) {
|
||||||
|
.clip-toggle {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -6,10 +6,14 @@
|
|||||||
import { downloadButtonState } from "$lib/state/omnibox";
|
import { downloadButtonState } from "$lib/state/omnibox";
|
||||||
|
|
||||||
import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
|
import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
|
||||||
|
import type { CobaltSaveRequestBody } from "$lib/types/api";
|
||||||
|
|
||||||
export let url: string;
|
export let url: string;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
export let loading = false;
|
export let loading = false;
|
||||||
|
export let clipMode = false;
|
||||||
|
export let clipStart: number
|
||||||
|
export let clipEnd: number
|
||||||
|
|
||||||
$: buttonText = ">>";
|
$: buttonText = ">>";
|
||||||
$: buttonAltText = $t("a11y.save.download");
|
$: buttonAltText = $t("a11y.save.download");
|
||||||
@ -56,7 +60,16 @@
|
|||||||
{disabled}
|
{disabled}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
hapticSwitch();
|
hapticSwitch();
|
||||||
|
if (clipMode) {
|
||||||
|
const req: CobaltSaveRequestBody & { clipStart?: number; clipEnd?: number } = {
|
||||||
|
url,
|
||||||
|
clipStart,
|
||||||
|
clipEnd,
|
||||||
|
};
|
||||||
|
savingHandler({ request: req });
|
||||||
|
} else {
|
||||||
savingHandler({ url });
|
savingHandler({ url });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={buttonAltText}
|
aria-label={buttonAltText}
|
||||||
>
|
>
|
||||||
|
@ -137,7 +137,26 @@ const probeCobaltTunnel = async (url: string) => {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMetadata = async (url: string) => {
|
||||||
|
const api = currentApiURL().replace(/\/?$/, '/metadata');
|
||||||
|
const authorization = await getAuthorization();
|
||||||
|
let extraHeaders = {};
|
||||||
|
if (authorization && typeof authorization === "string") {
|
||||||
|
extraHeaders = { "Authorization": authorization };
|
||||||
|
}
|
||||||
|
return fetch(api, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...extraHeaders,
|
||||||
|
},
|
||||||
|
}).then(r => r.json());
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
request,
|
request,
|
||||||
probeCobaltTunnel,
|
probeCobaltTunnel,
|
||||||
|
getMetadata,
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
|||||||
|
|
||||||
if (!request && !url) return;
|
if (!request && !url) return;
|
||||||
|
|
||||||
const selectedRequest = request || {
|
const selectedRequest = {
|
||||||
url: url!,
|
url: url!,
|
||||||
|
|
||||||
// not lazy cuz default depends on device capabilities
|
// not lazy cuz default depends on device capabilities
|
||||||
@ -67,6 +67,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
|||||||
|
|
||||||
allowH265: getSetting("save", "allowH265"),
|
allowH265: getSetting("save", "allowH265"),
|
||||||
convertGif: getSetting("save", "convertGif"),
|
convertGif: getSetting("save", "convertGif"),
|
||||||
|
...request,
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await API.request(selectedRequest);
|
const response = await API.request(selectedRequest);
|
||||||
|
Loading…
Reference in New Issue
Block a user