app: clipping prototype

This commit is contained in:
Alenvelocity 2025-07-01 09:34:05 +05:30
parent 25042742ad
commit d965cf4da3
15 changed files with 917 additions and 45 deletions

View File

@ -32,6 +32,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.4.1",
"ffmpeg-static": "^5.1.0",
"ffprobe-static": "^3.1.0",
"hls-parser": "^0.10.7",
"ipaddr.js": "2.2.0",
"mime": "^4.0.4",

View File

@ -278,44 +278,71 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
app.post('/metadata', apiLimiter);
app.post('/metadata', async (req, res) => {
const request = req.body;
if (!request.url) {
return fail(res, "error.api.link.missing");
}
const { success, data: normalizedRequest } = await normalizeRequest(request);
if (!success) {
return fail(res, "error.api.invalid_body");
}
const parsed = extract(
normalizedRequest.url,
APIKeys.getAllowedServices(req.rateLimitKey),
);
if (!parsed) {
return fail(res, "error.api.link.invalid");
}
if ("error" in parsed) {
let context;
if (parsed?.context) {
context = parsed.context;
try {
const request = req.body;
if (!request.url) {
return fail(res, "error.api.link.missing");
}
return fail(res, `error.api.${parsed.error}`, context);
}
if (parsed.host === "youtube") {
const youtube = (await import("../processing/services/youtube.js")).default;
const info = await youtube({ id: parsed.patternMatch.id });
if (info.error) {
return fail(res, info.error);
const { success, data: normalizedRequest } = await normalizeRequest(request);
if (!success) {
return fail(res, "error.api.invalid_body");
}
const meta = {
title: info.fileMetadata?.title,
duration: info.duration || null,
thumbnail: info.cover || null,
author: info.fileMetadata?.artist || null,
const parsed = extract(
normalizedRequest.url,
APIKeys.getAllowedServices(req.rateLimitKey),
);
if (!parsed) {
return fail(res, "error.api.link.invalid");
}
if ("error" in parsed) {
let context;
if (parsed?.context) {
context = parsed.context;
}
return fail(res, `error.api.${parsed.error}`, context);
}
if (parsed.host !== "youtube") {
return res.status(501).json({
status: "error",
code: "not_implemented",
message: "Metadata endpoint is only implemented for YouTube."
});
}
const youtube = (await import("../processing/services/youtube.js")).default;
const fetchInfo = {
id: parsed.patternMatch.id.slice(0, 11),
metadataOnly: true,
};
return res.json({ status: "success", metadata: meta });
} else {
return res.status(501).json({ status: "error", code: "not_implemented", message: "Metadata endpoint is only implemented for YouTube." });
const result = await youtube(fetchInfo);
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");
}
});

View File

@ -36,6 +36,8 @@ export default function({
subtitles: r.subtitles,
cover: !disableMetadata ? r.cover : false,
cropCover: !disableMetadata ? r.cropCover : false,
clipStart: r.clipStart,
clipEnd: r.clipEnd,
},
params = {};

View File

@ -123,6 +123,21 @@ export default async function({ host, patternMatch, params, authType }) {
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) {
fetchInfo.quality = "1080";
fetchInfo.codec = "vp9";

View File

@ -60,5 +60,8 @@ export const apiSchema = z.object({
youtubeHLS: z.boolean().default(false),
youtubeBetterAudio: z.boolean().default(false),
clipStart: z.number().min(0).optional(),
clipEnd: z.number().min(0).optional(),
})
.strict();

View File

@ -291,6 +291,13 @@ export default async function (o) {
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"
// or a similar stub by youtube
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) {
fileMetadata.sublanguage = subtitles.language;
}
@ -553,6 +581,8 @@ export default async function (o) {
cover,
cropCover: basicInfo.author.endsWith("- Topic"),
clipStart: o.clipStart,
clipEnd: o.clipEnd,
}
}
@ -599,6 +629,8 @@ export default async function (o) {
isHLS: useHLS,
originalRequest,
duration: basicInfo.duration,
clipStart: o.clipStart,
clipEnd: o.clipEnd,
}
}

View File

@ -99,9 +99,18 @@ const render = async (res, streamInfo, ffargs, estimateMultiplier) => {
const remux = async (streamInfo, res) => {
const format = streamInfo.filename.split('.').pop();
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) {
return closeResponse(res);
}
@ -126,10 +135,19 @@ const remux = async (streamInfo, res) => {
);
}
args.push(
'-c:v', 'copy',
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
);
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(
'-c:v', 'copy',
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
);
}
if (format === 'mp4') {
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');
await render(res, streamInfo, args);
await render(res, streamInfo, args, estimateAudioMultiplier(streamInfo) * 1.1);
}
const convertAudio = async (streamInfo, res) => {
const args = [
let args = [];
args.push(
'-i', streamInfo.urls,
'-vn',
...(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') {
args.push('-ar', '12000');

View File

@ -45,6 +45,9 @@ export function createStream(obj) {
// url to a subtitle file
subtitles: obj.subtitles,
clipStart: obj.clipStart,
clipEnd: obj.clipEnd,
};
// FIXME: this is now a Promise, but it is not awaited

View File

@ -37,6 +37,9 @@ importers:
ffmpeg-static:
specifier: ^5.1.0
version: 5.2.0
ffprobe-static:
specifier: ^3.1.0
version: 3.1.0
hls-parser:
specifier: ^0.10.7
version: 0.10.9
@ -1254,6 +1257,9 @@ packages:
resolution: {integrity: sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==}
engines: {node: '>=16'}
ffprobe-static@3.1.0:
resolution: {integrity: sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@ -3189,6 +3195,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
ffprobe-static@3.1.0: {}
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1

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

View File

@ -18,6 +18,7 @@
import type { Optional } from "$lib/types/generic";
import type { DownloadModeOption } from "$lib/types/settings";
import type { CobaltSaveRequestBody } from "$lib/types/api";
import ClearButton from "$components/save/buttons/ClearButton.svelte";
import DownloadButton from "$components/save/buttons/DownloadButton.svelte";
@ -33,6 +34,10 @@
import IconSparkles from "$components/icons/Sparkles.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>;
const validLink = (url: string) => {
@ -58,6 +63,40 @@
let downloadable = $derived(validLink($link));
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 (() => {
if (linkPrefill) {
// prefilled link may be uri encoded
@ -139,6 +178,12 @@
break;
}
};
function formatTime(seconds: number) {
const m = Math.floor(seconds / 60);
const s = (seconds % 60).toFixed(3).padStart(6, '0');
return `${m}:${s}`;
}
</script>
<svelte:window onkeydown={handleKeydown} />
@ -192,6 +237,9 @@
<ClearButton click={() => ($link = "")} />
<DownloadButton
url={$link}
clipMode={clipMode}
clipStart={clipStart}
clipEnd={clipEnd}
bind:disabled={isDisabled}
bind:loading={isLoading}
/>
@ -225,12 +273,72 @@
</SettingsButton>
</Switcher>
<ClipCheckbox checked={clipMode} onclick={() => clipMode = !clipMode} />
<ActionButton id="paste" click={pasteClipboard}>
<IconClipboard />
<span id="paste-desktop-text">{$t("save.paste")}</span>
<span id="paste-mobile-text">{$t("save.paste.long")}</span>
</ActionButton>
</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>
<style>
@ -359,6 +467,140 @@
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) {
#action-container {
flex-direction: column;
@ -376,5 +618,36 @@
#paste-desktop-text {
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>

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

View File

@ -6,10 +6,14 @@
import { downloadButtonState } from "$lib/state/omnibox";
import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
import type { CobaltSaveRequestBody } from "$lib/types/api";
export let url: string;
export let disabled = false;
export let loading = false;
export let clipMode = false;
export let clipStart: number
export let clipEnd: number
$: buttonText = ">>";
$: buttonAltText = $t("a11y.save.download");
@ -56,7 +60,16 @@
{disabled}
on:click={() => {
hapticSwitch();
savingHandler({ url });
if (clipMode) {
const req: CobaltSaveRequestBody & { clipStart?: number; clipEnd?: number } = {
url,
clipStart,
clipEnd,
};
savingHandler({ request: req });
} else {
savingHandler({ url });
}
}}
aria-label={buttonAltText}
>

View File

@ -137,7 +137,26 @@ const probeCobaltTunnel = async (url: string) => {
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 {
request,
probeCobaltTunnel,
getMetadata,
}

View File

@ -41,7 +41,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
if (!request && !url) return;
const selectedRequest = request || {
const selectedRequest = {
url: url!,
// not lazy cuz default depends on device capabilities
@ -67,6 +67,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
allowH265: getSetting("save", "allowH265"),
convertGif: getSetting("save", "convertGif"),
...request,
}
const response = await API.request(selectedRequest);