web/download: support downloading and sharing raw files

This commit is contained in:
wukko 2024-09-09 02:30:03 +06:00
parent b1f41cae41
commit 853bc26587
No known key found for this signature in database
GPG key ID: 3E30B3F26C7B4AA2
5 changed files with 120 additions and 50 deletions

View file

@ -18,7 +18,13 @@
$: itemType = item.type ?? "photo"; $: itemType = item.type ?? "photo";
</script> </script>
<button class="picker-item" on:click={() => downloadFile(item.url)}> <button
class="picker-item"
on:click={() =>
downloadFile({
url: item.url,
})}
>
<div class="picker-type"> <div class="picker-type">
{#if itemType === "video"} {#if itemType === "video"}
<IconMovie /> <IconMovie />
@ -32,11 +38,9 @@
<img <img
class="picker-image" class="picker-image"
src={item.thumb ?? item.url} src={item.thumb ?? item.url}
class:loading={!imageLoaded} class:loading={!imageLoaded}
class:video-thumbnail={["video", "gif"].includes(itemType)} class:video-thumbnail={["video", "gif"].includes(itemType)}
on:load={() => imageLoaded = true} on:load={() => (imageLoaded = true)}
alt="{$t(`a11y.dialog.picker.item.${itemType}`)} {number}" alt="{$t(`a11y.dialog.picker.item.${itemType}`)} {number}"
/> />
<Skeleton class="picker-image elevated" hidden={imageLoaded} /> <Skeleton class="picker-image elevated" hidden={imageLoaded} />

View file

@ -2,7 +2,13 @@
import { t } from "$lib/i18n/translations"; import { t } from "$lib/i18n/translations";
import { device } from "$lib/device"; import { device } from "$lib/device";
import { copyURL, openURL, shareURL } from "$lib/download"; import {
copyURL,
openURL,
shareURL,
openFile,
shareFile,
} from "$lib/download";
import DialogContainer from "$components/dialog/DialogContainer.svelte"; import DialogContainer from "$components/dialog/DialogContainer.svelte";
@ -17,9 +23,11 @@
import CopyIcon from "$components/misc/CopyIcon.svelte"; import CopyIcon from "$components/misc/CopyIcon.svelte";
export let id: string; export let id: string;
export let url: string;
export let bodyText: string = "";
export let dismissable = true; export let dismissable = true;
export let bodyText: string = "";
export let url: string = "";
export let file: File | undefined = undefined;
let close: () => void; let close: () => void;
@ -45,13 +53,20 @@
{$t("dialog.saving.title")} {$t("dialog.saving.title")}
</h2> </h2>
</div> </div>
<div class="action-buttons"> <div class="action-buttons">
{#if device.supports.directDownload} {#if device.supports.directDownload}
<VerticalActionButton <VerticalActionButton
id="save-download" id="save-download"
fill fill
elevated elevated
click={() => openURL(url)} click={() => {
if (file) {
return openFile(file);
} else if (url) {
return openURL(url);
}
}}
> >
<IconDownload /> <IconDownload />
{$t("button.download")} {$t("button.download")}
@ -63,26 +78,34 @@
id="save-share" id="save-share"
fill fill
elevated elevated
click={async () => await shareURL(url)} click={async () => {
if (file) {
return await shareFile(file);
} else if (url) {
return await shareURL(url);
}
}}
> >
<IconShare2 /> <IconShare2 />
{$t("button.share")} {$t("button.share")}
</VerticalActionButton> </VerticalActionButton>
{/if} {/if}
<VerticalActionButton {#if !file}
id="save-copy" <VerticalActionButton
fill id="save-copy"
elevated fill
click={async () => { elevated
copyURL(url); click={async () => {
copied = true; copyURL(url);
}} copied = true;
ariaLabel={copied ? $t("button.copied") : ""} }}
> ariaLabel={copied ? $t("button.copied") : ""}
<CopyIcon check={copied} /> >
{$t("button.copy")} <CopyIcon check={copied} />
</VerticalActionButton> {$t("button.copy")}
</VerticalActionButton>
{/if}
</div> </div>
{#if device.is.iOS} {#if device.is.iOS}

View file

@ -30,29 +30,29 @@
type DownloadButtonState = "idle" | "think" | "check" | "done" | "error"; type DownloadButtonState = "idle" | "think" | "check" | "done" | "error";
const changeDownloadButton = (state: DownloadButtonState) => { const changeDownloadButton = (state: DownloadButtonState) => {
disabled = state !== 'idle'; disabled = state !== "idle";
buttonText = ({ buttonText = {
idle: ">>", idle: ">>",
think: "...", think: "...",
check: "..?", check: "..?",
done: ">>>", done: ">>>",
error: "!!" error: "!!",
})[state]; }[state];
buttonAltText = $t( buttonAltText = $t(
({ {
idle: "a11y.save.download", idle: "a11y.save.download",
think: "a11y.save.download.think", think: "a11y.save.download.think",
check: "a11y.save.download.check", check: "a11y.save.download.check",
done: "a11y.save.download.done", done: "a11y.save.download.done",
error: "a11y.save.download.error", error: "a11y.save.download.error",
})[state] }[state]
); );
// states that don't wait for anything, and thus can // states that don't wait for anything, and thus can
// transition back to idle after some period of time. // transition back to idle after some period of time.
const final: DownloadButtonState[] = ['done', 'error']; const final: DownloadButtonState[] = ["done", "error"];
if (final.includes(state)) { if (final.includes(state)) {
setTimeout(() => changeDownloadButton("idle"), 1500); setTimeout(() => changeDownloadButton("idle"), 1500);
} }
@ -84,7 +84,9 @@
if (response.status === "redirect") { if (response.status === "redirect") {
changeDownloadButton("done"); changeDownloadButton("done");
return downloadFile(response.url); return downloadFile({
url: response.url,
});
} }
if (response.status === "tunnel") { if (response.status === "tunnel") {
@ -95,7 +97,9 @@
if (probeResult === 200) { if (probeResult === 200) {
changeDownloadButton("done"); changeDownloadButton("done");
return downloadFile(response.url); return downloadFile({
url: response.url,
});
} else { } else {
changeDownloadButton("error"); changeDownloadButton("error");
@ -122,7 +126,9 @@
text: $t("button.download.audio"), text: $t("button.download.audio"),
main: false, main: false,
action: () => { action: () => {
downloadFile(pickerAudio); downloadFile({
url: pickerAudio,
});
}, },
}); });
} }

View file

@ -8,23 +8,47 @@ import { t } from "$lib/i18n/translations";
import { createDialog } from "$lib/dialogs"; import { createDialog } from "$lib/dialogs";
import type { DialogInfo } from "$lib/types/dialog"; import type { DialogInfo } from "$lib/types/dialog";
export const openSavingDialog = (url: string, body: string | void) => { const openSavingDialog = ({ url, file, body }: { url?: string, file?: File, body?: string }) => {
const dialogData: DialogInfo = { const dialogData: DialogInfo = {
type: "saving", type: "saving",
id: "saving", id: "saving",
url file,
url,
} }
if (body) dialogData.bodyText = body; if (body) dialogData.bodyText = body;
createDialog(dialogData) createDialog(dialogData)
} }
export const openFile = async (file: File) => {
const a = document.createElement("a");
const url = URL.createObjectURL(file);
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
}
export const shareFile = async (file: File) => {
return await navigator?.share({
files: [
new File([file], file.name, {
type: file.type,
}),
],
});
}
export const openURL = (url: string) => { export const openURL = (url: string) => {
const open = window.open(url, "_blank"); const open = window.open(url, "_blank");
/* if new tab got blocked by user agent, show a saving dialog */ /* if new tab got blocked by user agent, show a saving dialog */
if (!open) { if (!open) {
return openSavingDialog(url, get(t)("dialog.saving.blocked")); return openSavingDialog({
url,
body: get(t)("dialog.saving.blocked")
});
} }
} }
@ -36,7 +60,9 @@ export const copyURL = async (url: string) => {
return await navigator?.clipboard?.writeText(url); return await navigator?.clipboard?.writeText(url);
} }
export const downloadFile = (url: string) => { export const downloadFile = ({ url, file }: { url?: string, file?: File }) => {
if (!url && !file) throw new Error("attempted to download void");
const pref = get(settings).save.savingMethod; const pref = get(settings).save.savingMethod;
/* /*
@ -50,18 +76,28 @@ export const downloadFile = (url: string) => {
*/ */
if (pref === "ask" || !navigator.userActivation.isActive) { if (pref === "ask" || !navigator.userActivation.isActive) {
return openSavingDialog(url); return openSavingDialog({ url, file });
} }
try { try {
if (pref === "share" && device.supports.share) { if (file) {
return shareURL(url); if (pref === "share" && device.supports.share) {
} else if (pref === "download" && device.supports.directDownload) { return shareFile(file);
return openURL(url); } else if (pref === "download" && device.supports.directDownload) {
} else if (pref === "copy") { return openFile(file);
return copyURL(url); }
} }
} catch {}
return openSavingDialog(url); if (url) {
if (pref === "share" && device.supports.share) {
return shareURL(url);
} else if (pref === "download" && device.supports.directDownload) {
return openURL(url);
} else if (pref === "copy" && !file) {
return copyURL(url);
}
}
} catch { /* catch & ignore */ }
return openSavingDialog({ url, file });
} }

View file

@ -40,8 +40,9 @@ type PickerDialog = Dialog & {
type SavingDialog = Dialog & { type SavingDialog = Dialog & {
type: "saving", type: "saving",
url: string,
bodyText?: string, bodyText?: string,
url?: string,
file?: File,
}; };
export type DialogInfo = SmallDialog | PickerDialog | SavingDialog; export type DialogInfo = SmallDialog | PickerDialog | SavingDialog;