nx: implement multi-share (fix #901)
Signed-off-by: Varun Patil <radialapps@gmail.com>pulsejet/aio-hw-docs
parent
fe74b9f089
commit
14a890796e
|
@ -29,7 +29,6 @@ use OCA\Memories\Exif;
|
|||
use OCA\Memories\Service;
|
||||
use OCA\Memories\Util;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\Files\IRootFolder;
|
||||
|
||||
|
@ -56,11 +55,22 @@ class ImageController extends GenericApiController
|
|||
throw Exceptions::MissingParameter('id, x, y');
|
||||
}
|
||||
|
||||
// Get preview for this file
|
||||
$file = $this->fs->getUserFile($id);
|
||||
$preview = \OC::$server->get(\OCP\IPreview::class)->getPreview($file, $x, $y, !$a, $mode);
|
||||
$response = new FileDisplayResponse($preview, Http::STATUS_OK, [
|
||||
'Content-Type' => $preview->getMimeType(),
|
||||
]);
|
||||
|
||||
// Get the filename. We need to move the extension from
|
||||
// the preview file to the filename's end if it's not there
|
||||
// Do the comparison case-insensitive
|
||||
$filename = $file->getName();
|
||||
if ($ext = pathinfo($preview->getName(), PATHINFO_EXTENSION)) {
|
||||
if (!str_ends_with(strtolower($filename), strtolower('.'.$ext))) {
|
||||
$filename .= '.'.$ext;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate response with proper content-disposition
|
||||
$response = new Http\DataDownloadResponse($preview->getContent(), $filename, $preview->getMimeType());
|
||||
$response->cacheFor(3600 * 24, false, true);
|
||||
|
||||
return $response;
|
||||
|
@ -293,13 +303,17 @@ class ImageController extends GenericApiController
|
|||
/** @var string Blob of image */
|
||||
$blob = $file->getContent();
|
||||
|
||||
/** @var string Name of file */
|
||||
$name = $file->getName();
|
||||
|
||||
// Convert image to JPEG if required
|
||||
if (!\in_array($mimetype, ['image/png', 'image/webp', 'image/jpeg', 'image/gif'], true)) {
|
||||
[$blob, $mimetype] = $this->getImageJPEG($blob, $mimetype);
|
||||
$name .= '.jpg';
|
||||
}
|
||||
|
||||
// Return the image
|
||||
$response = new Http\DataDisplayResponse($blob, Http::STATUS_OK, ['Content-Type' => $mimetype]);
|
||||
$response = new Http\DataDownloadResponse($blob, $name, $mimetype);
|
||||
$response->cacheFor(3600 * 24, false, false);
|
||||
|
||||
return $response;
|
||||
|
|
|
@ -50,6 +50,7 @@ import * as dav from '@services/dav';
|
|||
import * as utils from '@services/utils';
|
||||
import * as nativex from '@native';
|
||||
|
||||
import ShareIcon from 'vue-material-design-icons/ShareVariant.vue';
|
||||
import StarIcon from 'vue-material-design-icons/Star.vue';
|
||||
import DownloadIcon from 'vue-material-design-icons/Download.vue';
|
||||
import DeleteIcon from 'vue-material-design-icons/TrashCanOutline.vue';
|
||||
|
@ -203,6 +204,12 @@ export default defineComponent({
|
|||
callback: this.deleteSelection.bind(this),
|
||||
if: () => this.routeIsAlbums,
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Share'),
|
||||
icon: ShareIcon,
|
||||
callback: this.shareSelection.bind(this),
|
||||
if: () => !this.routeIsAlbums,
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Download'),
|
||||
icon: DownloadIcon,
|
||||
|
@ -809,6 +816,13 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Share the currently selected photos
|
||||
*/
|
||||
shareSelection(selection: Selection) {
|
||||
_m.modals.sharePhotos(selection.photosNoDupFileId());
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the edit date dialog
|
||||
*/
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<Modal ref="modal" @close="cleanup" size="normal" v-if="show">
|
||||
<template #title>
|
||||
{{ t('memories', 'Share File') }}
|
||||
{{ n('memories', 'Share File', 'Share Files', photos?.length ?? 0) }}
|
||||
</template>
|
||||
|
||||
<div class="loading-icon fill-block" v-if="loading > 0">
|
||||
|
@ -10,25 +10,21 @@
|
|||
|
||||
<ul class="options" v-else>
|
||||
<NcListItem
|
||||
v-if="canShareNative && canShareTranscode"
|
||||
v-if="canShareNative && canShareLowRes"
|
||||
:title="t('memories', 'Reduced Size')"
|
||||
:bold="false"
|
||||
@click.prevent="sharePreview()"
|
||||
@click.prevent="shareLowRes()"
|
||||
>
|
||||
<template #icon>
|
||||
<PhotoIcon class="avatar" :size="24" />
|
||||
</template>
|
||||
<template #subtitle>
|
||||
{{
|
||||
isVideo
|
||||
? t('memories', 'Share the video as a low quality MP4')
|
||||
: t('memories', 'Share a lower resolution image preview')
|
||||
}}
|
||||
{{ t('memories', 'Share in lower quality (small file size)') }}
|
||||
</template>
|
||||
</NcListItem>
|
||||
|
||||
<NcListItem
|
||||
v-if="canShareNative && canShareTranscode"
|
||||
v-if="canShareNative && canShareHighRes"
|
||||
:title="t('memories', 'High Resolution')"
|
||||
:bold="false"
|
||||
@click.prevent="shareHighRes()"
|
||||
|
@ -37,11 +33,7 @@
|
|||
<LargePhotoIcon class="avatar" :size="24" />
|
||||
</template>
|
||||
<template #subtitle>
|
||||
{{
|
||||
isVideo
|
||||
? t('memories', 'Share the video as a high quality MP4')
|
||||
: t('memories', 'Share the image as a high quality JPEG')
|
||||
}}
|
||||
{{ t('memories', 'Share in high quality (large file size)') }}
|
||||
</template>
|
||||
</NcListItem>
|
||||
|
||||
|
@ -55,7 +47,7 @@
|
|||
<FileIcon class="avatar" :size="24" />
|
||||
</template>
|
||||
<template #subtitle>
|
||||
{{ t('memories', 'Share the original image / video file') }}
|
||||
{{ n('memories', 'Share the original file', 'Share the original files', photos?.length ?? 0) }}
|
||||
</template>
|
||||
</NcListItem>
|
||||
|
||||
|
@ -112,47 +104,57 @@ export default defineComponent({
|
|||
mixins: [UserConfig, ModalMixin],
|
||||
|
||||
data: () => ({
|
||||
photo: null as IPhoto | null,
|
||||
photos: null as IPhoto[] | null,
|
||||
loading: 0,
|
||||
}),
|
||||
|
||||
created() {
|
||||
console.assert(!_m.modals.sharePhoto, 'ShareModal created twice');
|
||||
_m.modals.sharePhoto = this.open;
|
||||
console.assert(!_m.modals.sharePhotos, 'ShareModal created twice');
|
||||
_m.modals.sharePhotos = this.open;
|
||||
},
|
||||
|
||||
computed: {
|
||||
isVideo(): boolean {
|
||||
return !!this.photo && (this.photo.mimetype?.startsWith('video/') || !!(this.photo.flag & this.c.FLAG_IS_VIDEO));
|
||||
isSingle(): boolean {
|
||||
return this.photos?.length === 1 ?? false;
|
||||
},
|
||||
|
||||
hasVideos(): boolean {
|
||||
return !!this.photos?.some(utils.isVideo);
|
||||
},
|
||||
|
||||
canShareNative(): boolean {
|
||||
return 'share' in navigator || nativex.has();
|
||||
},
|
||||
|
||||
canShareTranscode(): boolean {
|
||||
return !this.isLocal && (!this.isVideo || !this.config.vod_disable);
|
||||
canShareLowRes(): boolean {
|
||||
// Only allow transcoding videos if a single video is selected
|
||||
return !this.hasLocal && (!this.hasVideos || (!this.config.vod_disable && this.isSingle));
|
||||
},
|
||||
|
||||
canShareHighRes(): boolean {
|
||||
// High-CPU operations only permitted for single node
|
||||
return this.isSingle && this.canShareLowRes;
|
||||
},
|
||||
|
||||
canShareLink(): boolean {
|
||||
return !!this.photo?.imageInfo?.permissions?.includes('S') && !this.routeIsAlbums;
|
||||
return !this.routeIsAlbums && !!this.photos?.every((p) => p?.imageInfo?.permissions?.includes('S'));
|
||||
},
|
||||
|
||||
isLocal(): boolean {
|
||||
return utils.isLocalPhoto(this.photo!);
|
||||
hasLocal(): boolean {
|
||||
return !!this.photos?.some(utils.isLocalPhoto);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
open(photo: IPhoto) {
|
||||
this.photo = photo;
|
||||
open(photos: IPhoto[]) {
|
||||
this.photos = photos;
|
||||
this.loading = 0;
|
||||
this.show = true;
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
this.show = false;
|
||||
this.photo = null;
|
||||
this.photos = null;
|
||||
},
|
||||
|
||||
async l<T>(cb: () => Promise<T>): Promise<T> {
|
||||
|
@ -164,93 +166,89 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
async sharePreview() {
|
||||
const src = this.isVideo
|
||||
? API.VIDEO_TRANSCODE(this.photo!.fileid, '480p.mp4')
|
||||
: utils.getPreviewUrl({ photo: this.photo!, size: 2048 });
|
||||
this.shareWithHref(src, true);
|
||||
async shareLowRes() {
|
||||
await this.shareWithHref(
|
||||
this.photos!.map((photo) => ({
|
||||
auid: String(), // no local
|
||||
href: utils.isVideo(photo)
|
||||
? API.VIDEO_TRANSCODE(photo.fileid, '480p.mp4')
|
||||
: utils.getPreviewUrl({ photo, size: 2048 }),
|
||||
})),
|
||||
);
|
||||
},
|
||||
|
||||
async shareHighRes() {
|
||||
const fileid = this.photo!.fileid;
|
||||
const src = this.isVideo
|
||||
? API.VIDEO_TRANSCODE(fileid, '1080p.mp4')
|
||||
: API.IMAGE_DECODABLE(fileid, this.photo!.etag);
|
||||
this.shareWithHref(src, !this.isVideo);
|
||||
await this.shareWithHref(
|
||||
this.photos!.map((photo) => ({
|
||||
auid: String(), // no local
|
||||
href: utils.isVideo(photo)
|
||||
? API.VIDEO_TRANSCODE(photo.fileid, '1080p.mp4')
|
||||
: API.IMAGE_DECODABLE(photo.fileid, photo.etag),
|
||||
})),
|
||||
);
|
||||
},
|
||||
|
||||
async shareOriginal() {
|
||||
if (nativex.has()) {
|
||||
try {
|
||||
return await this.l(async () => await nativex.shareLocal(this.photo!));
|
||||
} catch (e) {
|
||||
// maybe the file doesn't exist locally
|
||||
}
|
||||
|
||||
// if it's purel local, we can't share it
|
||||
if (this.isLocal) return;
|
||||
}
|
||||
|
||||
await this.shareWithHref(dav.getDownloadLink(this.photo!));
|
||||
await this.shareWithHref(
|
||||
this.photos!.map((photo) => ({
|
||||
auid: photo.auid ?? String(),
|
||||
href: dav.getDownloadLink(photo),
|
||||
})),
|
||||
);
|
||||
},
|
||||
|
||||
async shareLink() {
|
||||
const fileInfo = await this.l(async () => (await dav.getFiles([this.photo!]))[0]);
|
||||
const fileInfo = await this.l(async () => (await dav.getFiles([this.photos![0]]))[0]);
|
||||
await this.close(); // wait till transition is done
|
||||
_m.modals.shareNodeLink(fileInfo.filename, true);
|
||||
},
|
||||
|
||||
/**
|
||||
* Download a file and then share the blob.
|
||||
*
|
||||
* If a download object includes AUID then local download
|
||||
* is allowed when on NativeX.
|
||||
*/
|
||||
async shareWithHref(href: string, replaceExt = false) {
|
||||
async shareWithHref(
|
||||
objects: {
|
||||
auid: string;
|
||||
href: string;
|
||||
}[],
|
||||
) {
|
||||
if (nativex.has()) {
|
||||
return await this.l(async () => nativex.shareBlobFromUrl(href));
|
||||
return await this.l(async () => nativex.shareBlobs(objects));
|
||||
}
|
||||
|
||||
let blob: Blob | undefined;
|
||||
await this.l(async () => {
|
||||
const res = await axios.get(href, { responseType: 'blob' });
|
||||
blob = res.data;
|
||||
// Pull blobs in parallel
|
||||
const calls = objects.map((obj) => async () => {
|
||||
return await this.l(async () => {
|
||||
try {
|
||||
return await axios.get(obj.href, { responseType: 'blob' });
|
||||
} catch (e) {
|
||||
showError(this.t('memories', 'Failed to download file {href}', { href: obj.href }));
|
||||
return null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!blob) {
|
||||
showError(this.t('memories', 'Failed to download file'));
|
||||
return;
|
||||
}
|
||||
|
||||
let basename = this.photo?.basename ?? 'blank';
|
||||
|
||||
if (replaceExt) {
|
||||
// Fix basename extension
|
||||
let targetExts: string[] = [];
|
||||
if (blob.type === 'image/png') {
|
||||
targetExts = ['png'];
|
||||
} else {
|
||||
targetExts = ['jpg', 'jpeg'];
|
||||
}
|
||||
|
||||
// Append extension if not found
|
||||
const baseExt = basename.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (!targetExts.includes(baseExt)) {
|
||||
basename += '.' + targetExts[0];
|
||||
// Get all blobs from parallel calls
|
||||
const files: File[] = [];
|
||||
for await (const responses of dav.runInParallel(calls, 8)) {
|
||||
for (const res of responses.filter(Boolean)) {
|
||||
const filename = res!.headers['content-disposition']?.match(/filename="(.+)"/)?.[1] ?? '';
|
||||
const blob = res!.data;
|
||||
files.push(new File([blob], filename, { type: blob.type }));
|
||||
}
|
||||
}
|
||||
if (!files.length) return;
|
||||
|
||||
const data = {
|
||||
files: [
|
||||
new File([blob], basename, {
|
||||
type: blob.type,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
if (!navigator.canShare(data)) {
|
||||
// Check if we can share this type of data
|
||||
if (!navigator.canShare({ files })) {
|
||||
showError(this.t('memories', 'Cannot share this type of data'));
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.share(data);
|
||||
await navigator.share({ files });
|
||||
} catch (e) {
|
||||
// Don't show this error because it's silly stuff
|
||||
// like "share canceled"
|
||||
|
|
|
@ -986,8 +986,8 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
/** Share the current photo externally */
|
||||
async shareCurrent() {
|
||||
_m.modals.sharePhoto(this.currentPhoto!);
|
||||
shareCurrent() {
|
||||
_m.modals.sharePhotos([this.currentPhoto!]);
|
||||
},
|
||||
|
||||
/** Key press events */
|
||||
|
|
|
@ -40,7 +40,7 @@ declare global {
|
|||
modals: {
|
||||
editMetadata: (photos: IPhoto[], sections?: number[]) => void;
|
||||
updateAlbums: (photos: IPhoto[]) => void;
|
||||
sharePhoto: (photo: IPhoto) => void;
|
||||
sharePhotos: (photo: IPhoto[]) => void;
|
||||
shareNodeLink: (path: string, immediate?: boolean) => Promise<void>;
|
||||
moveToFolder: (photos: IPhoto[]) => void;
|
||||
moveToFace: (photos: IPhoto[]) => void;
|
||||
|
|
|
@ -69,22 +69,15 @@ export const NAPI = {
|
|||
*/
|
||||
SHARE_URL: (url: string) => `${BASE_URL}/api/share/url/${euc(euc(url))}`,
|
||||
/**
|
||||
* Share an object (as blob) natively using a given URL.
|
||||
* Share an object (as blob) natively.
|
||||
* The list of objects to share is already set using setShareBlobs
|
||||
* The native client MUST download the object using a download manager
|
||||
* and immediately prompt the user to download it. The asynchronous call
|
||||
* must return only after the object has been downloaded.
|
||||
* @regex ^/api/share/blob/.+$
|
||||
* @param url URL to share (double-encoded)
|
||||
* @regex ^/api/share/blobs$
|
||||
* @returns {void}
|
||||
*/
|
||||
SHARE_BLOB: (url: string) => `${BASE_URL}/api/share/blob/${euc(euc(url))}`,
|
||||
/**
|
||||
* Share a local file (as blob) with native page.
|
||||
* @regex ^/api/share/local/\d+$
|
||||
* @param auid AUID of the photo
|
||||
* @returns {void}
|
||||
*/
|
||||
SHARE_LOCAL: (auid: string) => `${BASE_URL}/api/share/local/${auid}`,
|
||||
SHARE_BLOBS: () => `${BASE_URL}/api/share/blobs`,
|
||||
|
||||
/**
|
||||
* Allow usage of local media (permissions request)
|
||||
|
@ -139,6 +132,13 @@ export type NativeX = {
|
|||
*/
|
||||
downloadFromUrl: (url: string, filename: string) => void;
|
||||
|
||||
/**
|
||||
* Set the list of objects to share with SHARE_BLOB API.
|
||||
* @param objects List of objects to share (JSON-encoded)
|
||||
* @details The SHARE_BLOB API must be called immediately after this call.
|
||||
*/
|
||||
setShareBlobs: (objects: string) => void;
|
||||
|
||||
/**
|
||||
* Play a video from the given AUID or URL(s).
|
||||
* @param auid AUID of file (will play local if available)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import axios from '@nextcloud/axios';
|
||||
import { BASE_URL, NAPI, nativex } from './api';
|
||||
import { NAPI, nativex } from './api';
|
||||
import { addOrigin } from './basic';
|
||||
import type { IPhoto } from '@typings';
|
||||
|
||||
/**
|
||||
* Download a file from the given URL.
|
||||
|
@ -29,17 +28,16 @@ export async function shareUrl(url: string) {
|
|||
/**
|
||||
* Download a blob from the given URL and share it.
|
||||
*/
|
||||
export async function shareBlobFromUrl(url: string) {
|
||||
if (url.startsWith(BASE_URL)) {
|
||||
throw new Error('Cannot share localhost URL');
|
||||
}
|
||||
await axios.get(NAPI.SHARE_BLOB(addOrigin(url)));
|
||||
}
|
||||
export async function shareBlobs(
|
||||
objects: {
|
||||
auid: string;
|
||||
href: string;
|
||||
}[],
|
||||
) {
|
||||
// Make sure all URLs are absolute
|
||||
objects.forEach((obj) => (obj.href = addOrigin(obj.href)));
|
||||
|
||||
/**
|
||||
* Share a local file with native page.
|
||||
*/
|
||||
export async function shareLocal(photo: IPhoto) {
|
||||
if (!photo.auid) throw new Error('Cannot share local file without AUID');
|
||||
await axios.get(NAPI.SHARE_LOCAL(photo.auid));
|
||||
// Hand off to native client
|
||||
nativex.setShareBlobs(JSON.stringify(objects));
|
||||
await axios.get(NAPI.SHARE_BLOBS());
|
||||
}
|
||||
|
|
|
@ -107,6 +107,14 @@ export function isLocalPhoto(photo: IPhoto): boolean {
|
|||
return Boolean(photo?.fileid) && Boolean((photo?.flag ?? 0) & c.FLAG_IS_LOCAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an object is a video
|
||||
* @param photo Photo object
|
||||
*/
|
||||
export function isVideo(photo: IPhoto): boolean {
|
||||
return !!photo?.mimetype?.startsWith('video/') || !!(photo.flag & c.FLAG_IS_VIDEO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for the imageInfo of a photo
|
||||
*
|
||||
|
|
Loading…
Reference in New Issue