mirror of https://github.com/VERT-sh/VERT.git
Merge remote-tracking branch 'upstream/main' into file-name-options
This commit is contained in:
commit
174e568fe4
16
README.md
16
README.md
|
@ -49,6 +49,22 @@ To build the project for production, run `bun run build`
|
|||
|
||||
This will build the site to the `build` folder. You can then start the server with `bun run preview` and navigate to `http://localhost:4173` to see the application.
|
||||
|
||||
### With Docker
|
||||
|
||||
Clone the repository, then build a Docker image with:
|
||||
```shell
|
||||
$ docker build -t not-nullptr/vert \
|
||||
--build-arg PUB_HOSTNAME=vert.sh \
|
||||
--build-arg PUB_PLAUSIBLE_URL=https://plausible.example.com .
|
||||
```
|
||||
|
||||
You can then run it by using:
|
||||
```shell
|
||||
$ docker run --restart unless-stopped -p 3000:3000 -d --name "vert" not-nullptr/vert
|
||||
```
|
||||
|
||||
We also have a `docker-compose.yml` file available. Use `docker compose up` if you want to start the stack, or `docker compose down` to bring it down. You can pass `--build` to `docker compose up` to rebuild the Docker image (useful if you've changed any of the environment variables) as well as `-d` to start it in dettached mode. You can read more about Docker Compose in general [here](https://docs.docker.com/compose/intro/compose-application-model/).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the AGPL-3.0 License, please see the [LICENSE](LICENSE) file for details.
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsmediatags": "^3.9.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.7.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
@ -40,9 +42,13 @@
|
|||
"@imagemagick/magick-wasm": "^0.0.31",
|
||||
"client-zip": "^2.4.5",
|
||||
"clsx": "^2.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsmediatags": "^3.9.7",
|
||||
"lucide-svelte": "^0.456.0",
|
||||
"svelte-adapter-bun": "^0.5.2",
|
||||
"typescript-cookie": "^1.0.6",
|
||||
"wasm-vips": "^0.0.11"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"jsmediatags@3.9.7": "patches/jsmediatags@3.9.7.patch"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
diff --git a/package.json b/package.json
|
||||
index 1265c61a16be5dc94dea97e1a7bcd117b0b5c0fe..602a37452738d778bf705b7a2931a661e363e33c 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -18,8 +18,8 @@
|
||||
"email": "jesse.ditson@gmail.com"
|
||||
}
|
||||
],
|
||||
- "main": "build2/jsmediatags.js",
|
||||
- "browser": "dist/jsmediatags.js",
|
||||
+ "main": "dist/jsmediatags.min.js",
|
||||
+ "browser": "dist/jsmediatags.min.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/aadsm/jsmediatags.git"
|
|
@ -5,7 +5,8 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||
if (theme !== "dark" && theme !== "light") {
|
||||
event.cookies.set("theme", "", {
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
sameSite: "lax",
|
||||
expires: new Date(2147483647 * 1000),
|
||||
});
|
||||
theme = "";
|
||||
}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
<script lang="ts">
|
||||
type Props = {
|
||||
progress: number | null;
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
let { progress, min, max }: Props = $props();
|
||||
|
||||
const percent = $derived(
|
||||
progress ? ((progress - min) / (max - min)) * 100 : null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-full h-1 dynadark:bg-foreground-muted-alt bg-foreground-muted rounded-full overflow-hidden relative"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-accent-background dynadark:bg-accent-foreground absolute left-0 top-0"
|
||||
class:percentless-animation={progress === null}
|
||||
style={percent
|
||||
? `width: ${percent}%; transition: 500ms linear width;`
|
||||
: ""}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.percentless-animation {
|
||||
width: 100%;
|
||||
animation:
|
||||
percentless-animation 1s ease infinite,
|
||||
left-right 1s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes percentless-animation {
|
||||
0% {
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
50% {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes left-right {
|
||||
49% {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
50% {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -47,7 +47,7 @@
|
|||
class="absolute w-full h-full"
|
||||
style="
|
||||
z-index: {index + 2};
|
||||
backdrop-filter: blur({blurIntensity}px);
|
||||
backdrop-filter: blur( calc({blurIntensity}px * var(--blur-amount, 1)) );
|
||||
mask: {mask};
|
||||
"
|
||||
></div>
|
||||
|
@ -63,6 +63,6 @@
|
|||
></div>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full z-50"
|
||||
style="background: linear-gradient({getGradientDirection()}, transparent 0%, {fadeTo} 100%);"
|
||||
style="background: linear-gradient({getGradientDirection()}, transparent 0%, {fadeTo} 100%); opacity: var(--blur-amount, 1);"
|
||||
></div>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { IFile, OmitBetterStrict } from "$lib/types";
|
||||
import type { VertFile } from "$lib/types";
|
||||
|
||||
/**
|
||||
* Base class for all converters.
|
||||
|
@ -18,13 +18,14 @@ export class Converter {
|
|||
* @param to The format to convert to. Includes the dot.
|
||||
*/
|
||||
public ready: boolean = $state(false);
|
||||
public readonly reportsProgress: boolean = false;
|
||||
|
||||
public async convert(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
input: OmitBetterStrict<IFile, "extension">,
|
||||
input: VertFile,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
to: string,
|
||||
): Promise<IFile> {
|
||||
): Promise<VertFile> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { IFile } from "$lib/types";
|
||||
import { VertFile } from "$lib/types";
|
||||
import { Converter } from "./converter.svelte";
|
||||
import type { OmitBetterStrict } from "$lib/types";
|
||||
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
||||
import { browser } from "$app/environment";
|
||||
import { log } from "$lib/logger";
|
||||
|
@ -25,6 +24,8 @@ export class FFmpegConverter extends Converter {
|
|||
".aiff",
|
||||
];
|
||||
|
||||
public readonly reportsProgress = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
log(["converters", this.name], `created converter`);
|
||||
|
@ -42,19 +43,19 @@ export class FFmpegConverter extends Converter {
|
|||
})();
|
||||
}
|
||||
|
||||
public async convert(
|
||||
input: OmitBetterStrict<IFile, "extension">,
|
||||
to: string,
|
||||
): Promise<IFile> {
|
||||
public async convert(input: VertFile, to: string): Promise<VertFile> {
|
||||
if (!to.startsWith(".")) to = `.${to}`;
|
||||
const ffmpeg = new FFmpeg();
|
||||
ffmpeg.on("progress", (progress) => {
|
||||
input.progress = progress.progress * 100;
|
||||
});
|
||||
const baseURL =
|
||||
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
|
||||
await ffmpeg.load({
|
||||
coreURL: `${baseURL}/ffmpeg-core.js`,
|
||||
wasmURL: `${baseURL}/ffmpeg-core.wasm`,
|
||||
});
|
||||
const buf = new Uint8Array(input.buffer);
|
||||
const buf = new Uint8Array(await input.file.arrayBuffer());
|
||||
await ffmpeg.writeFile("input", buf);
|
||||
log(
|
||||
["converters", this.name],
|
||||
|
@ -70,10 +71,6 @@ export class FFmpegConverter extends Converter {
|
|||
`read ${input.name.split(".").slice(0, -1).join(".") + to} from ffmpeg virtual fs`,
|
||||
);
|
||||
ffmpeg.terminate();
|
||||
return {
|
||||
...input,
|
||||
buffer: output.buffer,
|
||||
extension: to,
|
||||
};
|
||||
return new VertFile(new File([output], input.name), to);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,82 +0,0 @@
|
|||
import type { IFile } from "$lib/types";
|
||||
import { Converter } from "./converter.svelte";
|
||||
import MagickWorker from "$lib/workers/magick?worker";
|
||||
import { browser } from "$app/environment";
|
||||
import type { WorkerMessage, OmitBetterStrict } from "$lib/types";
|
||||
import { MagickFormat } from "@imagemagick/magick-wasm";
|
||||
|
||||
const sortFirst = [".png", ".jpeg", ".jpg", ".webp", ".gif"];
|
||||
|
||||
export class MagickConverter extends Converter {
|
||||
private worker: Worker = browser ? new MagickWorker() : null!;
|
||||
private id = 0;
|
||||
public name = "imagemagick";
|
||||
public ready = $state(false);
|
||||
public supportedFormats = Object.keys(MagickFormat)
|
||||
.map((key) => `.${key.toLowerCase()}`)
|
||||
.sort((a, b) => {
|
||||
const aIndex = sortFirst.indexOf(a);
|
||||
const bIndex = sortFirst.indexOf(b);
|
||||
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
|
||||
if (aIndex === -1) return 1;
|
||||
if (bIndex === -1) return -1;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if (!browser) return;
|
||||
this.worker.onmessage = (e) => {
|
||||
const message: WorkerMessage = e.data;
|
||||
if (message.type === "loaded") this.ready = true;
|
||||
};
|
||||
}
|
||||
|
||||
public async convert(
|
||||
input: OmitBetterStrict<IFile, "extension">,
|
||||
to: string,
|
||||
): Promise<IFile> {
|
||||
const res = await this.sendMessage({
|
||||
type: "convert",
|
||||
input: input as unknown as IFile,
|
||||
to,
|
||||
});
|
||||
|
||||
if (res.type === "finished") {
|
||||
return res.output;
|
||||
}
|
||||
|
||||
if (res.type === "error") {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
throw new Error("Unknown message type");
|
||||
}
|
||||
|
||||
private sendMessage(
|
||||
message: OmitBetterStrict<WorkerMessage, "id">,
|
||||
): Promise<OmitBetterStrict<WorkerMessage, "id">> {
|
||||
const id = this.id++;
|
||||
let resolved = false;
|
||||
return new Promise((resolve) => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.data.id === id) {
|
||||
this.worker.removeEventListener("message", onMessage);
|
||||
resolve(e.data);
|
||||
resolved = true;
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
this.worker.removeEventListener("message", onMessage);
|
||||
throw new Error("Timeout");
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
this.worker.addEventListener("message", onMessage);
|
||||
|
||||
this.worker.postMessage({ ...message, id });
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import type { IFile } from "$lib/types";
|
||||
import { VertFile } from "$lib/types";
|
||||
import { Converter } from "./converter.svelte";
|
||||
import VipsWorker from "$lib/workers/vips?worker";
|
||||
import { browser } from "$app/environment";
|
||||
|
@ -29,6 +29,8 @@ export class VipsConverter extends Converter {
|
|||
".tiff",
|
||||
];
|
||||
|
||||
public readonly reportsProgress = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
log(["converters", this.name], `created converter`);
|
||||
|
@ -39,20 +41,23 @@ export class VipsConverter extends Converter {
|
|||
};
|
||||
}
|
||||
|
||||
public async convert(
|
||||
input: OmitBetterStrict<IFile, "extension">,
|
||||
to: string,
|
||||
): Promise<IFile> {
|
||||
public async convert(input: VertFile, to: string): Promise<VertFile> {
|
||||
log(["converters", this.name], `converting ${input.name} to ${to}`);
|
||||
const res = await this.sendMessage({
|
||||
const msg = {
|
||||
type: "convert",
|
||||
input: input as unknown as IFile,
|
||||
input: {
|
||||
file: input.file,
|
||||
name: input.name,
|
||||
to: input.to,
|
||||
from: input.from,
|
||||
},
|
||||
to,
|
||||
});
|
||||
} as WorkerMessage;
|
||||
const res = await this.sendMessage(msg);
|
||||
|
||||
if (res.type === "finished") {
|
||||
log(["converters", this.name], `converted ${input.name} to ${to}`);
|
||||
return res.output;
|
||||
return new VertFile(new File([res.output], input.name), to);
|
||||
}
|
||||
|
||||
if (res.type === "error") {
|
||||
|
@ -84,8 +89,12 @@ export class VipsConverter extends Converter {
|
|||
}, 60000);
|
||||
|
||||
this.worker.addEventListener("message", onMessage);
|
||||
|
||||
this.worker.postMessage({ ...message, id });
|
||||
const msg = { ...message, id, worker: null };
|
||||
try {
|
||||
this.worker.postMessage(msg);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,20 @@
|
|||
import { log } from "$lib/logger";
|
||||
import type { IFile } from "$lib/types";
|
||||
import { VertFile } from "$lib/types";
|
||||
import JSCookie from "js-cookie";
|
||||
|
||||
class Files {
|
||||
public files = $state<
|
||||
{
|
||||
file: File;
|
||||
from: string;
|
||||
to: string;
|
||||
blobUrl: string;
|
||||
id: string;
|
||||
result?: (IFile & { blobUrl: string; animating: boolean }) | null;
|
||||
}[]
|
||||
>([]);
|
||||
public files = $state<VertFile[]>([]);
|
||||
}
|
||||
|
||||
class Theme {
|
||||
public dark = $state(false);
|
||||
public toggle = () => {
|
||||
this.dark = !this.dark;
|
||||
JSCookie.set("theme", this.dark ? "dark" : "light", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
expires: 2147483647,
|
||||
});
|
||||
log(["theme"], `set to ${this.dark ? "dark" : "light"}`);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import type { IFile } from "./file";
|
||||
import { VertFile } from "./file.svelte";
|
||||
|
||||
interface ConvertMessage {
|
||||
type: "convert";
|
||||
input: IFile;
|
||||
input: VertFile;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface FinishedMessage {
|
||||
type: "finished";
|
||||
output: IFile;
|
||||
output: ArrayBufferLike;
|
||||
}
|
||||
|
||||
interface LoadedMessage {
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import type { Converter } from "$lib/converters/converter.svelte";
|
||||
|
||||
export class VertFile {
|
||||
public id: string = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
public get from() {
|
||||
return "." + this.file.name.split(".").pop()!;
|
||||
}
|
||||
|
||||
public get name() {
|
||||
return this.file.name;
|
||||
}
|
||||
|
||||
public progress = $state(0);
|
||||
public result = $state<VertFile | null>(null);
|
||||
|
||||
public to = $state("");
|
||||
|
||||
public blobUrl = $state<string>();
|
||||
|
||||
public converter: Converter | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly file: File,
|
||||
to: string,
|
||||
converter?: Converter,
|
||||
blobUrl?: string,
|
||||
) {
|
||||
this.to = to;
|
||||
this.converter = converter ?? null;
|
||||
this.convert = this.convert.bind(this);
|
||||
this.download = this.download.bind(this);
|
||||
this.blobUrl = blobUrl;
|
||||
}
|
||||
|
||||
public async convert() {
|
||||
console.log(this.converter);
|
||||
if (!this.converter) throw new Error("No converter found");
|
||||
this.result = null;
|
||||
this.progress = 0;
|
||||
const res = await this.converter.convert(this, this.to);
|
||||
this.result = res;
|
||||
return res;
|
||||
}
|
||||
|
||||
public async download() {
|
||||
if (!this.result) throw new Error("No result found");
|
||||
const blob = URL.createObjectURL(
|
||||
new Blob([await this.result.file.arrayBuffer()], {
|
||||
type: this.to.slice(1),
|
||||
}),
|
||||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = blob;
|
||||
a.download = `VERT-Converted_${new Date().toISOString()}${this.to}`;
|
||||
// force it to not open in a new tab
|
||||
a.target = "_blank";
|
||||
a.style.display = "none";
|
||||
a.click();
|
||||
URL.revokeObjectURL(blob);
|
||||
a.remove();
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
export interface IFile {
|
||||
name: string;
|
||||
extension: string;
|
||||
buffer: ArrayBuffer;
|
||||
}
|
|
@ -1,3 +1,3 @@
|
|||
export * from "./file";
|
||||
export * from "./file.svelte";
|
||||
export * from "./util";
|
||||
export * from "./conversion-worker";
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
import type { WorkerMessage, OmitBetterStrict } from "$lib/types";
|
||||
import {
|
||||
ImageMagick,
|
||||
initializeImageMagick,
|
||||
MagickFormat,
|
||||
} from "@imagemagick/magick-wasm";
|
||||
import wasmUrl from "@imagemagick/magick-wasm/magick.wasm?url";
|
||||
|
||||
const magickPromise = fetch(wasmUrl)
|
||||
.then((r) => r.arrayBuffer())
|
||||
.then((r) => initializeImageMagick(r));
|
||||
|
||||
magickPromise
|
||||
.then(() => {
|
||||
postMessage({ type: "loaded" });
|
||||
})
|
||||
.catch((error) => {
|
||||
postMessage({ type: "error", error });
|
||||
});
|
||||
|
||||
const handleMessage = async (
|
||||
message: WorkerMessage,
|
||||
): Promise<OmitBetterStrict<WorkerMessage, "id"> | undefined> => {
|
||||
await magickPromise;
|
||||
switch (message.type) {
|
||||
case "convert": {
|
||||
if (!message.to.startsWith(".")) message.to = `.${message.to}`;
|
||||
message.to = message.to.slice(1);
|
||||
|
||||
// unfortunately this lib uses some hacks to dispose images when the promise is resolved
|
||||
// this means we can't promisify it :(
|
||||
return new Promise((resolve) => {
|
||||
ImageMagick.read(
|
||||
new Uint8Array(message.input.buffer),
|
||||
(img) => {
|
||||
const keys = Object.keys(MagickFormat);
|
||||
const values = Object.values(MagickFormat);
|
||||
const index = keys.findIndex(
|
||||
(key) =>
|
||||
key.toLowerCase() === message.to.toLowerCase(),
|
||||
);
|
||||
const format = values[index];
|
||||
img.write(format, (output) => {
|
||||
resolve({
|
||||
type: "finished",
|
||||
output: {
|
||||
...message.input,
|
||||
buffer: output,
|
||||
extension: message.to,
|
||||
},
|
||||
});
|
||||
});
|
||||
img.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onmessage = async (e) => {
|
||||
const message: WorkerMessage = e.data;
|
||||
try {
|
||||
const res = await handleMessage(message);
|
||||
if (!res) return;
|
||||
postMessage({
|
||||
...res,
|
||||
id: message.id,
|
||||
});
|
||||
} catch (e) {
|
||||
postMessage({
|
||||
type: "error",
|
||||
error: e,
|
||||
id: message.id,
|
||||
});
|
||||
}
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import type { WorkerMessage, OmitBetterStrict } from "$lib/types";
|
||||
import { type WorkerMessage, type OmitBetterStrict } from "$lib/types";
|
||||
import Vips from "wasm-vips";
|
||||
|
||||
const vipsPromise = Vips({
|
||||
|
@ -21,16 +21,14 @@ const handleMessage = async (
|
|||
switch (message.type) {
|
||||
case "convert": {
|
||||
if (!message.to.startsWith(".")) message.to = `.${message.to}`;
|
||||
const image = vips.Image.newFromBuffer(message.input.buffer);
|
||||
const image = vips.Image.newFromBuffer(
|
||||
await message.input.file.arrayBuffer(),
|
||||
);
|
||||
const output = image.writeToBuffer(message.to);
|
||||
image.delete();
|
||||
return {
|
||||
type: "finished",
|
||||
output: {
|
||||
...message.input,
|
||||
buffer: output.buffer,
|
||||
extension: message.to,
|
||||
},
|
||||
output: output.buffer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,20 @@
|
|||
export const load = ({ url, request }) => {
|
||||
export const load = ({ url, request, cookies }) => {
|
||||
// if the "theme" cookie isn't "dark" or "light", reset it
|
||||
const theme = cookies.get("theme") ?? "";
|
||||
if (theme !== "dark" && theme !== "light") {
|
||||
cookies.set("theme", "", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
expires: new Date(0),
|
||||
});
|
||||
}
|
||||
const { pathname } = url;
|
||||
const ua = request.headers.get("user-agent");
|
||||
const isMobile = /mobile/i.test(ua || "");
|
||||
const isFirefox = /firefox/i.test(ua || "");
|
||||
return {
|
||||
pathname,
|
||||
isMobile,
|
||||
isFirefox,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
import { writable } from "svelte/store";
|
||||
import { MoonIcon, SunIcon } from "lucide-svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { setCookie } from "typescript-cookie";
|
||||
import JSCookie from "js-cookie";
|
||||
let { children, data } = $props();
|
||||
|
||||
let shouldGoBack = writable(false);
|
||||
|
@ -55,14 +55,18 @@
|
|||
if (theme.dark) {
|
||||
document.body.classList.add("dark");
|
||||
document.body.classList.remove("light");
|
||||
setCookie("theme", "dark", {
|
||||
sameSite: "strict",
|
||||
JSCookie.set("theme", "dark", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
expires: 2147483647,
|
||||
});
|
||||
} else {
|
||||
document.body.classList.add("light");
|
||||
document.body.classList.remove("dark");
|
||||
setCookie("theme", "light", {
|
||||
sameSite: "strict",
|
||||
JSCookie.set("theme", "light", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
expires: 2147483647,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import { browser } from "$app/environment";
|
||||
import { theme } from "$lib/store/index.svelte";
|
||||
import { getCookie, setCookie } from "typescript-cookie";
|
||||
import JSCookie from "js-cookie";
|
||||
|
||||
export const load = ({ data }) => {
|
||||
if (!browser) return;
|
||||
const themeStr = getCookie("theme");
|
||||
const themeStr = JSCookie.get("theme");
|
||||
if (typeof themeStr === "undefined") {
|
||||
theme.dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setCookie("theme", theme.dark ? "dark" : "light", {
|
||||
JSCookie.set("theme", theme.dark ? "dark" : "light", {
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
expires: 2147483647,
|
||||
});
|
||||
} else {
|
||||
theme.dark = themeStr === "dark";
|
||||
}
|
||||
theme.dark = getCookie("theme") === "dark";
|
||||
theme.dark = JSCookie.get("theme") === "dark";
|
||||
return data;
|
||||
};
|
||||
|
|
|
@ -2,9 +2,12 @@
|
|||
import { goto } from "$app/navigation";
|
||||
import Uploader from "$lib/components/functional/Uploader.svelte";
|
||||
import { converters } from "$lib/converters";
|
||||
import { log } from "$lib/logger/index.js";
|
||||
import { log } from "$lib/logger";
|
||||
import { files } from "$lib/store/index.svelte";
|
||||
import { VertFile } from "$lib/types/file.svelte";
|
||||
import { Check } from "lucide-svelte";
|
||||
import jsmediatags from "jsmediatags";
|
||||
import type { TagType } from "jsmediatags/types/index.js";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
|
@ -42,31 +45,55 @@
|
|||
ctx?.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
// get the blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
resolve({
|
||||
file: f,
|
||||
from,
|
||||
to,
|
||||
blobUrl:
|
||||
blob === null
|
||||
? ""
|
||||
: URL.createObjectURL(blob),
|
||||
id: Math.random().toString(36).substring(2),
|
||||
});
|
||||
async (blob) => {
|
||||
resolve(
|
||||
new VertFile(
|
||||
f,
|
||||
to,
|
||||
converter,
|
||||
URL.createObjectURL(blob!),
|
||||
),
|
||||
);
|
||||
},
|
||||
"image/jpeg",
|
||||
0.75,
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
resolve({
|
||||
file: f,
|
||||
from,
|
||||
to,
|
||||
blobUrl: "",
|
||||
id: Math.random().toString(36).substring(2),
|
||||
});
|
||||
img.onerror = async () => {
|
||||
// resolve(new VertFile(f, to, converter));
|
||||
const reader = new FileReader();
|
||||
const file = new VertFile(f, to, converter);
|
||||
resolve(file);
|
||||
reader.onload = async (e) => {
|
||||
const tags = await new Promise<TagType>(
|
||||
(resolve, reject) => {
|
||||
jsmediatags.read(
|
||||
new Blob([
|
||||
new Uint8Array(
|
||||
e.target?.result as ArrayBuffer,
|
||||
),
|
||||
]),
|
||||
{
|
||||
onSuccess: (tag) => resolve(tag),
|
||||
onError: (error) => reject(error),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const picture = tags.tags.picture;
|
||||
if (!picture) return;
|
||||
|
||||
const blob = new Blob(
|
||||
[new Uint8Array(picture.data)],
|
||||
{
|
||||
type: picture.format,
|
||||
},
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
file.blobUrl = url;
|
||||
};
|
||||
reader.readAsArrayBuffer(f);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
|
@ -3,14 +3,23 @@
|
|||
import { blur, duration, flip } from "$lib/animation";
|
||||
import Dropdown from "$lib/components/functional/Dropdown.svelte";
|
||||
import ProgressiveBlur from "$lib/components/visual/effects/ProgressiveBlur.svelte";
|
||||
import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
|
||||
import { converters } from "$lib/converters";
|
||||
import type { Converter } from "$lib/converters/converter.svelte";
|
||||
import { log } from "$lib/logger";
|
||||
import { files, outputFilenameOption } from "$lib/store/index.svelte";
|
||||
import type { VertFile } from "$lib/types";
|
||||
import clsx from "clsx";
|
||||
import { ArrowRight, XIcon } from "lucide-svelte";
|
||||
import { ArrowRight, Disc2Icon, FileAudioIcon, XIcon } from "lucide-svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import {
|
||||
fade,
|
||||
type EasingFunction,
|
||||
type TransitionConfig,
|
||||
} from "svelte/transition";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const reversedFiles = $derived(files.files.slice().reverse());
|
||||
|
||||
|
@ -18,8 +27,6 @@
|
|||
Array.from({ length: files.files.length }, () => false),
|
||||
);
|
||||
|
||||
let isSm = $state(false);
|
||||
|
||||
let processings = $state<boolean[]>([]);
|
||||
|
||||
const convertersRequired = $derived.by(() => {
|
||||
|
@ -51,11 +58,6 @@
|
|||
let outputFilename = $state(outputFilenameOption[0]);
|
||||
|
||||
onMount(() => {
|
||||
isSm = window.innerWidth < 640;
|
||||
window.addEventListener("resize", () => {
|
||||
isSm = window.innerWidth < 640;
|
||||
});
|
||||
|
||||
// reloads the "output filename" option
|
||||
const savedOption = localStorage.getItem("outputFilename");
|
||||
if (savedOption) {
|
||||
|
@ -80,37 +82,9 @@
|
|||
const promises: Promise<void>[] = [];
|
||||
for (let i = 0; i < files.files.length; i++) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const file = files.files[i];
|
||||
const converter = converters.find(
|
||||
(c) =>
|
||||
c.supportedFormats.includes(file.from) &&
|
||||
c.supportedFormats.includes(file.to),
|
||||
);
|
||||
if (!converter) throw new Error("No converter found");
|
||||
const to = file.to;
|
||||
processings[i] = true;
|
||||
const converted = await converter.convert(
|
||||
{
|
||||
name: file.file.name,
|
||||
buffer: await file.file.arrayBuffer(),
|
||||
},
|
||||
to,
|
||||
);
|
||||
files.files[i] = {
|
||||
...file,
|
||||
result: {
|
||||
...converted,
|
||||
blobUrl: URL.createObjectURL(
|
||||
new Blob([converted.buffer], {
|
||||
type: file.file.type,
|
||||
}),
|
||||
),
|
||||
animating: true,
|
||||
},
|
||||
};
|
||||
processings[i] = false;
|
||||
})(),
|
||||
(async (i) => {
|
||||
await convert(files.files[i], i);
|
||||
})(i),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -120,6 +94,13 @@
|
|||
log(["converter"], `converted all files in ${seconds}s`);
|
||||
};
|
||||
|
||||
const convert = async (file: VertFile, index: number) => {
|
||||
file.progress = 0;
|
||||
processings[index] = true;
|
||||
await file.convert();
|
||||
processings[index] = false;
|
||||
};
|
||||
|
||||
const downloadAll = async () => {
|
||||
const date = new Date().toISOString();
|
||||
const dlFiles: any[] = [];
|
||||
|
@ -133,7 +114,7 @@
|
|||
dlFiles.push({
|
||||
name: file.file.name.replace(/\.[^/.]+$/, "") + file.to,
|
||||
lastModified: Date.now(),
|
||||
input: result.buffer,
|
||||
input: await result.file.arrayBuffer(),
|
||||
});
|
||||
}
|
||||
if (files.files.length === 0) return;
|
||||
|
@ -166,6 +147,38 @@
|
|||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
};
|
||||
|
||||
const deleteAll = () => {
|
||||
files.files = [];
|
||||
goto("/");
|
||||
};
|
||||
|
||||
export const progBlur = (
|
||||
_: HTMLElement,
|
||||
config:
|
||||
| Partial<{
|
||||
duration: number;
|
||||
easing: EasingFunction;
|
||||
}>
|
||||
| undefined,
|
||||
dir: {
|
||||
direction: "in" | "out" | "both";
|
||||
},
|
||||
): TransitionConfig => {
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
if (!config) config = {};
|
||||
if (!config.duration) config.duration = 300;
|
||||
if (!config.easing) config.easing = quintOut;
|
||||
return {
|
||||
duration: prefersReducedMotion ? 0 : config?.duration || 300,
|
||||
css: (t) => {
|
||||
return "--blur-amount: " + (dir.direction !== "in" ? t : 1 - t);
|
||||
},
|
||||
easing: config?.easing,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
@ -182,7 +195,7 @@
|
|||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col gap-4 w-full items-center col-start-1 row-start-1"
|
||||
class="flex flex-col gap-4 w-full col-start-1 row-start-1"
|
||||
out:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
|
@ -248,7 +261,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="italic w-fit text-foreground-muted-alt h-11 flex items-center row-start-1 col-start-1"
|
||||
class="italic w-fit text-foreground-muted-alt flex items-center row-start-1 col-start-1"
|
||||
transition:blur={{
|
||||
blurMultiplier: 8,
|
||||
duration,
|
||||
|
@ -263,13 +276,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-3 mt-4">
|
||||
<div class="grid gap-3 sm:grid-cols-3 mt-4">
|
||||
<button
|
||||
onclick={convertAll}
|
||||
class={clsx("btn flex-grow", {
|
||||
"btn-highlight": disabled,
|
||||
"btn-highlight":
|
||||
disabled && !processings.some((p) => p),
|
||||
})}
|
||||
disabled={!allConvertersReady}
|
||||
disabled={!allConvertersReady ||
|
||||
processings.some((p) => p)}
|
||||
>
|
||||
{#if allConvertersReady}
|
||||
Convert {files.files.length > 1 ? "All" : ""}
|
||||
|
@ -285,148 +300,242 @@
|
|||
{disabled}
|
||||
>Download {files.files.length > 1 ? "All" : ""}</button
|
||||
>
|
||||
<button
|
||||
onclick={deleteAll}
|
||||
disabled={processings.some((p) => p)}
|
||||
class="btn flex-grow"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#each reversedFiles as file, i (file.id)}
|
||||
{@const converter = (() => {
|
||||
return converters.find((c) =>
|
||||
c.supportedFormats.includes(file.from),
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
class="w-full rounded-xl"
|
||||
animate:flip={{ duration, easing: quintOut }}
|
||||
out:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 16,
|
||||
}}
|
||||
>
|
||||
<div class="w-full gap-4 grid md:grid-cols-2">
|
||||
{#each reversedFiles as file, i (file.id)}
|
||||
{@const converter = (() => {
|
||||
return converters.find((c) =>
|
||||
c.supportedFormats.includes(file.from),
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
class={clsx(
|
||||
"sm:h-16 sm:py-0 py-4 px-3 flex relative flex-shrink-0 items-center w-full rounded-xl",
|
||||
{
|
||||
"initial-fade": !finisheds[i],
|
||||
processing:
|
||||
processings[files.files.length - i - 1],
|
||||
},
|
||||
)}
|
||||
style="--delay: {i * 50}ms; z-index: {files.files
|
||||
.length - i}; border: solid 3px {file.result
|
||||
? 'var(--accent-bg)'
|
||||
: 'var(--fg-muted-alt)'}; transition: border 1000ms ease; transition: filter {duration}ms var(--transition), transform {duration}ms var(--transition);"
|
||||
class="relative"
|
||||
animate:flip={{ duration, easing: quintOut }}
|
||||
out:blur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
blurMultiplier: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex gap-8 sm:gap-0 sm:flex-row flex-col items-center justify-between w-full z-50 relative sm:h-fit h-full"
|
||||
class={clsx(
|
||||
"flex relative flex-shrink-0 items-center w-full rounded-xl h-72",
|
||||
{
|
||||
"initial-fade": !finisheds[i],
|
||||
},
|
||||
)}
|
||||
style="--delay: {i * 50}ms; z-index: {files.files
|
||||
.length - i}; border: solid 2px {file.result
|
||||
? 'var(--accent-bg)'
|
||||
: 'var(--fg-muted-alt)'}; transition: border 1000ms ease; transition: filter {duration}ms var(--transition), transform {duration}ms var(--transition);"
|
||||
>
|
||||
<div
|
||||
class={clsx(
|
||||
"py-2 px-3 rounded-xl transition-colors duration-300 sm:w-fit w-full sm:text-left text-center",
|
||||
{
|
||||
"bg-accent-background text-accent-foreground":
|
||||
file.result,
|
||||
"bg-background text-foreground":
|
||||
!file.result,
|
||||
},
|
||||
)}
|
||||
class="flex h-full flex-col items-center w-full z-50 relative"
|
||||
>
|
||||
{file.file.name}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-3 sm:justify-normal w-full sm:w-fit flex-shrink-0"
|
||||
>
|
||||
{#if converter && converter.supportedFormats.includes(file.from)}
|
||||
<span class="sm:block hidden">from</span>
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground text-background rounded-xl sm:block hidden"
|
||||
>{file.from}</span
|
||||
>
|
||||
<span class="sm:block hidden">to</span>
|
||||
<div class="sm:block hidden">
|
||||
<Dropdown
|
||||
options={converter.supportedFormats}
|
||||
bind:selected={files.files[
|
||||
files.files.length - i - 1
|
||||
].to}
|
||||
onselect={() => {
|
||||
file.result = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:hidden block h-11">
|
||||
<div
|
||||
class="py-2 px-3 font-display bg-foreground text-background rounded-xl"
|
||||
>
|
||||
{file.from}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex-shrink-0">
|
||||
<div
|
||||
class="w-full sm:hidden h-full flex justify-center items-center"
|
||||
class={clsx(
|
||||
"py-3 dynadark:[--transparency:50%] [--transparency:25%] px-4 w-full flex transition-colors duration-300 flex-shrink text-left border-b-2 border-solid border-foreground-muted-alt rounded-tl-[9.5px] rounded-tr-[10px] overflow-hidden",
|
||||
{
|
||||
"text-accent-foreground":
|
||||
file.result,
|
||||
"text-foreground": !file.result,
|
||||
},
|
||||
)}
|
||||
style="background-color: color-mix(in srgb, var(--{file.result
|
||||
? 'accent-bg'
|
||||
: 'bg'}), transparent var(--transparency)); backdrop-filter: blur({data.isFirefox
|
||||
? 0
|
||||
: 18}px);"
|
||||
>
|
||||
<ArrowRight
|
||||
class="w-6 h-6 text-accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:hidden block h-full">
|
||||
<Dropdown
|
||||
options={converter.supportedFormats}
|
||||
bind:selected={files.files[
|
||||
files.files.length - 1 - i
|
||||
].to}
|
||||
onselect={() => {
|
||||
file.result = null;
|
||||
<div
|
||||
class="w-full grid grid-cols-1 grid-rows-1"
|
||||
>
|
||||
{#if processings[files.files.length - i - 1]}
|
||||
<div
|
||||
class="w-full row-start-1 col-start-1 h-full flex items-center pr-4"
|
||||
transition:blur={{
|
||||
blurMultiplier: 6,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
scale: {
|
||||
start: 0.9,
|
||||
end: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ProgressBar
|
||||
min={0}
|
||||
max={100}
|
||||
progress={file.converter
|
||||
?.reportsProgress
|
||||
? file.result
|
||||
? 100
|
||||
: file.progress
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<h3
|
||||
class="row-start-1 col-start-1 whitespace-nowrap overflow-hidden text-ellipsis font-medium"
|
||||
transition:blur={{
|
||||
blurMultiplier: 6,
|
||||
duration,
|
||||
easing: quintOut,
|
||||
scale: {
|
||||
start: 0.9,
|
||||
end: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{file.file.name}
|
||||
</h3>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => {
|
||||
// delete the file from the list
|
||||
files.files =
|
||||
files.files.filter(
|
||||
(f) => f !== file,
|
||||
);
|
||||
if (files.files.length === 0)
|
||||
goto("/");
|
||||
}}
|
||||
/>
|
||||
class="ml-2 mr-1 flex-shrink-0"
|
||||
>
|
||||
<XIcon size="24" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground-failure text-white rounded-xl"
|
||||
>{file.from}</span
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-3 justify-normal flex-grow w-full h-full"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-end gap-3 w-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-end gap-3 w-full h-full px-5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center gap-3 w-full pb-4"
|
||||
>
|
||||
{#if converter && converter.supportedFormats.includes(file.from)}
|
||||
<span>from</span>
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground text-background rounded-xl"
|
||||
>{file.from}</span
|
||||
>
|
||||
<span>to</span>
|
||||
<div class="inline-flex">
|
||||
<Dropdown
|
||||
options={converter.supportedFormats}
|
||||
bind:selected={files
|
||||
.files[
|
||||
files.files
|
||||
.length -
|
||||
i -
|
||||
1
|
||||
].to}
|
||||
onselect={() => {
|
||||
file.result =
|
||||
null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
class="py-2 px-3 font-display bg-foreground-failure text-white rounded-xl"
|
||||
>{file.from}</span
|
||||
>
|
||||
|
||||
<span class="text-foreground-failure">
|
||||
is not supported!
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => {
|
||||
// delete the file from the list
|
||||
files.files = files.files.filter(
|
||||
(f) => f !== file,
|
||||
);
|
||||
if (files.files.length === 0) goto("/");
|
||||
}}
|
||||
class="ml-2 mr-1 sm:block hidden"
|
||||
>
|
||||
<XIcon size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if converter && converter.supportedFormats.includes(file.from)}
|
||||
<!-- god knows why, but setting opacity > 0.98 causes a z-ordering issue in firefox ??? -->
|
||||
<div
|
||||
class="absolute top-0 -z-50 left-0 w-full h-full rounded-[10px] overflow-hidden opacity-[0.98]"
|
||||
>
|
||||
<div
|
||||
class="bg-cover bg-center w-full h-full"
|
||||
style="background-image: url({file.blobUrl});"
|
||||
></div>
|
||||
<div
|
||||
class="absolute sm:top-0 bottom-0 sm:right-0 sm:w-5/6 h-5/6 w-full sm:h-full"
|
||||
>
|
||||
<ProgressiveBlur
|
||||
direction={isSm ? "bottom" : "right"}
|
||||
endIntensity={128}
|
||||
iterations={6}
|
||||
fadeTo="var(--bg-transparent)"
|
||||
/>
|
||||
<span
|
||||
class="text-foreground-failure"
|
||||
>
|
||||
is not supported!
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="hidden lg:flex gap-4 w-full"
|
||||
>
|
||||
<button
|
||||
class="btn flex-grow flex-shrink-0"
|
||||
onclick={() => convert(file)}
|
||||
>
|
||||
Convert
|
||||
</button>
|
||||
<button
|
||||
class="btn flex-grow flex-shrink-0"
|
||||
disabled={!file.result}
|
||||
onclick={file.download}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if converter && converter.supportedFormats.includes(file.from)}
|
||||
<!-- god knows why, but setting opacity > 0.98 causes a z-ordering issue in firefox ??? -->
|
||||
<div
|
||||
class="absolute top-[0px] -z-50 left-0 w-full h-full opacity-[0.98] rounded-xl overflow-hidden"
|
||||
>
|
||||
{#if file.blobUrl}
|
||||
<div
|
||||
class="bg-cover bg-center w-full h-full"
|
||||
style="background-image: url({file.blobUrl})"
|
||||
in:blur={{
|
||||
blurMultiplier: 24,
|
||||
scale: {
|
||||
start: 1.1,
|
||||
end: 1,
|
||||
},
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
class="absolute left-0 top-0 pt-[50px] h-full w-full"
|
||||
transition:progBlur={{
|
||||
duration,
|
||||
easing: quintOut,
|
||||
}}
|
||||
>
|
||||
<ProgressiveBlur
|
||||
direction="bottom"
|
||||
endIntensity={64}
|
||||
iterations={8}
|
||||
fadeTo="var(--bg-transparent)"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<FileAudioIcon
|
||||
size="96"
|
||||
strokeWidth="1.5"
|
||||
color="var(--fg)"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="w-full h-4 flex-shrink-0"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -456,9 +565,28 @@
|
|||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@keyframes processing {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
filter: blur(0px);
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
filter: blur(4px);
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
filter: blur(0px);
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.processing {
|
||||
transform: scale(1.05);
|
||||
filter: blur(4px);
|
||||
animation: processing 2000ms infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue