From 5d6ae4f97d2c644547f3040c5ac5783da7e66253 Mon Sep 17 00:00:00 2001 From: aidenlx <31102694+aidenlx@users.noreply.github.com> Date: Wed, 17 Jan 2024 19:14:43 +0800 Subject: [PATCH] perf(app): add media note index --- apps/app/package.json | 1 + apps/app/src/media-note/index.ts | 19 +- .../app/src/media-note/link-click/external.ts | 81 +----- .../app/src/media-note/link-click/internal.ts | 35 +-- apps/app/src/media-note/link-click/opened.ts | 12 +- apps/app/src/media-note/manager/file-info.ts | 17 ++ apps/app/src/media-note/manager/index.ts | 211 ++++++++++++++++ apps/app/src/media-note/manager/url-info.ts | 72 ++++++ apps/app/src/media-note/timestamp.ts | 56 ++--- apps/app/src/media-note/utils.ts | 234 ++++-------------- apps/app/src/media-view/base.tsx | 3 +- apps/app/src/media-view/file-view.tsx | 28 ++- apps/app/src/mx-main.ts | 2 + pnpm-lock.yaml | 7 + 14 files changed, 435 insertions(+), 343 deletions(-) create mode 100644 apps/app/src/media-note/manager/file-info.ts create mode 100644 apps/app/src/media-note/manager/index.ts create mode 100644 apps/app/src/media-note/manager/url-info.ts diff --git a/apps/app/package.json b/apps/app/package.json index 4c27bc94..308abb44 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-tooltip": "^1.0.6", "@vidstack/react": "npm:@aidenlx/vidstack-react@1.9.8-mod.10", "ahooks": "^3.7.8", + "assert-never": "^1.2.1", "clsx": "^1.2.1", "dayjs": "^1.11.10", "iso-639-1": "^3.1.0", diff --git a/apps/app/src/media-note/index.ts b/apps/app/src/media-note/index.ts index 0e716a99..02548007 100644 --- a/apps/app/src/media-note/index.ts +++ b/apps/app/src/media-note/index.ts @@ -1,4 +1,4 @@ -import type { TFile } from "obsidian"; +import { TFile } from "obsidian"; import type { AudioFileView, VideoFileView } from "@/media-view/file-view"; import { isMediaFileViewType } from "@/media-view/file-view"; import type { MediaEmbedView } from "@/media-view/iframe-view"; @@ -8,22 +8,23 @@ import { isMediaUrlViewType } from "@/media-view/url-view"; import type { MediaWebpageView } from "@/media-view/webpage-view"; import { MEDIA_WEBPAGE_VIEW_TYPE } from "@/media-view/webpage-view"; import type MxPlugin from "@/mx-main"; +import { parseUrl } from "./manager/url-info"; import { takeTimestampOnFile, takeTimestampOnUrl } from "./timestamp"; -import { noteUtils } from "./utils"; +import { openMedia } from "./utils"; export function handleMediaNote(this: MxPlugin) { - const { openMedia, getMediaInfo } = noteUtils(this.app); const { workspace } = this.app; this.registerEvent( this.app.workspace.on("file-menu", (menu, file, _source, _leaf) => { - const mediaInfo = getMediaInfo(file); + if (!(file instanceof TFile)) return; + const mediaInfo = this.mediaNote.findMedia(file); if (!mediaInfo) return; menu.addItem((item) => item .setSection("view") .setIcon("play") .setTitle("Open linked media") - .onClick(() => openMedia(mediaInfo, file as TFile)), + .onClick(() => openMedia(mediaInfo, this.app)), ); }), ); @@ -39,9 +40,9 @@ export function handleMediaNote(this: MxPlugin) { const viewType = view.getViewType(); if (isMediaFileViewType(viewType)) { if (checking) return true; - const takeTimestamp = takeTimestampOnFile( - view as VideoFileView | AudioFileView, - (player) => player.file, + const fileView = view as VideoFileView | AudioFileView; + const takeTimestamp = takeTimestampOnFile(fileView, () => + fileView.getMediaInfo(), ); takeTimestamp(); } else if ( @@ -56,7 +57,7 @@ export function handleMediaNote(this: MxPlugin) { | AudioUrlView | MediaWebpageView | MediaEmbedView, - (player) => player.source, + (player) => parseUrl(player.source), ); takeTimestamp(); } diff --git a/apps/app/src/media-note/link-click/external.ts b/apps/app/src/media-note/link-click/external.ts index 74c74f57..a52fc7bb 100644 --- a/apps/app/src/media-note/link-click/external.ts +++ b/apps/app/src/media-note/link-click/external.ts @@ -1,84 +1,11 @@ import type { WorkspaceLeaf } from "obsidian"; -import { noHash } from "@/lib/url"; -import { MEDIA_EMBED_VIEW_TYPE } from "@/media-view/iframe-view"; -import type { - MediaEmbedViewType, - MediaEmbedViewState, -} from "@/media-view/iframe-view"; -import type { MediaUrlViewType } from "@/media-view/url-view"; -import { MEDIA_URL_VIEW_TYPE } from "@/media-view/url-view"; -import { MEDIA_WEBPAGE_VIEW_TYPE } from "@/media-view/webpage-view"; -import type { - MediaWebpageViewType, - MediaWebpageViewState, -} from "@/media-view/webpage-view"; +import type { MediaEmbedViewState } from "@/media-view/iframe-view"; +import type { MediaWebpageViewState } from "@/media-view/webpage-view"; import type MxPlugin from "@/mx-main"; -import { matchHostForEmbed } from "@/web/match-embed"; -import { matchHostForUrl } from "@/web/match-url"; -import { matchHostForWeb, SupportedWebHost } from "@/web/match-webpage"; +import type { UrlMediaInfo } from "../manager/url-info"; +import { parseUrl } from "../manager/url-info"; import { openInOpenedPlayer } from "./opened"; -export interface UrlMediaInfo { - viewType: MediaUrlViewType | MediaEmbedViewType | MediaWebpageViewType; - source: URL; - original: string; - hash: string; - isSameSource: (src: string) => boolean; -} -export function parseUrl(url: string): UrlMediaInfo | null { - const directlinkInfo = matchHostForUrl(url); - - if (directlinkInfo) { - return { - viewType: MEDIA_URL_VIEW_TYPE[directlinkInfo.type], - source: directlinkInfo.source, - original: url, - hash: directlinkInfo.source.hash, - isSameSource: (src) => { - const matched = matchHostForUrl(src); - return ( - !!matched && - noHash(matched.cleanUrl) === noHash(directlinkInfo.cleanUrl) - ); - }, - }; - } - - const embedInfo = matchHostForEmbed(url); - - if (embedInfo) { - return { - viewType: MEDIA_EMBED_VIEW_TYPE, - source: embedInfo.source, - original: url, - hash: embedInfo.source.hash, - isSameSource: (src) => { - const matched = matchHostForEmbed(src); - return ( - !!matched && noHash(matched.cleanUrl) === noHash(embedInfo.cleanUrl) - ); - }, - }; - } - - const webpageInfo = matchHostForWeb(url); - if (webpageInfo && webpageInfo.type !== SupportedWebHost.Generic) { - return { - viewType: MEDIA_WEBPAGE_VIEW_TYPE, - source: webpageInfo.source, - original: url, - hash: webpageInfo.source.hash, - isSameSource: (src) => { - const matched = matchHostForWeb(src); - return ( - !!matched && noHash(matched.cleanUrl) === noHash(webpageInfo.cleanUrl) - ); - }, - }; - } - return null; -} - export async function onExternalLinkClick( this: MxPlugin, url: string, diff --git a/apps/app/src/media-note/link-click/internal.ts b/apps/app/src/media-note/link-click/internal.ts index 947696eb..ebca89ca 100644 --- a/apps/app/src/media-note/link-click/internal.ts +++ b/apps/app/src/media-note/link-click/internal.ts @@ -1,24 +1,9 @@ -import type { TFile } from "obsidian"; import { parseLinktext } from "obsidian"; -import { - MEDIA_FILE_VIEW_TYPE, - isMediaFileViewType, - type MediaFileViewType, -} from "@/media-view/file-view"; +import { MEDIA_FILE_VIEW_TYPE } from "@/media-view/file-view"; import type MxPlugin from "@/mx-main"; import { checkMediaType } from "@/patch/utils"; import { openInOpenedPlayer } from "./opened"; -export interface FileMediaInfo { - viewType: MediaFileViewType; - file: TFile; - hash: string; -} - -export function isFileMediaInfo(info: unknown): info is FileMediaInfo { - return isMediaFileViewType((info as FileMediaInfo).viewType); -} - export function onInternalLinkClick( this: MxPlugin, linktext: string, @@ -34,17 +19,13 @@ export function onInternalLinkClick( fallback(); return; } - if ( - !newLeaf && - openInOpenedPlayer( - { - file: linkFile, - hash: subpath, - viewType: MEDIA_FILE_VIEW_TYPE[mediaType], - }, - workspace, - ) - ) { + const mediaInfo = { + file: linkFile, + hash: subpath, + type: mediaType, + viewType: MEDIA_FILE_VIEW_TYPE[mediaType], + }; + if (!newLeaf && openInOpenedPlayer(mediaInfo, workspace)) { return; } fallback(); diff --git a/apps/app/src/media-note/link-click/opened.ts b/apps/app/src/media-note/link-click/opened.ts index 0e16d1f9..2f45044e 100644 --- a/apps/app/src/media-note/link-click/opened.ts +++ b/apps/app/src/media-note/link-click/opened.ts @@ -2,8 +2,9 @@ import type { Workspace, WorkspaceLeaf } from "obsidian"; import type { MediaEmbedViewState } from "@/media-view/iframe-view"; import type { MediaUrlViewState } from "@/media-view/url-view"; import type { MediaWebpageViewState } from "@/media-view/webpage-view"; -import type { UrlMediaInfo } from "./external"; -import { isFileMediaInfo, type FileMediaInfo } from "./internal"; +import type { MediaInfo } from "../manager"; +import { isFileMediaInfo, type FileMediaInfo } from "../manager/file-info"; +import type { UrlMediaInfo } from "../manager/url-info"; function filterFileLeaf(leaf: WorkspaceLeaf, info: FileMediaInfo) { const { file: filePath } = leaf.view.getState() as { file: string }; @@ -18,10 +19,7 @@ function filterUrlLeaf(leaf: WorkspaceLeaf, info: UrlMediaInfo) { return source && info.isSameSource(source); } -export function getLeavesOfMedia( - info: UrlMediaInfo | FileMediaInfo, - workspace: Workspace, -) { +export function getLeavesOfMedia(info: MediaInfo, workspace: Workspace) { return workspace.getLeavesOfType(info.viewType).filter((leaf) => { if (isFileMediaInfo(info)) { return filterFileLeaf(leaf, info); @@ -36,7 +34,7 @@ export function updateHash(hash: string, leaf: WorkspaceLeaf) { } export function openInOpenedPlayer( - info: FileMediaInfo | UrlMediaInfo, + info: MediaInfo, workspace: Workspace, ): boolean { const opened = getLeavesOfMedia(info, workspace); diff --git a/apps/app/src/media-note/manager/file-info.ts b/apps/app/src/media-note/manager/file-info.ts new file mode 100644 index 00000000..3036195d --- /dev/null +++ b/apps/app/src/media-note/manager/file-info.ts @@ -0,0 +1,17 @@ +import type { TFile } from "obsidian"; +import { + isMediaFileViewType, + type MediaFileViewType, +} from "@/media-view/file-view"; +import type { MediaType } from "@/patch/utils"; + +export interface FileMediaInfo { + viewType: MediaFileViewType; + type: MediaType; + file: TFile; + hash: string; +} + +export function isFileMediaInfo(info: unknown): info is FileMediaInfo { + return isMediaFileViewType((info as FileMediaInfo).viewType); +} diff --git a/apps/app/src/media-note/manager/index.ts b/apps/app/src/media-note/manager/index.ts new file mode 100644 index 00000000..73a53ad0 --- /dev/null +++ b/apps/app/src/media-note/manager/index.ts @@ -0,0 +1,211 @@ +import { Component, TFolder, TFile, parseLinktext } from "obsidian"; +import type { MetadataCache, App, Vault, CachedMetadata } from "obsidian"; +import { toURL } from "@/lib/url"; +import { MEDIA_FILE_VIEW_TYPE } from "@/media-view/file-view"; +import { checkMediaType } from "@/patch/utils"; +import { isFileMediaInfo, type FileMediaInfo } from "./file-info"; +import { parseUrl, type UrlMediaInfo } from "./url-info"; + +export type MediaInfo = FileMediaInfo | UrlMediaInfo; + +declare module "obsidian" { + interface MetadataCache { + on(name: "finished", callback: () => any, ctx?: any): EventRef; + on(name: "initialized", callback: () => any, ctx?: any): EventRef; + initialized: boolean; + } +} + +export class MediaNoteManager extends Component { + constructor(public app: App) { + super(); + } + + private noteToMediaIndex = new Map(); + private mediaToNoteIndex = new Map>(); + + findNotes(media: MediaInfo): TFile[] { + const notes = this.mediaToNoteIndex.get(mediaInfoToString(media)); + if (!notes) return []; + return [...notes]; + } + findMedia(note: TFile) { + return this.noteToMediaIndex.get(note.path); + } + + private onResolve() { + this.noteToMediaIndex.clear(); + this.mediaToNoteIndex.clear(); + for (const { file, mediaInfo } of iterateMediaNote(this.app)) { + this.addMediaNote(mediaInfo, file); + } + this.registerEvent( + this.app.metadataCache.on("changed", (file) => { + const mediaInfo = getMediaNoteMeta(file, this.app); + if (!mediaInfo) return; + this.addMediaNote(mediaInfo, file); + }), + ); + this.registerEvent( + this.app.metadataCache.on("deleted", (file) => { + this.removeMediaNote(file); + }), + ); + this.registerEvent( + this.app.vault.on("rename", (file, oldPath) => { + if (!this.noteToMediaIndex.has(oldPath)) return; + const mediaInfo = this.noteToMediaIndex.get(oldPath)!; + this.noteToMediaIndex.delete(oldPath); + this.noteToMediaIndex.set(file.path, mediaInfo); + // mediaToNoteIndex don't need to update + // since TFile pointer is not changed + }), + ); + } + + removeMediaNote(toRemove: TFile) { + const mediaInfo = this.noteToMediaIndex.get(toRemove.path)!; + if (!mediaInfo) return; + this.noteToMediaIndex.delete(toRemove.path); + const mediaInfoKey = mediaInfoToString(mediaInfo); + const mediaNotes = this.mediaToNoteIndex.get(mediaInfoKey); + if (!mediaNotes) return; + mediaNotes.delete(toRemove); + if (mediaNotes.size === 0) { + this.mediaToNoteIndex.delete(mediaInfoKey); + } + } + addMediaNote(mediaInfo: MediaInfo, newNote: TFile) { + this.noteToMediaIndex.set(newNote.path, mediaInfo); + const mediaNotes = this.mediaToNoteIndex.get(mediaInfoToString(mediaInfo)); + if (!mediaNotes) { + this.mediaToNoteIndex.set( + mediaInfoToString(mediaInfo), + new Set([newNote]), + ); + } else { + mediaNotes.add(newNote); + } + } + + onload(): void { + waitUntilResolve(this.app.metadataCache, this).then(() => { + this.onResolve(); + }); + } +} + +function waitUntilResolve( + meta: MetadataCache, + component?: Component, +): Promise { + if (meta.initialized) return Promise.resolve(); + return new Promise((resolve) => { + const evt = meta.on("initialized", () => { + meta.offref(evt); + resolve(); + }); + component?.registerEvent(evt); + }); +} +function* iterateFiles(folder: TFolder): IterableIterator { + for (const child of folder.children) { + if (child instanceof TFolder) { + yield* iterateFiles(child); + } else if (child instanceof TFile) { + yield child; + } + } +} + +function* iterateMediaNote(ctx: { + metadataCache: MetadataCache; + vault: Vault; +}) { + for (const file of iterateFiles(ctx.vault.getRoot())) { + if (file.extension !== "md") continue; + const mediaInfo = getMediaNoteMeta(file, ctx); + if (!mediaInfo) continue; + yield { mediaInfo, file }; + } +} + +export const mediaSourceField = { + generic: "media", + video: "video", + audio: "audio", +} as const; +type MediaType = (typeof mediaSourceField)[keyof typeof mediaSourceField]; + +export interface InternalLinkField { + type: "internal"; + media: "video" | "audio"; + source: TFile; + subpath: string; + original: string; +} +export interface ExternalLinkField { + type: "external"; + media: MediaType; + source: URL; + subpath: string; + original: string; + isSameSource: (src: string) => boolean; +} + +function getMediaNoteMeta( + file: TFile, + { metadataCache }: { metadataCache: MetadataCache }, +): MediaInfo | null { + const meta = metadataCache.getFileCache(file); + if (!meta) return null; + const ctx = { metadataCache, sourcePath: file.path }; + + // prefer explicit typed media + return ( + getField(mediaSourceField.video, meta, ctx) ?? + getField(mediaSourceField.audio, meta, ctx) ?? + getField(mediaSourceField.generic, meta, ctx) + ); +} + +function getField( + key: MediaType, + meta: CachedMetadata, + ctx: { metadataCache: MetadataCache; sourcePath: string }, +): MediaInfo | null { + const { frontmatter, frontmatterLinks } = meta; + if (!frontmatter || !(key in frontmatter)) return null; + const linkCache = frontmatterLinks?.find((link) => link.key === key); + if (linkCache) { + const { path: linkpath, subpath } = parseLinktext(linkCache.link); + const mediaFile = ctx.metadataCache.getFirstLinkpathDest( + linkpath, + ctx.sourcePath, + ); + if (!mediaFile) return null; + // if local file, prefer detect media type + const mediaType = checkMediaType(mediaFile.extension); + if (!mediaType) return null; + return { + type: mediaType, + viewType: MEDIA_FILE_VIEW_TYPE[mediaType], + file: mediaFile, + hash: subpath, + }; + } + const content = frontmatter[key]; + if (typeof content !== "string") return null; + const url = toURL(content); + if (!url) return null; + const urlInfo = parseUrl(url.href); + return urlInfo; +} + +function mediaInfoToString(info: MediaInfo) { + if (isFileMediaInfo(info)) { + return `file:${info.file.path}`; + } else { + return `url:${info.original}`; + } +} diff --git a/apps/app/src/media-note/manager/url-info.ts b/apps/app/src/media-note/manager/url-info.ts new file mode 100644 index 00000000..3f1bb486 --- /dev/null +++ b/apps/app/src/media-note/manager/url-info.ts @@ -0,0 +1,72 @@ +import { noHash } from "@/lib/url"; +import { MEDIA_EMBED_VIEW_TYPE } from "@/media-view/iframe-view"; +import type { MediaEmbedViewType } from "@/media-view/iframe-view"; +import type { MediaUrlViewType } from "@/media-view/url-view"; +import { MEDIA_URL_VIEW_TYPE } from "@/media-view/url-view"; +import { MEDIA_WEBPAGE_VIEW_TYPE } from "@/media-view/webpage-view"; +import type { MediaWebpageViewType } from "@/media-view/webpage-view"; +import { matchHostForEmbed } from "@/web/match-embed"; +import { matchHostForUrl } from "@/web/match-url"; +import { matchHostForWeb, SupportedWebHost } from "@/web/match-webpage"; + +export interface UrlMediaInfo { + viewType: MediaUrlViewType | MediaEmbedViewType | MediaWebpageViewType; + source: URL; + original: string; + hash: string; + isSameSource: (src: string) => boolean; +} +export function parseUrl(url: string | null): UrlMediaInfo | null { + if (!url) return null; + const directlinkInfo = matchHostForUrl(url); + + if (directlinkInfo) { + return { + viewType: MEDIA_URL_VIEW_TYPE[directlinkInfo.type], + source: directlinkInfo.source, + original: url, + hash: directlinkInfo.source.hash, + isSameSource: (src) => { + const matched = matchHostForUrl(src); + return ( + !!matched && + noHash(matched.cleanUrl) === noHash(directlinkInfo.cleanUrl) + ); + }, + }; + } + + const embedInfo = matchHostForEmbed(url); + + if (embedInfo) { + return { + viewType: MEDIA_EMBED_VIEW_TYPE, + source: embedInfo.source, + original: url, + hash: embedInfo.source.hash, + isSameSource: (src) => { + const matched = matchHostForEmbed(src); + return ( + !!matched && noHash(matched.cleanUrl) === noHash(embedInfo.cleanUrl) + ); + }, + }; + } + + const webpageInfo = matchHostForWeb(url); + if (webpageInfo && webpageInfo.type !== SupportedWebHost.Generic) { + return { + viewType: MEDIA_WEBPAGE_VIEW_TYPE, + source: webpageInfo.source, + original: url, + hash: webpageInfo.source.hash, + isSameSource: (src) => { + const matched = matchHostForWeb(src); + return ( + !!matched && noHash(matched.cleanUrl) === noHash(webpageInfo.cleanUrl) + ); + }, + }; + } + return null; +} diff --git a/apps/app/src/media-note/timestamp.ts b/apps/app/src/media-note/timestamp.ts index 30268892..951619a9 100644 --- a/apps/app/src/media-note/timestamp.ts +++ b/apps/app/src/media-note/timestamp.ts @@ -1,37 +1,32 @@ import { Notice } from "obsidian"; -import type { Editor, TFile } from "obsidian"; +import type { Editor } from "obsidian"; import { formatDuration, toTempFragString } from "@/lib/hash/format"; -import { noHash, toURL } from "@/lib/url"; +import { noHash } from "@/lib/url"; import type { PlayerComponent } from "@/media-view/base"; -import { checkMediaType } from "@/patch/utils"; -import { noteUtils } from "./utils"; +import type { FileMediaInfo } from "./manager/file-info"; +import type { UrlMediaInfo } from "./manager/url-info"; +import { openMarkdownView } from "./utils"; export function takeTimestampOnUrl( playerComponent: T, - getSource: (player: T) => string | null, + getSource: (player: T) => UrlMediaInfo | null, ) { - const { mediaNoteFinder, openMarkdownView } = noteUtils( - playerComponent.plugin.app, - ); return async function takeTimestamp() { const player = playerComponent.store.getState().player; if (!player) { new Notice("Player not initialized"); return; } - const source = getSource(playerComponent); - if (!source) { + const mediaInfo = getSource(playerComponent); + if (!mediaInfo) { new Notice("No media is opened"); return; } - const sourceUrl = toURL(source); - if (!sourceUrl) { - new Notice("Invalid URL: " + source); - return; - } + const sourceUrl = mediaInfo.source; const time = player.currentTime; - const existingMediaNotes = mediaNoteFinder.url(source); + const existingMediaNotes = + playerComponent.plugin.mediaNote.findNotes(mediaInfo); const title = player.state.title ?? sourceUrl.hostname + decodeURI(sourceUrl.pathname).replaceAll("/", "_"); @@ -41,6 +36,7 @@ export function takeTimestampOnUrl( title, () => ({ media: noHash(sourceUrl) }), "", + playerComponent.plugin.app, ); if (time > 0) { @@ -55,11 +51,8 @@ export function takeTimestampOnUrl( export function takeTimestampOnFile( playerComponent: T, - getSource: (player: T) => TFile | null, + getSource: (player: T) => FileMediaInfo | null, ) { - const { mediaNoteFinder, openMarkdownView } = noteUtils( - playerComponent.plugin.app, - ); const { metadataCache, fileManager } = playerComponent.plugin.app; return async function takeTimestamp() { const player = playerComponent.store.getState().player; @@ -67,34 +60,33 @@ export function takeTimestampOnFile( new Notice("Player not initialized"); return; } - const source = getSource(playerComponent); - if (!source) { + const mediaInfo = getSource(playerComponent); + if (!mediaInfo) { new Notice("No media file is opened"); return; } - const mediaType = checkMediaType(source.extension); - if (!mediaType) { - new Notice("media file format not supported"); - return; - } + + const { file, type: mediaType } = mediaInfo; const time = player.currentTime; - const existingMediaNotes = mediaNoteFinder.local(source); - const title = player.title ?? source.basename; + const existingMediaNotes = + playerComponent.plugin.mediaNote.findNotes(mediaInfo); + const title = player.title ?? file.basename; const view = await openMarkdownView( existingMediaNotes, title, (newNotePath) => ({ - [mediaType]: `[[${metadataCache.fileToLinktext(source, newNotePath)}]]`, + [mediaType]: `[[${metadataCache.fileToLinktext(file, newNotePath)}]]`, }), - source.path, + file.path, + playerComponent.plugin.app, ); if (time > 0) { const hash = toTempFragString({ start: time, end: -1 })!; const link = fileManager.generateMarkdownLink( - source, + file, view.file.path, "#" + hash, formatDuration(time), diff --git a/apps/app/src/media-note/utils.ts b/apps/app/src/media-note/utils.ts index 9427a2ca..d32adaec 100644 --- a/apps/app/src/media-note/utils.ts +++ b/apps/app/src/media-note/utils.ts @@ -1,161 +1,37 @@ -import { Notice, TFile, parseLinktext, TFolder } from "obsidian"; -import type { - CachedMetadata, - TAbstractFile, - App, - MarkdownView, - Editor, -} from "obsidian"; -import { toURL } from "@/lib/url"; -import { openInLeaf, parseUrl } from "@/media-note/link-click/external"; +import { Notice } from "obsidian"; +import type { App, MarkdownView, Editor, TFile, Workspace } from "obsidian"; +import { openInLeaf } from "@/media-note/link-click/external"; import { openInOpenedPlayer } from "@/media-note/link-click/opened"; -import { checkMediaType } from "@/patch/utils"; +import type { MediaInfo } from "./manager"; +import { isFileMediaInfo } from "./manager/file-info"; +import { parseUrl } from "./manager/url-info"; -export const mediaSourceField = { - generic: "media", - video: "video", - audio: "audio", -} as const; - -type MediaType = (typeof mediaSourceField)[keyof typeof mediaSourceField]; - -export interface InternalLinkField { - type: "internal"; - media: MediaType; - source: string; - original: string; -} -export interface ExternalLinkField { - type: "external"; - media: MediaType; - source: URL; -} - -export function noteUtils({ - metadataCache, - workspace, - vault, - fileManager, -}: App) { - const mediaNoteFinder = { - local: (source: TFile) => { - return Array.from(iterateMatchedMediaNote()); - - function* iterateMatchedMediaNote() { - for (const { mediaInfo, file } of iterateMediaNote()) { - if (mediaInfo.type !== "internal") continue; - const linkinfo = resolveLink(mediaInfo.source, file); - if (!linkinfo || linkinfo.file.path !== source.path) continue; - yield file; - } - } - }, - url: (source: string) => { - return Array.from(iterateMatchedMediaNote()); - function* iterateMatchedMediaNote() { - const myUrlInfo = parseUrl(source); - if (!myUrlInfo) return; - for (const { mediaInfo, file } of iterateMediaNote()) { - if ( - mediaInfo.type !== "external" || - !myUrlInfo.isSameSource(mediaInfo.source.href) - ) - continue; - yield file; - } - } - }, - }; - - return { - mediaNoteFinder, - getMediaInfo, - resolveLink, - openMedia, - openMarkdownView, - }; - - async function openMarkdownView( - notes: TFile[], - newNoteTitle: string, - toNewNoteFm: (sourcePath: string) => Record, - sourcePath: string, - ): Promise<{ file: TFile; editor: Editor }> { - if (notes.length > 0) { - const view = getOpenedView(notes); - if (view) return { file: view.file!, editor: view.editor }; - const leaf = workspace.getLeaf("split", "vertical"); - const targetNote = notes[0]; - await leaf.openFile(targetNote); - return { - file: targetNote, - editor: (leaf.view as MarkdownView).editor, - }; - } - const filename = `Media Note - ${newNoteTitle}.md`; - const view = await createNewNoteAndOpen(filename, toNewNoteFm, sourcePath); - return { file: view.file!, editor: view.editor }; - } - - function getMediaInfo( - file: TAbstractFile, - ): ExternalLinkField | InternalLinkField | null { - if (!(file instanceof TFile)) return null; - const meta = metadataCache.getFileCache(file); - if (!meta) return null; - - // prefer explicit typed media - return ( - getField(mediaSourceField.video, meta) ?? - getField(mediaSourceField.audio, meta) ?? - getField(mediaSourceField.generic, meta) - ); - } - - function resolveLink(linktext: string, file: TFile) { - const { path: linkpath, subpath } = parseLinktext(linktext); - const mediaFile = metadataCache.getFirstLinkpathDest(linkpath, file.path); - if (!mediaFile) return null; - // if local file, prefer detect media type - const mediaType = checkMediaType(mediaFile.extension); - if (!mediaType) return null; - return { file: mediaFile, subpath }; - } - - async function openMedia( - mediaInfo: InternalLinkField | ExternalLinkField, - file: TFile, - ) { +/** + * @param sourcePath path where new note will be created from + * @returns + */ +export async function openMarkdownView( + notes: TFile[], + newNoteTitle: string, + toNewNoteFm: (sourcePath: string) => Record, + sourcePath: string, + { workspace, fileManager, vault }: App, +): Promise<{ file: TFile; editor: Editor }> { + if (notes.length > 0) { + const view = getOpenedView(notes); + if (view) return { file: view.file!, editor: view.editor }; const leaf = workspace.getLeaf("split", "vertical"); - if (mediaInfo.type === "internal") { - const linkFile = resolveLink(mediaInfo.source, file as TFile); - if (!linkFile) { - new Notice(`Cannot resolve media file from link ${mediaInfo.original}`); - return; - } - await leaf.openFile(linkFile.file, { - eState: { subpath: linkFile.subpath }, - }); - } else { - const urlInfo = parseUrl(mediaInfo.source.href); - if (!urlInfo) { - new Notice( - `Failed to open media url ${mediaInfo.source.href}, invalid url or not supported`, - ); - return; - } - if (openInOpenedPlayer(urlInfo, workspace)) return; - await openInLeaf(urlInfo, leaf); - } - } - function* iterateMediaNote() { - for (const file of iterateFiles(vault.getRoot())) { - if (file.extension !== "md") continue; - const mediaInfo = getMediaInfo(file); - if (!mediaInfo) continue; - yield { mediaInfo, file }; - } + const targetNote = notes[0]; + await leaf.openFile(targetNote); + return { + file: targetNote, + editor: (leaf.view as MarkdownView).editor, + }; } + const filename = `Media Note - ${newNoteTitle}.md`; + const view = await createNewNoteAndOpen(filename, toNewNoteFm, sourcePath); + return { file: view.file!, editor: view.editor }; + function getOpenedView(notes: TFile[]) { const openedViews = workspace .getLeavesOfType("markdown") @@ -183,38 +59,24 @@ export function noteUtils({ } } -function getField( - key: MediaType, - meta: CachedMetadata, -): InternalLinkField | ExternalLinkField | null { - const { frontmatter, frontmatterLinks } = meta; - if (!frontmatter || !(key in frontmatter)) return null; - const internalLink = frontmatterLinks?.find((link) => link.key === key); - if (internalLink) { - return { - type: "internal", - media: key, - source: internalLink.link, - original: internalLink.original, - }; - } - const content = frontmatter[key]; - if (typeof content !== "string") return null; - const url = toURL(content); - if (!url) return null; - return { - type: "external", - media: key, - source: url, - }; -} - -function* iterateFiles(folder: TFolder): IterableIterator { - for (const child of folder.children) { - if (child instanceof TFolder) { - yield* iterateFiles(child); - } else if (child instanceof TFile) { - yield child; +export async function openMedia( + mediaInfo: MediaInfo, + ctx: { workspace: Workspace }, +) { + const leaf = ctx.workspace.getLeaf("split", "vertical"); + if (isFileMediaInfo(mediaInfo)) { + await leaf.openFile(mediaInfo.file, { + eState: { subpath: mediaInfo.hash }, + }); + } else { + const urlInfo = parseUrl(mediaInfo.source.href); + if (!urlInfo) { + new Notice( + `Failed to open media url ${mediaInfo.source.href}, invalid url or not supported`, + ); + return; } + if (openInOpenedPlayer(urlInfo, ctx.workspace)) return; + await openInLeaf(urlInfo, leaf); } } diff --git a/apps/app/src/media-view/base.tsx b/apps/app/src/media-view/base.tsx index 9b7b58d0..6205bf0a 100644 --- a/apps/app/src/media-view/base.tsx +++ b/apps/app/src/media-view/base.tsx @@ -12,6 +12,7 @@ import { import { Player } from "@/components/player"; import { isTimestamp, parseTempFrag } from "@/lib/hash/temporal-frag"; import { handleWindowMigration } from "@/lib/window-migration"; +import { parseUrl } from "@/media-note/manager/url-info"; import { takeTimestampOnUrl } from "@/media-note/timestamp"; import type MediaExtended from "@/mx-main"; @@ -140,7 +141,7 @@ export abstract class MediaRemoteView this.addAction( "star", "Timestamp", - takeTimestampOnUrl(this, (player) => player._source), + takeTimestampOnUrl(this, (player) => parseUrl(player._source)), ); // make sure to unmount the player before the leaf detach it from DOM diff --git a/apps/app/src/media-view/file-view.tsx b/apps/app/src/media-view/file-view.tsx index 03a79892..ce27157b 100644 --- a/apps/app/src/media-view/file-view.tsx +++ b/apps/app/src/media-view/file-view.tsx @@ -5,6 +5,7 @@ import { createMediaViewStore, MediaViewContext } from "@/components/context"; import { Player } from "@/components/player"; import { getTracks } from "@/lib/subtitle"; import { handleWindowMigration } from "@/lib/window-migration"; +import type { FileMediaInfo } from "@/media-note/manager/file-info"; import { takeTimestampOnFile } from "@/media-note/timestamp"; import type MediaExtended from "@/mx-main"; import { MediaFileExtensions } from "@/patch/utils"; @@ -47,7 +48,7 @@ abstract class MediaFileView this.addAction( "star", "Timestamp", - takeTimestampOnFile(this, (player) => player.file), + takeTimestampOnFile(this, () => this.getMediaInfo()), ); this.register( @@ -57,8 +58,9 @@ abstract class MediaFileView ); } - abstract getViewType(): string; + abstract getViewType(): MediaFileViewType; abstract getIcon(): string; + abstract getMediaInfo(): FileMediaInfo | null; async onLoadFile(file: TFile): Promise { const { vault } = this.app; @@ -113,7 +115,16 @@ export class VideoFileView extends MediaFileView { getIcon(): string { return "file-video"; } - getViewType(): string { + getMediaInfo(): FileMediaInfo | null { + if (!this.file) return null; + return { + type: "video", + file: this.file, + hash: this.getEphemeralState().subpath, + viewType: this.getViewType(), + }; + } + getViewType() { return MEDIA_FILE_VIEW_TYPE.video; } canAcceptExtension(extension: string): boolean { @@ -125,9 +136,18 @@ export class AudioFileView extends MediaFileView { getIcon(): string { return "file-audio"; } - getViewType(): string { + getViewType() { return MEDIA_FILE_VIEW_TYPE.audio; } + getMediaInfo(): FileMediaInfo | null { + if (!this.file) return null; + return { + type: "audio", + file: this.file, + hash: this.getEphemeralState().subpath, + viewType: this.getViewType(), + }; + } canAcceptExtension(extension: string): boolean { return MediaFileExtensions.audio.includes(extension); } diff --git a/apps/app/src/mx-main.ts b/apps/app/src/mx-main.ts index 450414db..866556b0 100644 --- a/apps/app/src/mx-main.ts +++ b/apps/app/src/mx-main.ts @@ -6,6 +6,7 @@ import { Plugin } from "obsidian"; import { handleMediaNote } from "./media-note"; import { onExternalLinkClick } from "./media-note/link-click/external"; import { onInternalLinkClick } from "./media-note/link-click/internal"; +import { MediaNoteManager } from "./media-note/manager"; import { MediaFileEmbed } from "./media-view/file-embed"; import { AudioFileView, @@ -40,6 +41,7 @@ export default class MxPlugin extends Plugin { this.handleMediaNote(); } + mediaNote = this.addChild(new MediaNoteManager(this.app)); handleMediaNote = handleMediaNote; injectMediaEmbed = injectMediaEmbed; injectMediaView = injectMediaView; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 536ddc41..779c756e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: ahooks: specifier: ^3.7.8 version: 3.7.8(react@18.2.0) + assert-never: + specifier: ^1.2.1 + version: 1.2.1 clsx: specifier: ^1.2.1 version: 1.2.1 @@ -2444,6 +2447,10 @@ packages: tslib: 2.6.2 dev: false + /assert-never@1.2.1: + resolution: {integrity: sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==} + dev: false + /ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} dev: false