Skip to content

Commit

Permalink
feat(app): initial support for youtube and vimeo embed
Browse files Browse the repository at this point in the history
  • Loading branch information
aidenlx committed Jan 10, 2024
1 parent a33631b commit 0932b6b
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 135 deletions.
5 changes: 1 addition & 4 deletions apps/app/src/components/player/layouts/audio-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Tooltip from "@radix-ui/react-tooltip";
import { Controls, Gesture, useMediaState } from "@vidstack/react";
import { Controls, Gesture } from "@vidstack/react";

import * as Buttons from "../buttons";
import * as Sliders from "../sliders";
Expand All @@ -13,9 +13,6 @@ export interface VideoLayoutProps {
}

export function AudioLayout({ thumbnails }: VideoLayoutProps) {
const viewType = useMediaState("viewType");
if (viewType !== "audio") return null;

return (
<>
<Gestures />
Expand Down
4 changes: 1 addition & 3 deletions apps/app/src/components/player/layouts/video-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Tooltip from "@radix-ui/react-tooltip";
import { Captions, Controls, Gesture, useMediaState } from "@vidstack/react";
import { Captions, Controls, Gesture } from "@vidstack/react";

import * as Buttons from "../buttons";
import * as Menus from "../menus";
Expand All @@ -15,8 +15,6 @@ export interface VideoLayoutProps {
}

export function VideoLayout({ thumbnails }: VideoLayoutProps) {
const viewType = useMediaState("viewType");
if (viewType !== "video") return null;
return (
<>
<Gestures />
Expand Down
8 changes: 7 additions & 1 deletion apps/app/src/lib/hash/hash-prop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ type PlayerProperties = "loop" | "muted" | "autoplay" | "controls";
export const convertHashToProps = (
hash: string | undefined,
): Partial<Record<PlayerProperties, boolean>> => {
if (!hash) return {};
if (!hash)
return {
loop: false,
muted: false,
autoplay: false,
controls: true,
};
const query = new URLSearchParams(hash.replace(/^#+/, ""));
return {
loop: query.has("loop"),
Expand Down
135 changes: 133 additions & 2 deletions apps/app/src/media-view/base.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import type { Component } from "obsidian";
import type ReactDOM from "react-dom/client";
import { around } from "monkey-around";
import type { Component, WorkspaceLeaf } from "obsidian";
import { ItemView, Scope } from "obsidian";
import ReactDOM from "react-dom/client";
import { type MediaViewStoreApi } from "@/components/context";
import {
createMediaViewStore,
MediaViewContext,
onPlayerMounted,
} from "@/components/context";
import { Player } from "@/components/player";
import { isTimestamp, parseTempFrag } from "@/lib/hash/temporal-frag";
import { handleWindowMigration } from "@/lib/window-migration";
import type MediaExtended from "@/mx-main";

export interface PlayerComponent extends Component {
Expand All @@ -22,3 +31,125 @@ export function setTempFrag(hash: string, store: MediaViewStoreApi) {
}
}
}

declare module "obsidian" {
interface View {
titleEl: HTMLElement;
}
interface WorkspaceLeaf {
updateHeader(): void;
}
interface Workspace {
requestActiveLeafEvents(): boolean;
}
}

export interface MediaRemoteViewState {
source?: string;
}

export abstract class MediaRemoteView
extends ItemView
implements PlayerComponent
{
// no need to manage scope manually,
// as it's implicitly called and handled by the WorkspaceLeaf
store;
scope: Scope;
root: ReactDOM.Root | null = null;
protected _title = "";
protected _sourceType = "";

constructor(leaf: WorkspaceLeaf, public plugin: MediaExtended) {
super(leaf);
this.store = createMediaViewStore();
this.scope = new Scope(this.app.scope);
this.contentEl.addClasses(["mx", "custom"]);
// this.register(
// this.containerEl.onWindowMigrated(() => {
// this.render();
// }),
// );
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;

// make sure to unmount the player before the leaf detach it from DOM
this.register(
around(this.leaf, {
detach: (next) =>
function (this: WorkspaceLeaf, ...args) {
self.root?.unmount();
self.root = null;
return next.call(this, ...args);
},
}),
);

handleWindowMigration.call(this, () => this.render());
this.register(
onPlayerMounted(this.store, (player) =>
player.subscribe(({ title, source }) => {
this._title = title;
this._sourceType = source.type;
this.updateTitle();
}),
),
);
}

abstract getViewType(): string;
abstract getIcon(): string;
abstract getDisplayText(): string;

setEphemeralState(state: any): void {
const { subpath = "" } = state;
setTempFrag(subpath, this.store);
super.setEphemeralState(state);
}

protected async onOpen(): Promise<void> {
await super.onOpen();
this.render();
}

updateTitle() {
const newTitle = this.getDisplayText();
this.titleEl.setText(newTitle);

if (
// eslint-disable-next-line deprecation/deprecation
this.app.workspace.activeLeaf === this.leaf &&
this.app.workspace.requestActiveLeafEvents()
) {
this.leaf.updateHeader();
}
}

render() {
this.root?.unmount();
this.root = ReactDOM.createRoot(this.contentEl);
this.root.render(
<MediaViewContext.Provider
value={{
plugin: this.plugin,
store: this.store,
embed: false,
}}
>
<Player />
</MediaViewContext.Provider>,
);
}

close() {
this.root?.unmount();
this.root = null;
// @ts-expect-error -- this would call leaf.detach()
return super.close();
}
async onClose() {
this.root?.unmount();
this.root = null;
return super.onClose();
}
}
48 changes: 48 additions & 0 deletions apps/app/src/media-view/iframe-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { ViewStateResult } from "obsidian";
import type { MediaRemoteViewState } from "./base";
import { MediaRemoteView } from "./base";

// eslint-disable-next-line @typescript-eslint/naming-convention
export const MEDIA_EMBED_VIEW_TYPE = "mx-embed";

export type MediaEmbedViewState = MediaRemoteViewState;

export const hostTitleMap: Record<string, string> = {
"video/vimeo": "Vimeo",
"video/youtube": "YouTube",
};

export function isEmbedSrc(src: string) {
return src.includes("youtube") || src.includes("vimeo");
}

export class MediaEmbedView extends MediaRemoteView {
async setState(
state: MediaRemoteViewState,
result: ViewStateResult,
): Promise<void> {
if (typeof state.source === "string") {
this.store.setState({ source: { src: state.source } });
}
return super.setState(state, result);
}
getState(): MediaRemoteViewState {
const fromStore = this.store.getState();
const state = super.getState();
return {
...state,
source: fromStore.source?.src,
};
}
getDisplayText(): string {
const source = hostTitleMap[this._sourceType] ?? "Embed";
if (!this._title) return source;
return `${this._title} - ${source}`;
}
getIcon(): string {
return this._sourceType === "video/youtube" ? "youtube" : "video";
}
getViewType(): string {
return MEDIA_EMBED_VIEW_TYPE;
}
}
128 changes: 5 additions & 123 deletions apps/app/src/media-view/webpage-view.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,14 @@
import { around } from "monkey-around";
import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
import { ItemView, Scope } from "obsidian";
import ReactDOM from "react-dom/client";
import {
createMediaViewStore,
MediaViewContext,
onPlayerMounted,
} from "@/components/context";
import { Player } from "@/components/player";
import { handleWindowMigration } from "@/lib/window-migration";
import type MediaExtended from "@/mx-main";
import type { ViewStateResult } from "obsidian";
import { SupportedWebHost, matchHost, webHostDisplayName } from "@/web/match";
import { setTempFrag, type PlayerComponent } from "./base";

declare module "obsidian" {
interface View {
titleEl: HTMLElement;
}
interface WorkspaceLeaf {
updateHeader(): void;
}
interface Workspace {
requestActiveLeafEvents(): boolean;
}
}
import type { MediaRemoteViewState } from "./base";
import { MediaRemoteView } from "./base";

// eslint-disable-next-line @typescript-eslint/naming-convention
export const MEDIA_WEBPAGE_VIEW_TYPE = "mx-webpage";

export interface MediaWebpageViewState {
source?: string;
}

export class MediaWebpageView extends ItemView implements PlayerComponent {
// no need to manage scope manually,
// as it's implicitly called and handled by the WorkspaceLeaf
store;
scope: Scope;
root: ReactDOM.Root | null = null;
private _title = "";

constructor(leaf: WorkspaceLeaf, public plugin: MediaExtended) {
super(leaf);
this.store = createMediaViewStore();
this.scope = new Scope(this.app.scope);
this.contentEl.addClasses(["mx", "custom"]);
// this.register(
// this.containerEl.onWindowMigrated(() => {
// this.render();
// }),
// );
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;

// make sure to unmount the player before the leaf detach it from DOM
this.register(
around(this.leaf, {
detach: (next) =>
function (this: WorkspaceLeaf, ...args) {
self.root?.unmount();
self.root = null;
return next.call(this, ...args);
},
}),
);

handleWindowMigration.call(this, () => this.render());
this.register(
onPlayerMounted(this.store, (player) =>
player.subscribe(({ title }) => {
this._title = title;
this.updateTitle();
}),
),
);
}
export type MediaWebpageViewState = MediaRemoteViewState;

export class MediaWebpageView extends MediaRemoteView {
getViewType(): string {
return MEDIA_WEBPAGE_VIEW_TYPE;
}
Expand Down Expand Up @@ -115,55 +48,4 @@ export class MediaWebpageView extends ItemView implements PlayerComponent {
source: fromStore.source?.src.replace(/^webview::/, ""),
};
}
setEphemeralState(state: any): void {
const { subpath = "" } = state;
setTempFrag(subpath, this.store);
super.setEphemeralState(state);
}

protected async onOpen(): Promise<void> {
await super.onOpen();
this.render();
}

updateTitle() {
const newTitle = this.getDisplayText();
this.titleEl.setText(newTitle);

if (
// eslint-disable-next-line deprecation/deprecation
this.app.workspace.activeLeaf === this.leaf &&
this.app.workspace.requestActiveLeafEvents()
) {
this.leaf.updateHeader();
}
}

render() {
this.root?.unmount();
this.root = ReactDOM.createRoot(this.contentEl);
this.root.render(
<MediaViewContext.Provider
value={{
plugin: this.plugin,
store: this.store,
embed: false,
}}
>
<Player />
</MediaViewContext.Provider>,
);
}

close() {
this.root?.unmount();
this.root = null;
// @ts-expect-error -- this would call leaf.detach()
return super.close();
}
async onClose() {
this.root?.unmount();
this.root = null;
return super.onClose();
}
}
Loading

0 comments on commit 0932b6b

Please sign in to comment.