Skip to content

Commit

Permalink
feat(app): take screenshot
Browse files Browse the repository at this point in the history
  • Loading branch information
aidenlx committed Jan 27, 2024
1 parent 12e0538 commit 39fae97
Show file tree
Hide file tree
Showing 14 changed files with 307 additions and 153 deletions.
2 changes: 1 addition & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@
"ahooks": "^3.7.8",
"assert-never": "^1.2.1",
"clsx": "^1.2.1",
"dayjs": "^1.11.10",
"iso-639-1": "^3.1.0",
"maverick.js": "0.41.2",
"mime": "^4.0.1",
"monkey-around": "^2.3.0",
"nanoevents": "^9.0.0",
"react": "^18.0.0",
Expand Down
20 changes: 3 additions & 17 deletions apps/app/src/components/player/buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
CaptionButton,
FullscreenButton,
isTrackCaptionKind,
isVideoProvider,
MuteButton,
PIPButton,
PlayButton,
Expand All @@ -13,7 +12,6 @@ import {
useMediaProvider,
useMediaState,
} from "@vidstack/react";
import { Platform } from "obsidian";
import { useEffect, useState } from "react";
import {
PlayIcon,
Expand All @@ -32,10 +30,9 @@ import {
ImageDownIcon,
PinIcon,
} from "@/components/icon";
import { WebiviewMediaProvider } from "@/lib/remote-player/provider";
import { captureScreenshot } from "@/lib/screenshot";
import { cn } from "@/lib/utils";
import { useIsEmbed, useScreenshot, useTimestamp } from "../context";
import { canProviderScreenshot, takeScreenshot } from "./screenshot";

export const buttonClass =
"group ring-mod-border-focus relative inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 focus-visible:ring-2 aria-disabled:hidden";
Expand Down Expand Up @@ -162,10 +159,6 @@ export function EditorEdit() {
);
}

function canProviderScreenshot(provider: any) {
return isVideoProvider(provider) || provider instanceof WebiviewMediaProvider;
}

