Merge branch 'pr-990' (iOS Web Share for completed downloads)

This commit is contained in:
Alex Shnitman
2026-05-29 13:25:22 +03:00
2 changed files with 77 additions and 1 deletions
+3
View File
@@ -854,6 +854,9 @@
@if (entry[1].filename) { @if (entry[1].filename) {
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a> <a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
} }
@if (entry[1].filename && canShareDownloads()) {
<button type="button" class="btn btn-link" [attr.aria-label]="'Share result file for ' + entry[1].title" (click)="shareDownload(entry[1])"><fa-icon [icon]="faShareNodes" /></button>
}
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a> <a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button> <button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
</div> </div>
+74 -1
View File
@@ -7,7 +7,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay, faShareNodes } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons'; import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { AddDownloadPayload, DownloadsService } from './services/downloads.service'; import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
@@ -185,6 +185,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
faUpload = faUpload; faUpload = faUpload;
faPause = faPause; faPause = faPause;
faPlay = faPlay; faPlay = faPlay;
faShareNodes = faShareNodes;
subtitleLanguages = [ subtitleLanguages = [
{ id: 'en', text: 'English' }, { id: 'en', text: 'English' },
{ id: 'ar', text: 'Arabic' }, { id: 'ar', text: 'Arabic' },
@@ -1189,6 +1190,78 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return baseDir + encodeURIComponent(download.filename); return baseDir + encodeURIComponent(download.filename);
} }
// Web Share API support — primarily for iOS Safari / Chrome, lets the user
// hand the downloaded file off to the platform share sheet (Photos.app,
// Files, third-party apps, AirDrop). Falls back silently to the standard
// download flow on platforms without navigator.share / canShare.
canShareDownloads(): boolean {
// navigator.share alone is not enough — Desktop Safari implements
// navigator.share but not canShare with files. We explicitly require
// both, since we always intend to share a file (not a URL).
return typeof navigator !== 'undefined'
&& typeof navigator.share === 'function'
&& typeof navigator.canShare === 'function';
}
// Conservative warning threshold for the share sheet — iOS' actual
// refusal limit varies between ~50 MB (older versions) and ~150 MB
// (recent ones). 80 MB warns the user before the time-wasting fetch+
// copy of a too-large file that the platform will then reject.
private static readonly SHARE_SIZE_WARN_BYTES = 80 * 1024 * 1024;
async shareDownload(download: Download): Promise<void> {
if (!this.canShareDownloads()) {
return;
}
// Pre-flight size check: warn the user about the iOS share-sheet
// soft-fail on large files, before we spend time fetching the whole
// file into memory only to have navigator.canShare reject it.
if (download.size && download.size > App.SHARE_SIZE_WARN_BYTES) {
const sizeMb = Math.round(download.size / 1024 / 1024);
const proceed = window.confirm(
`This file is ${sizeMb} MB. iOS' share sheet often refuses files ` +
`larger than ~100 MB and the share will silently fail. ` +
`Try anyway? (Use the download button instead if it fails.)`
);
if (!proceed) return;
}
try {
const response = await fetch(this.buildDownloadLink(download));
if (!response.ok) {
throw new Error(`HTTP ${response.status} fetching file for share`);
}
const blob = await response.blob();
const file = new File([blob], download.filename, {
type: blob.type || 'application/octet-stream',
});
const payload: ShareData = { files: [file], title: download.title };
if (!navigator.canShare(payload)) {
// The platform refused the payload — most commonly because the
// file is too large for the iOS share sheet, or the MIME type
// isn't accepted. Tell the user so they can fall back to the
// download button right next to this one instead of staring at
// a button that quietly did nothing.
console.warn('navigator.canShare rejected payload for', download.filename);
window.alert(
`Your device's share sheet doesn't accept this file ` +
`(most likely because it's too large). ` +
`Please use the download button instead.`
);
return;
}
await navigator.share(payload);
} catch (err) {
const e = err as { name?: string; message?: string };
// AbortError = user dismissed the share sheet → silent no-op.
if (e.name === 'AbortError') return;
console.error('Share failed:', err);
window.alert(
`Share failed: ${e.message || 'unknown error'}. ` +
`Please use the download button instead.`
);
}
}
buildResultItemTooltip(download: Download) { buildResultItemTooltip(download: Download) {
const parts = []; const parts = [];
if (download.msg) { if (download.msg) {