Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions apps/web/src/components/download-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
useOverlayLock(true);
const { isClosing, dismiss } = useSmoothDismiss({ onClose });
const downloader = useDownloaderJob();
const { isDone, jobId, isQueued, isRunning, errorText, openArtifact, reset, start } = downloader;
const {
isDone,
jobId,
isQueued,
isRunning,
errorText,
openArtifact,
reset,
start,
canUseIosShareFlow,
} = downloader;
const isBusy = isQueued || isRunning;
const [artifactError, setArtifactError] = useState<string | null>(null);
const options = useMemo(() => buildDownloadOptions(stream), [stream]);
Expand All @@ -37,11 +47,14 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
jobId,
selectedLabel: selected?.label ?? "file",
openArtifact,
autoStart: !canUseIosShareFlow,
preferShare: canUseIosShareFlow,
onDone,
onDismiss: dismiss,
reset,
onArtifactError: setArtifactError,
});
const requiresManualArtifactTap = isDone && canUseIosShareFlow;
const showWorkingState = isBusy || completion.isCompleting;

function selectMode(next: DownloadMode) {
Expand Down Expand Up @@ -81,7 +94,7 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
Close
</button>
</div>
{!showWorkingState && (
{!showWorkingState && !requiresManualArtifactTap && (
<>
<DownloadSheetPicker
mode={mode}
Expand All @@ -100,6 +113,21 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
</button>
</>
)}
{requiresManualArtifactTap && (
<div className="mt-2 flex flex-col gap-3 rounded-lg border border-border bg-surface/60 p-3">
<p className="text-xs text-fg-muted">
File is ready. Tap below to open the iOS share sheet and save to Files.
</p>
<button
type="button"
onClick={() => void completion.completeNow()}
disabled={completion.isCompleting}
className="w-full rounded-lg bg-fg px-3 py-2 text-sm font-medium text-app transition-colors hover:bg-white disabled:cursor-not-allowed disabled:bg-surface-soft disabled:text-fg-muted"
>
{completion.isCompleting ? "Opening..." : "Open iOS share sheet"}
</button>
</div>
)}
<DownloaderJobFeedback
stage={downloader.stage}
progressPercent={downloader.progressPercent}
Expand Down
26 changes: 15 additions & 11 deletions apps/web/src/components/shorts-info-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,28 @@ export function ShortsInfoOverlay({ stream, variant = "overlay", className }: Pr
return () => clearTimeout(timer);
}, [toastMsg]);

function handleSubscribe() {
async function handleSubscribe() {
if (!stream.channelUrl) return;
if (!isAuthed) {
const redirect = `/shorts?v=${encodeURIComponent(stream.id)}`;
window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`);
return;
}
if (subscribed) {
remove.mutate(stream.channelUrl);
setToastMsg(`Unsubscribed from ${stream.channelName}`);
return;
try {
if (subscribed) {
await remove.mutateAsync(stream.channelUrl);
setToastMsg(`Unsubscribed from ${stream.channelName}`);
return;
}
await add.mutateAsync({
channelUrl: stream.channelUrl,
name: stream.channelName,
avatarUrl: stream.channelAvatar,
});
setToastMsg(`Subscribed to ${stream.channelName}`);
} catch {
setToastMsg("Subscription update failed");
}
add.mutate({
channelUrl: stream.channelUrl,
name: stream.channelName,
avatarUrl: stream.channelAvatar,
});
setToastMsg(`Subscribed to ${stream.channelName}`);
}

const panelButtonClass = `rounded-full px-4 py-1.5 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
Expand Down
26 changes: 15 additions & 11 deletions apps/web/src/components/watch-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,22 @@ export function WatchInfo({ stream }: Props) {
return () => clearTimeout(t);
}, [toastMsg]);

function handleSubscribe() {
async function handleSubscribe() {
if (!stream.channelUrl) return;
if (subscribed) {
remove.mutate(stream.channelUrl);
setToastMsg(`Unsubscribed from ${stream.channelName}`);
} else {
add.mutate({
channelUrl: stream.channelUrl,
name: stream.channelName,
avatarUrl: stream.channelAvatar,
});
setToastMsg(`Subscribed to ${stream.channelName}`);
try {
if (subscribed) {
await remove.mutateAsync(stream.channelUrl);
setToastMsg(`Unsubscribed from ${stream.channelName}`);
} else {
await add.mutateAsync({
channelUrl: stream.channelUrl,
name: stream.channelName,
avatarUrl: stream.channelAvatar,
});
setToastMsg(`Subscribed to ${stream.channelName}`);
}
} catch {
setToastMsg("Subscription update failed");
}
}

Expand Down
32 changes: 26 additions & 6 deletions apps/web/src/hooks/use-artifact-download-on-done.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ type Params = {
isDone: boolean;
jobId: string | undefined;
selectedLabel: string;
openArtifact: () => Promise<void> | undefined;
openArtifact: (options?: { preferShare?: boolean }) => Promise<void> | undefined;
autoStart?: boolean;
preferShare?: boolean;
onDone: (message: string) => void;
onDismiss: () => void;
reset: () => void;
Expand All @@ -16,6 +18,8 @@ export function useArtifactDownloadOnDone({
jobId,
selectedLabel,
openArtifact,
autoStart = true,
preferShare = false,
onDone,
onDismiss,
reset,
Expand All @@ -25,8 +29,8 @@ export function useArtifactDownloadOnDone({
const handledJobIdRef = useRef<string | null>(null);

const completeDownload = useCallback(
async (selected: string) => {
const run = openArtifact();
async (selected: string, options?: { preferShare?: boolean }) => {
const run = openArtifact(options);
if (!run) throw new Error("Download is not ready");
await run;
onDone(`Download started: ${selected}`);
Expand All @@ -36,6 +40,20 @@ export function useArtifactDownloadOnDone({
[onDismiss, onDone, openArtifact, reset],
);

const completeNow = useCallback(async () => {
if (!isDone || !jobId) return;
if (handledJobIdRef.current === jobId) return;
handledJobIdRef.current = jobId;
setIsCompleting(true);
try {
await completeDownload(selectedLabel, { preferShare });
} catch (error) {
handledJobIdRef.current = null;
setIsCompleting(false);
onArtifactError(error instanceof Error ? error.message : "Download failed");
}
}, [completeDownload, isDone, jobId, onArtifactError, preferShare, selectedLabel]);

useEffect(() => {
if (isDone) return;
setIsCompleting(false);
Expand All @@ -47,17 +65,19 @@ export function useArtifactDownloadOnDone({
}, [jobId]);

useEffect(() => {
if (!autoStart) return;
if (!isDone || !jobId) return;
if (handledJobIdRef.current === jobId) return;
handledJobIdRef.current = jobId;
setIsCompleting(true);
let cancelled = false;
const run = async () => {
try {
await completeDownload(selectedLabel);
await completeDownload(selectedLabel, { preferShare });
if (cancelled) return;
} catch (error) {
if (cancelled) return;
handledJobIdRef.current = null;
setIsCompleting(false);
onArtifactError(error instanceof Error ? error.message : "Download failed");
}
Expand All @@ -66,7 +86,7 @@ export function useArtifactDownloadOnDone({
return () => {
cancelled = true;
};
}, [completeDownload, isDone, jobId, onArtifactError, selectedLabel]);
}, [autoStart, completeDownload, isDone, jobId, onArtifactError, preferShare, selectedLabel]);

return { isCompleting };
return { isCompleting, completeNow };
}
6 changes: 4 additions & 2 deletions apps/web/src/hooks/use-downloader-job.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import {
canUseIosShareFlow,
createDownloaderJob,
downloadDownloaderArtifact,
fetchDownloaderJob,
Expand Down Expand Up @@ -97,9 +98,9 @@ export function useDownloaderJob() {
create.mutate(payload);
}

function openArtifact() {
function openArtifact(options?: { preferShare?: boolean }) {
if (!jobId) return;
return downloadDownloaderArtifact(jobId);
return downloadDownloaderArtifact(jobId, options);
}

function reset() {
Expand All @@ -121,6 +122,7 @@ export function useDownloaderJob() {
uploadMs,
totalMs,
errorCode,
canUseIosShareFlow: canUseIosShareFlow(),
isQueued,
isRunning,
isDone,
Expand Down
64 changes: 58 additions & 6 deletions apps/web/src/lib/api-downloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ import type {
} from "../types/downloader";
import { ApiError } from "./api";
import { API_BASE as BASE } from "./env";
import { isMobileDownloadDevice } from "./ios-device";
import { isIosWebKitBrowser, isMobileDownloadDevice } from "./ios-device";

type ErrorBody = {
error?: string;
message?: string;
};

type DownloadArtifactOptions = {
preferShare?: boolean;
};

async function readJson(res: Response): Promise<unknown> {
return res.json().catch(() => ({}));
}
Expand Down Expand Up @@ -67,10 +71,60 @@ function filenameFromHeader(contentDisposition: string | null): string | null {
return classic?.[1] ?? null;
}

export async function downloadDownloaderArtifact(jobId: string): Promise<void> {
function canUseShareApi(): boolean {
if (typeof navigator === "undefined") return false;
return typeof navigator.share === "function";
}

function supportsFileShare(data: ShareData): boolean {
if (typeof navigator === "undefined") return false;
if (typeof navigator.canShare !== "function") return false;
return navigator.canShare(data);
}

function fallbackFileName(jobId: string, headers: Headers): string {
return (
filenameFromHeader(headers.get("content-disposition")) ??
`typetype-download-${jobId}.${extensionFromType(headers.get("content-type"))}`
);
}

async function shareDownloaderArtifact(endpoint: string, jobId: string): Promise<void> {
const res = await fetch(endpoint);
if (!res.ok) {
const body = await readJson(res);
throw new ApiError(readErrorMessage(body, "Failed to download artifact"), res.status);
}
const blob = await res.blob();
const fileName = fallbackFileName(jobId, res.headers);
const fileType = blob.type.length > 0 ? blob.type : "application/octet-stream";
const file = new File([blob], fileName, { type: fileType });
const shareData: ShareData = { files: [file], title: fileName };
if (!supportsFileShare(shareData)) {
throw new ApiError("Native iOS share is unavailable for this file", 422);
}
await navigator.share(shareData);
}

function openArtifactLocation(endpoint: string): void {
window.location.assign(endpoint);
}

export function canUseIosShareFlow(): boolean {
return isIosWebKitBrowser() && canUseShareApi();
}

export async function downloadDownloaderArtifact(
jobId: string,
options: DownloadArtifactOptions = {},
): Promise<void> {
const endpoint = `${BASE}/downloader/jobs/${encodeURIComponent(jobId)}/artifact`;
if (options.preferShare && canUseIosShareFlow()) {
await shareDownloaderArtifact(endpoint, jobId);
return;
}
if (isMobileDownloadDevice()) {
window.location.assign(endpoint);
openArtifactLocation(endpoint);
return;
}
const res = await fetch(endpoint);
Expand All @@ -79,9 +133,7 @@ export async function downloadDownloaderArtifact(jobId: string): Promise<void> {
throw new ApiError(readErrorMessage(body, "Failed to download artifact"), res.status);
}
const blob = await res.blob();
const fileName =
filenameFromHeader(res.headers.get("content-disposition")) ??
`typetype-download-${jobId}.${extensionFromType(res.headers.get("content-type"))}`;
const fileName = fallbackFileName(jobId, res.headers);
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/lib/ios-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ function isTouchMac(): boolean {
return desktopModeIpad || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
}

function isWebKitEngine(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
const hasWebKit = /AppleWebKit/i.test(ua);
const otherIosBrowser = /CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua);
return hasWebKit && !otherIosBrowser;
}

function isAndroidDevice(): boolean {
if (typeof navigator === "undefined") return false;
return /Android/i.test(navigator.userAgent);
Expand All @@ -15,6 +23,10 @@ export function isIosDevice(): boolean {
return /iPhone|iPad|iPod/.test(navigator.userAgent) || isTouchMac();
}

export function isIosWebKitBrowser(): boolean {
return isIosDevice() && isWebKitEngine();
}

export function isMobileDownloadDevice(): boolean {
return isIosDevice() || isAndroidDevice();
}