feat: conversion page redesign (#21)

* feat: conversion page redesign

* fix: loading bars reversed

* fix: dark mode flicker and non-functionality in general (#22)

* feat: add delete button, improve loading bar contrast

* feat: remove mobile optimizations

* feat: add way to tell if a converter reports progress

* More shrexy progress bar when progress isn't indicated

* Make progress existance check better

* fix: progress bar

* more UI tweaks

* feat: nicer loading bars

* feat: audio metadata

* feat: asynchronous album covers

---------

Co-authored-by: Realmy <163438634+RealmyTheMan@users.noreply.github.com>
This commit is contained in:
nullptr 2024-11-14 20:02:06 +00:00 committed by GitHub
parent 543144ff32
commit 531949606a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 490 additions and 187 deletions

View File

@ -17,6 +17,7 @@
"@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",
@ -42,8 +43,12 @@
"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",
"wasm-vips": "^0.0.11"
},
"patchedDependencies": {
"jsmediatags@3.9.7": "patches/jsmediatags@3.9.7.patch"
}
}

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ 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

View File

@ -24,6 +24,8 @@ export class FFmpegConverter extends Converter {
".aiff",
];
public readonly reportsProgress = true;
constructor() {
super();
log(["converters", this.name], `created converter`);
@ -45,10 +47,6 @@ export class FFmpegConverter extends Converter {
if (!to.startsWith(".")) to = `.${to}`;
const ffmpeg = new FFmpeg();
ffmpeg.on("progress", (progress) => {
log(
["converters", this.name],
`progress for "${input.name}": ${progress.progress * 100}%`,
);
input.progress = progress.progress * 100;
});
const baseURL =

View File

@ -29,6 +29,8 @@ export class VipsConverter extends Converter {
".tiff",
];
public readonly reportsProgress = false;
constructor() {
super();
log(["converters", this.name], `created converter`);
@ -41,15 +43,21 @@ export class VipsConverter extends Converter {
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: {
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") {
@ -81,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);
}
});
}
}

View File

@ -8,7 +8,7 @@ interface ConvertMessage {
interface FinishedMessage {
type: "finished";
output: VertFile;
output: ArrayBufferLike;
}
interface LoadedMessage {

View File

@ -1,3 +1,5 @@
import type { Converter } from "$lib/converters/converter.svelte";
export class VertFile {
public id: string = Math.random().toString(36).slice(2, 8);
@ -10,16 +12,52 @@ export class VertFile {
}
public progress = $state(0);
// public result: VertFile | null = null;
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,
public readonly blobUrl?: 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();
}
}

View File

@ -1,8 +1,4 @@
import {
type WorkerMessage,
type OmitBetterStrict,
VertFile,
} from "$lib/types";
import { type WorkerMessage, type OmitBetterStrict } from "$lib/types";
import Vips from "wasm-vips";
const vipsPromise = Vips({
@ -32,10 +28,7 @@ const handleMessage = async (
image.delete();
return {
type: "finished",
output: new VertFile(
new File([output.buffer], message.input.name),
message.to,
),
output: output.buffer,
};
}
}

View File

@ -11,8 +11,10 @@ export const load = ({ url, request, cookies }) => {
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,
};
};

View File

@ -2,10 +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.js";
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();
@ -48,6 +50,7 @@
new VertFile(
f,
to,
converter,
URL.createObjectURL(blob!),
),
);
@ -58,7 +61,39 @@
};
img.onerror = async () => {
resolve(new VertFile(f, to));
// 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);
};
},
);

View File

@ -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 } 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(() => {
@ -47,13 +54,6 @@
convertersRequired.every((c) => c.ready),
);
onMount(() => {
isSm = window.innerWidth < 640;
window.addEventListener("resize", () => {
isSm = window.innerWidth < 640;
});
});
let disabled = $derived(files.files.some((f) => !f.result));
onMount(() => {
@ -71,20 +71,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(file, to);
file.result = converted;
processings[i] = false;
})(),
(async (i) => {
await convert(files.files[i], i);
})(i),
);
}
@ -94,6 +83,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 dlFiles: any[] = [];
for (let i = 0; i < files.files.length; i++) {
@ -137,6 +133,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>
@ -153,7 +181,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,
@ -194,7 +222,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,
@ -209,13 +237,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" : ""}
@ -231,8 +261,16 @@
{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>
<div class="w-full gap-4 grid md:grid-cols-2">
{#each reversedFiles as file, i (file.id)}
{@const converter = (() => {
return converters.find((c) =>
@ -240,7 +278,7 @@
);
})()}
<div
class="w-full rounded-xl relative"
class="relative"
animate:flip={{ duration, easing: quintOut }}
out:blur={{
duration,
@ -250,81 +288,127 @@
>
<div
class={clsx(
"sm:h-16 sm:py-0 py-4 px-3 flex relative overflow-hidden flex-shrink-0 items-center w-full rounded-xl",
"flex relative flex-shrink-0 items-center w-full rounded-xl h-72",
{
"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
.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="absolute top-0 left-0 bg-red-500 h-full"
style="width: {file.progress}%; transition: width 500ms linear;"
></div> -->
<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="flex h-full flex-col items-center w-full z-50 relative"
>
<div class="w-full flex-shrink-0">
<div
class={clsx(
"py-2 px-3 rounded-xl transition-colors duration-300 sm:w-fit w-full sm:text-left text-center",
"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",
{
"bg-accent-background text-accent-foreground":
"text-accent-foreground":
file.result,
"bg-background text-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);"
>
<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>
</div>
<div
class="flex items-center gap-3 sm:justify-normal w-full sm:w-fit flex-shrink-0"
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 class="sm:block hidden">from</span>
<span>from</span>
<span
class="py-2 px-3 font-display bg-foreground text-background rounded-xl sm:block hidden"
class="py-2 px-3 font-display bg-foreground text-background rounded-xl"
>{file.from}</span
>
<span class="sm:block hidden">to</span>
<div class="sm:block hidden">
<span>to</span>
<div class="inline-flex">
<Dropdown
options={converter.supportedFormats}
bind:selected={files.files[
files.files.length - i - 1
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 sm:hidden h-full flex justify-center items-center"
>
<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;
file.result =
null;
}}
/>
</div>
@ -334,49 +418,85 @@
>{file.from}</span
>
<span class="text-foreground-failure">
<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"
</div>
</div>
<!-- <div
class="hidden lg:flex gap-4 w-full"
>
<XIcon size="18" />
<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 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]"
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});"
style="background-image: url({file.blobUrl})"
in:blur={{
blurMultiplier: 24,
scale: {
start: 1.1,
end: 1,
},
duration,
easing: quintOut,
}}
></div>
<div
class="absolute sm:top-0 bottom-0 sm:right-0 sm:w-5/6 h-5/6 w-full sm:h-full"
class="absolute left-0 top-0 pt-[50px] h-full w-full"
transition:progBlur={{
duration,
easing: quintOut,
}}
>
<ProgressiveBlur
direction={isSm ? "bottom" : "right"}
endIntensity={128}
iterations={6}
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>
{/each}
<div class="w-full h-4 flex-shrink-0"></div>
</div>
</div>
{/if}
</div>
@ -406,9 +526,28 @@
opacity: 1 !important;
}
.processing {
@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 {
animation: processing 2000ms infinite;
pointer-events: none;
}