Skip to content

Commit

Permalink
feat(player): support subtitle from local drive
Browse files Browse the repository at this point in the history
close #101
  • Loading branch information
aidenlx committed Feb 17, 2024
1 parent f4615ee commit a9aa2d8
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 38 deletions.
31 changes: 17 additions & 14 deletions apps/app/src/components/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { TimeoutError } from "@/lib/message";
import noop from "@/lib/no-op";
import { WebiviewMediaProvider } from "@/lib/remote-player/provider";
import type { ScreenshotInfo } from "@/lib/screenshot";
import { getTracks } from "@/lib/subtitle";
import { getTracksInVault } from "@/lib/subtitle";
import { titleFromUrl } from "@/media-view/base";
import type MediaExtended from "@/mx-main";
import type { MxSettings } from "@/settings/def";
Expand All @@ -31,6 +31,7 @@ export interface SourceFacet {
title: string | true;
enableWebview: boolean;
type: string;
textTracks: TextTrackInit[];
}

export interface MediaViewState {
Expand Down Expand Up @@ -89,33 +90,35 @@ export function createMediaViewStore() {
}, timeout);
});
},
setSource(url, { hash, enableWebview, title: givenTitle, type } = {}) {
const title =
givenTitle === true ? titleFromUrl(url.source.href) : givenTitle;
set(({ source, title: ogTitle }) => ({
setSource(
url,
{ hash, enableWebview, title: title, type, textTracks } = {},
) {
set((og) => ({
source: {
...source,
type,
...og.source,
type: type ?? og.source?.type,
url,
enableWebview:
enableWebview !== undefined ? enableWebview : source?.enableWebview,
enableWebview: enableWebview ?? og.source?.enableWebview,
},
hash: parseHashProps(hash || url.hash),
title: title ?? ogTitle,
textTracks: textTracks ?? og.textTracks,
hash: { ...og.hash, ...parseHashProps(hash || url.hash) },
title:
(title === true ? titleFromUrl(url.source.href) : title) ?? og.title,
}));
applyTempFrag(get());
},
setHash(hash) {
set({ hash: parseHashProps(hash) });
set((og) => ({ hash: { ...og.hash, ...parseHashProps(hash) } }));
applyTempFrag(get());
},
async loadFile(file, vault, subpath) {
const textTracks = await getTracks(file, vault);
const textTracks = await getTracksInVault(file, vault);
set(({ source, hash }) => ({
source: { ...source, url: fromFile(file, vault) },
textTracks,
title: file.name,
hash: subpath ? parseHashProps(subpath) : hash,
hash: subpath ? { ...hash, ...parseHashProps(subpath) } : hash,
}));
await applyTempFrag(get());
},
Expand Down
109 changes: 90 additions & 19 deletions apps/app/src/lib/subtitle.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,28 @@
import { readdir, readFile } from "fs/promises";
import { basename, dirname, join } from "path";
import type { TextTrackInit } from "@vidstack/react";
import iso from "iso-639-1";
import type { Vault } from "obsidian";
import { TFile } from "obsidian";
import { Notice, TFile } from "obsidian";
import type { MediaURL } from "@/web/url-match";

const supportedFormat = ["vtt", "ass", "ssa", "srt"] as const;
function isCaptionsFile(ext: string): ext is (typeof supportedFormat)[number] {
return supportedFormat.includes(ext as any);
}

export async function getTracks(
media: TFile,
vault: Vault,
export function getTracks<F extends FileInfo>(
mediaBasename: string,
siblings: F[],
defaultLang?: string,
) {
const { basename: videoName, parent: folder } = media;
if (!folder) return [];

const subtitles = folder.children.flatMap((file) => {
// filter file only (exclude folder)
if (!(file instanceof TFile)) return [];
const track = toTrack(file, videoName);
): LocalTrack<F>[] {
const subtitles = siblings.flatMap((file) => {
const track = toTrack(file, mediaBasename);
if (!track) return [];
return [track];
});

const byLang = groupBy(subtitles, (v) => v.language);
const uniqueTracks: LocalTrack[] = [];
const uniqueTracks: LocalTrack<F>[] = [];
const hasDefaultLang = byLang.has(defaultLang);
byLang.forEach((tracks, lang) => {
for (const fmt of supportedFormat) {
Expand All @@ -45,6 +42,71 @@ export async function getTracks(
if (!hasDefaultLang) {
uniqueTracks[0].default = true;
}
return uniqueTracks;
}

export async function getTracksLocal(media: MediaURL, defaultLang?: string) {
const filePath = media.filePath;
if (!filePath || !media.inferredType) return [];
const mediaName = basename(filePath);
const mediaBaseame = mediaName.split(".").slice(0, -1).join(".");
const parentDir = dirname(filePath);

const siblings = (
await readdir(parentDir, {
encoding: "utf-8",
withFileTypes: true,
}).catch((e) => {
const err = e as NodeJS.ErrnoException;
if (err.code !== "ENOENT")
new Notice(`Failed to read directory ${parentDir}: ${err.message}`);
return [];
})
)
.filter((f) => f.name !== mediaName && (f.isFile() || f.isSymbolicLink()))
.map(
(f): FileInfo => ({
extension: f.name.split(".").pop()!,
basename: f.name.split(".").slice(0, -1).join("."),
path: join(parentDir, f.name),
}),
);

const uniqueTracks = getTracks(mediaBaseame, siblings, defaultLang);

return (
await Promise.all(
uniqueTracks.map(async ({ src, ...t }): Promise<TextTrackInit | null> => {
const content = await readFile(src.path, "utf-8").catch((e) => {
const err = e as NodeJS.ErrnoException;
if (err.code !== "ENOENT") {
new Notice(
`Failed to read subtitle file ${src.path}: ${err.message}`,
);
}
return "";
});
if (!content) return null;
return { ...t, content };
}),
)
).filter((t): t is TextTrackInit => !!t);
}

export async function getTracksInVault(
media: TFile,
vault: Vault,
defaultLang?: string,
) {
const { basename: videoName, parent: folder } = media;
if (!folder) return [];

const uniqueTracks = getTracks(
videoName,
folder.children.filter((f): f is TFile => f instanceof TFile),
defaultLang,
);

return await Promise.all(
uniqueTracks.map(
async ({ src, ...t }) =>
Expand All @@ -70,17 +132,26 @@ function groupBy<T, K>(array: T[], getKey: (item: T) => K): Map<K, T[]> {
return map;
}

interface LocalTrack {
interface LocalTrack<F extends FileInfo> {
id: string;
kind: "subtitles";
language?: string;
src: TFile;
src: F;
type: "vtt" | "ass" | "ssa" | "srt";
label: string;
default: boolean;
}

function toTrack(file: TFile, basename: string): LocalTrack | null {
interface FileInfo {
extension: string;
basename: string;
path: string;
}

function toTrack<F extends FileInfo>(
file: F,
basename: string,
): LocalTrack<F> | null {
if (!isCaptionsFile(file.extension)) return null;

// for video file "hello.mp4"
Expand All @@ -92,7 +163,7 @@ function toTrack(file: TFile, basename: string): LocalTrack | null {
return {
kind: "subtitles",
src: file,
id: `${file.name}.unknown`,
id: `${file.basename}.${file.extension}.unknown`,
type: file.extension,
label: "Unknown",
default: false,
Expand All @@ -104,7 +175,7 @@ function toTrack(file: TFile, basename: string): LocalTrack | null {
return {
kind: "subtitles",
language,
id: `${file.name}.${language}`,
id: `${file.basename}.${file.extension}.${language}`,
src: file,
type: file.extension,
label: iso.getNativeName(language) || language,
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/media-view/url-embed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class MediaRenderChild
return this.store.getState().source?.url ?? null;
}

get update() {
get setSource() {
return this.store.getState().setSource;
}

Expand Down
6 changes: 6 additions & 0 deletions apps/app/src/media-view/url-view.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ViewStateResult } from "obsidian";
import { getTracksLocal } from "@/lib/subtitle";
import { MediaRemoteView } from "./remote-view";
import type { MediaRemoteViewState } from "./remote-view";
import type { MediaUrlViewType } from "./view-type";
Expand Down Expand Up @@ -29,8 +30,13 @@ export class VideoUrlView extends MediaUrlView {
if (!url) {
console.warn("Invalid URL", state.source);
} else {
const textTracks = await getTracksLocal(url).catch((e) => {
console.error("Failed to get text tracks", e);
return [];
});
this.store.getState().setSource(url, {
title: true,
textTracks,
type: "video/mp4",
});
}
Expand Down
4 changes: 2 additions & 2 deletions apps/app/src/patch/embed-widget/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ abstract class UrlPlayerWidget extends WidgetType {
this.setPos(domToUpdate);
}
} else {
info.child.update(this.media, { title: true });
info.child.setSource(this.media, { title: true });
}
return true;
}
Expand All @@ -130,7 +130,7 @@ abstract class UrlPlayerWidget extends WidgetType {
// (evt) => 0 === evt.button && view.hasFocus && evt.preventDefault(),
// );
const child = new UrlMediaRenderChild(container, this.plugin);
child.update(this.media, { title: true });
child.setSource(this.media, { title: true });
child.load();
this.hookClickHandler(view, container);
this.setInfo(container, child);
Expand Down
12 changes: 10 additions & 2 deletions apps/app/src/patch/embed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { EmbedCreator, Plugin } from "obsidian";
import type { Size } from "@/lib/size-syntax";
import { parseSizeFromLinkTitle } from "@/lib/size-syntax";
import { getTracksLocal } from "@/lib/subtitle";
import { shouldOpenMedia } from "@/media-note/link-click";
import { titleFromUrl } from "@/media-view/base";
import { MediaRenderChild } from "@/media-view/url-embed";
Expand Down Expand Up @@ -68,9 +69,16 @@ class UrlEmbedMarkdownRenderChild extends MediaRenderChild {
) {
super(containerEl, plugin);
containerEl.addClasses(["mx-external-media-embed"]);
this.update(info, {
}
async onload() {
const textTracks = await getTracksLocal(this.info).catch((e) => {
console.error("Failed to get text tracks", e);
return [];
});
this.setSource(this.info, {
textTracks,
title: true,
enableWebview: viewType === MEDIA_WEBPAGE_VIEW_TYPE,
enableWebview: this.viewType === MEDIA_WEBPAGE_VIEW_TYPE,
});
}
}
Expand Down
7 changes: 7 additions & 0 deletions apps/app/src/web/url-match/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fileURLToPath } from "url";
import type { TFile, Vault } from "obsidian";
import { Platform } from "obsidian";
import { addTempFrag, removeTempFrag } from "@/lib/hash/format";
Expand Down Expand Up @@ -36,6 +37,12 @@ export class MediaURL extends URL implements URLResolveResult {
get isFileUrl(): boolean {
return this.protocol === "file:";
}
get filePath(): string | null {
if (this.isFileUrl) {
return fileURLToPath(this);
}
return null;
}

compare(other: MediaURL | null | undefined): boolean {
return !!other && this.jsonState.source === other.jsonState.source;
Expand Down

0 comments on commit a9aa2d8

Please sign in to comment.