export function useScreenshotHanlder() {
const provider = useMediaProvider();
const [canScreenshot, updateCanScreenshot] = useState<boolean>(() =>
Expand All @@ -175,16 +168,9 @@ export function useScreenshotHanlder() {
useEffect(() => {
updateCanScreenshot(canProviderScreenshot(provider));
}, [provider]);
if (!canScreenshot || !onScreenshot) return null;
if (!canScreenshot || !onScreenshot || !provider) return null;
return async () => {
const mimeType = Platform.isSafari ? "image/jpeg" : "image/webp";
if (isVideoProvider(provider)) {
onScreenshot(await captureScreenshot(provider.video, mimeType));
} else if (provider instanceof WebiviewMediaProvider) {
onScreenshot(await provider.media.methods.screenshot(mimeType));
} else {
throw new Error("Unsupported provider for screenshot");
}
onScreenshot(await takeScreenshot(provider));
};
}

Expand Down
20 changes: 20 additions & 0 deletions apps/app/src/components/player/screenshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { MediaProviderAdapter } from "@vidstack/react";
import { isVideoProvider } from "@vidstack/react";
import { Platform } from "obsidian";
import { WebiviewMediaProvider } from "@/lib/remote-player/provider";
import { captureScreenshot } from "@/lib/screenshot";

export function canProviderScreenshot(provider: MediaProviderAdapter | null) {
return isVideoProvider(provider) || provider instanceof WebiviewMediaProvider;
}

export async function takeScreenshot(provider: MediaProviderAdapter) {
const mimeType = Platform.isSafari ? "image/jpeg" : "image/webp";
if (isVideoProvider(provider)) {
return await captureScreenshot(provider.video, mimeType);
} else if (provider instanceof WebiviewMediaProvider) {
return await provider.media.methods.screenshot(mimeType);
} else {
throw new Error("Unsupported provider for screenshot");
}
}
1 change: 1 addition & 0 deletions apps/app/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const icons: Record<
string | null
> = {
bilibili: `<g transform="scale(5.556)"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.73252 2.67094C3.33229 2.28484 3.33229 1.64373 3.73252 1.25764C4.11291 0.890684 4.71552 0.890684 5.09591 1.25764L7.21723 3.30403C7.27749 3.36218 7.32869 3.4261 7.37081 3.49407H10.5789C10.6211 3.4261 10.6723 3.36218 10.7325 3.30403L12.8538 1.25764C13.2342 0.890684 13.8368 0.890684 14.2172 1.25764C14.6175 1.64373 14.6175 2.28484 14.2172 2.67094L13.364 3.49407H14C16.2091 3.49407 18 5.28493 18 7.49407V12.9996C18 15.2087 16.2091 16.9996 14 16.9996H4C1.79086 16.9996 0 15.2087 0 12.9996V7.49406C0 5.28492 1.79086 3.49407 4 3.49407H4.58579L3.73252 2.67094ZM4 5.42343C2.89543 5.42343 2 6.31886 2 7.42343V13.0702C2 14.1748 2.89543 15.0702 4 15.0702H14C15.1046 15.0702 16 14.1748 16 13.0702V7.42343C16 6.31886 15.1046 5.42343 14 5.42343H4ZM5 9.31747C5 8.76519 5.44772 8.31747 6 8.31747C6.55228 8.31747 7 8.76519 7 9.31747V10.2115C7 10.7638 6.55228 11.2115 6 11.2115C5.44772 11.2115 5 10.7638 5 10.2115V9.31747ZM12 8.31747C11.4477 8.31747 11 8.76519 11 9.31747V10.2115C11 10.7638 11.4477 11.2115 12 11.2115C12.5523 11.2115 13 10.7638 13 10.2115V9.31747C13 8.76519 12.5523 8.31747 12 8.31747Z" fill="currentColor"></path></g>`,
youtube: null,
};

Object.entries(icons).forEach(([name, svg]) => {
Expand Down
7 changes: 7 additions & 0 deletions apps/app/src/lib/hash/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ import { moment } from "obsidian";
import { isTimestamp, type TempFragment } from "./temporal-frag";

export function formatDuration(seconds: number) {
if (seconds === 0) return "00:00:00";
return moment
.utc(moment.duration({ seconds }).as("milliseconds"))
.format("HH:mm:ss");
}

export function toDurationISOString(duration: number) {
return duration === 0
? "DT0S"
: moment.duration(duration, "seconds").toISOString();
}

const fillZero = (time: number, fractionDigits = 2) => {
let main: string, frac: string | undefined;
if (Number.isInteger(time)) {
Expand Down
42 changes: 35 additions & 7 deletions apps/app/src/media-note/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
import type { MediaWebpageView } from "@/media-view/webpage-view";
import type MxPlugin from "@/mx-main";
import { parseUrl } from "./note-index/url-info";
import { takeTimestampOnFile, takeTimestampOnUrl } from "./timestamp";
import { saveScreenshot } from "./timestamp/screenshot";
import { takeTimestamp } from "./timestamp/timestamp";

export function handleMediaNote(this: MxPlugin) {
const { workspace } = this.app;
Expand Down Expand Up @@ -42,25 +43,52 @@ export function handleMediaNote(this: MxPlugin) {
if (isMediaFileViewType(viewType)) {
if (checking) return true;
const fileView = view as VideoFileView | AudioFileView;
const takeTimestamp = takeTimestampOnFile(fileView, () =>
fileView.getMediaInfo(),
takeTimestamp(fileView, () => fileView.getMediaInfo());
} else if (
isMediaUrlViewType(viewType) ||
MEDIA_EMBED_VIEW_TYPE === viewType ||
MEDIA_WEBPAGE_VIEW_TYPE === viewType
) {
if (checking) return true;
takeTimestamp(
view as
| VideoUrlView
| AudioUrlView
| MediaWebpageView
| MediaEmbedView,
(player) => parseUrl(player.store.getState().source?.original),
);
takeTimestamp();
}
},
});
this.addCommand({
id: "save-screenshot-view",
name: "Save screenshot on current media",
icon: "camera",
checkCallback: (checking) => {
// eslint-disable-next-line deprecation/deprecation
const leaf = workspace.activeLeaf;
if (!leaf) return false;
const view = leaf.view;
const viewType = view.getViewType();
if (isMediaFileViewType(viewType)) {
if (checking) return true;
const fileView = view as VideoFileView | AudioFileView;
saveScreenshot(fileView, () => fileView.getMediaInfo());
} else if (
isMediaUrlViewType(viewType) ||
MEDIA_EMBED_VIEW_TYPE === viewType ||
MEDIA_WEBPAGE_VIEW_TYPE === viewType
) {
if (checking) return true;
const takeTimestamp = takeTimestampOnUrl(
saveScreenshot(
view as
| VideoUrlView
| AudioUrlView
| MediaWebpageView
| MediaEmbedView,
(player) => parseUrl(player.source),
(player) => parseUrl(player.store.getState().source?.original),
);
takeTimestamp();
}
},
});
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/media-note/leaf-open/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { isFileMediaInfo } from "../note-index/file-info";
import type { UrlMediaInfo } from "../note-index/url-info";
import { filterFileLeaf, filterUrlLeaf, sortByMtime } from "./utils";

interface NewNoteInfo {
export interface NewNoteInfo {
title: string;
fm: (newNotePath: string) => Record<string, any>;
sourcePath?: string;
Expand Down
112 changes: 0 additions & 112 deletions apps/app/src/media-note/timestamp.ts

This file was deleted.

84 changes: 84 additions & 0 deletions apps/app/src/media-note/timestamp/screenshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import mime from "mime";
import { Notice } from "obsidian";
import {
canProviderScreenshot,
takeScreenshot,
} from "@/components/player/screenshot";
import { formatDuration, toDurationISOString } from "@/lib/hash/format";
import type { PlayerComponent } from "@/media-view/base";
import type { MediaInfo } from "../note-index";
import {
createTimestampGen,
insertTimestamp,
mediaTitle,
openOrCreateMediaNote,
} from "./utils";

declare module "obsidian" {
interface Vault {
getAvailablePathForAttachments(
fileName: string,
extension: string,
activeFile: TFile | null,
): Promise<string>;
}
}

export async function saveScreenshot<T extends PlayerComponent>(
playerComponent: T,
getSource: (player: T) => MediaInfo | null,
) {
const { fileManager, vault } = playerComponent.plugin.app;
const player = playerComponent.store.getState().player;
if (!player) {
new Notice("Player not initialized");
return;
}
const mediaInfo = getSource(playerComponent);
if (!mediaInfo) {
new Notice("No media is opened");
return;
}
if (!player?.provider || !canProviderScreenshot(player.provider)) {
new Notice("Screenshot is not supported for this media");
return;
}
const { blob, time } = await takeScreenshot(player.provider);
const genTimestamp = createTimestampGen(time, mediaInfo, playerComponent);

const ext = mime.getExtension(blob.type);
if (!ext) {
new Notice("Unknown mime type: " + blob.type);
return;
}

const title = mediaTitle(mediaInfo, player.state);
const screenshotName = title + toDurationISOString(time);
const humanizedDuration = time > 0 ? ` - ${formatDuration(time)}` : "";

const { file: newNote, editor } = await openOrCreateMediaNote(
mediaInfo,
playerComponent,
);
const screenshotPath = await vault.getAvailablePathForAttachments(
screenshotName,
ext,
newNote,
);
const screenshotFile = await vault.createBinary(
screenshotPath,
blob.arrayBuffer,
);
new Notice("Screenshot saved to " + screenshotFile.path);
const timestamp = genTimestamp(newNote.path),
screenshotEmbed = fileManager.generateMarkdownLink(
screenshotFile,
newNote.path,
"",
`${title}${humanizedDuration}|50`,
);

insertTimestamp(`- ${screenshotEmbed} ${timestamp}`, editor, () =>
playerComponent.containerEl.focus(),
);
}
Loading

0 comments on commit 39fae97

Please sign in to comment.