Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions Grayjay.Desktop.Web/src/backend/WindowBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,65 @@ export interface IOrderedPlatformVideo extends IPlatformVideo {
index: number;
}

export interface NavIntent {
url?: string;
route?: string;
timestamp: number;
}

export abstract class WindowBackend {
static readonly NAV_INTENT_KEY = 'grayjay_new_window_nav_queue';
static readonly NAV_INTENT_VALIDITY_MS = 5000;

private static _cmdClickPending = false;

static markCmdClick(active: boolean): void {
WindowBackend._cmdClickPending = active;
}

static consumeCmdClick(): boolean {
if (WindowBackend._cmdClickPending) {
WindowBackend._cmdClickPending = false;
return true;
}
return false;
}

static async startWindow(): Promise<Boolean> {
return await Backend.GET("/window/startWindow")
}

static async openInNewWindow(payload: { url?: string; route?: string }): Promise<Boolean> {
const queue = WindowBackend.readIntentQueue();
queue.push({ ...payload, timestamp: Date.now() });
localStorage.setItem(WindowBackend.NAV_INTENT_KEY, JSON.stringify(queue));
return await WindowBackend.startWindow();
}

static consumeNavIntent(): NavIntent | undefined {
const now = Date.now();
const valid = WindowBackend.readIntentQueue().filter(i => now - i.timestamp < WindowBackend.NAV_INTENT_VALIDITY_MS);
const next = valid.shift();
if (valid.length === 0) {
localStorage.removeItem(WindowBackend.NAV_INTENT_KEY);
} else {
localStorage.setItem(WindowBackend.NAV_INTENT_KEY, JSON.stringify(valid));
}
return next;
}

private static readIntentQueue(): NavIntent[] {
try {
const raw = localStorage.getItem(WindowBackend.NAV_INTENT_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.warn("Corrupt new-window nav intent queue, resetting", e);
localStorage.removeItem(WindowBackend.NAV_INTENT_KEY);
return [];
}
}

static async ready(): Promise<boolean> {
return await Backend.GET("/window/Ready");
}
Expand Down
13 changes: 11 additions & 2 deletions Grayjay.Desktop.Web/src/contexts/VideoProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Duration } from "luxon";
import { SettingsBackend } from "../backend/SettingsBackend";
import StateWebsocket from "../state/StateWebsocket";
import { DetailsBackend } from "../backend/DetailsBackend";
import { WindowBackend } from "../backend/WindowBackend";

export enum VideoState {
Closed = 0,
Expand Down Expand Up @@ -81,7 +82,11 @@ export const VideoProvider: ParentComponent<VideoContextProps> = (props) => {
return q[i];
})

const openVideo = (v: IPlatformVideo, time?: Duration, videoState?: VideoState) => {
const openVideo = (v: IPlatformVideo, time?: Duration, videoState?: VideoState) => {
if (WindowBackend.consumeCmdClick() && v.url) {
WindowBackend.openInNewWindow({ url: v.url });
return;
}
const desiredVideoState = videoState ?? VideoState.Maximized;
batch(() => {
setIndex(0);
Expand All @@ -91,7 +96,11 @@ export const VideoProvider: ParentComponent<VideoContextProps> = (props) => {
setState(desiredVideoState);
});
};
const openVideoByUrl = async (url: string, time?: Duration, videoState?: VideoState) => {
const openVideoByUrl = async (url: string, time?: Duration, videoState?: VideoState) => {
if (WindowBackend.consumeCmdClick() && url) {
WindowBackend.openInNewWindow({ url });
return;
}
const desiredVideoState = videoState ?? VideoState.Maximized;
const videoLoadResult = await DetailsBackend.videoLoad(url);
batch(() => {
Expand Down
45 changes: 45 additions & 0 deletions Grayjay.Desktop.Web/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import WatchLaterPage from './pages/WatchLater';
import RemotePlaylistPage from './pages/RemotePlaylist';
import SyncPage from './pages/Sync';
import Globals from './globals';
import { WindowBackend } from './backend/WindowBackend';
import PostDetailView from './components/contentDetails/PostDetailsView';
import StateWebsocket from './state/StateWebsocket';
import GlobalContextMenu from './components/GlobalContextMenu';
Expand Down Expand Up @@ -50,6 +51,31 @@ root?.addEventListener("click", function(event) {
StateGlobal.onGlobalClick?.invoke(event);
});

// Cmd/Ctrl+click flag lives briefly so the next navigation can consume it.
const CMD_CLICK_FLAG_RESET_MS = 50;
// Small delay so the new window finishes mounting before we dispatch the nav.
const NEW_WINDOW_NAV_DELAY_MS = 200;

let cmdClickResetTimeout: ReturnType<typeof setTimeout> | undefined;
document.addEventListener('click', (e) => {
const pressed = e.metaKey || e.ctrlKey;
WindowBackend.markCmdClick(pressed);
if (cmdClickResetTimeout !== undefined) clearTimeout(cmdClickResetTimeout);
if (pressed)
cmdClickResetTimeout = setTimeout(() => WindowBackend.markCmdClick(false), CMD_CLICK_FLAG_RESET_MS);
}, true);

// Router navigations go through history.pushState; we intercept it so a
// Cmd/Ctrl+click followed by navigate() opens in a new window instead.
const _origPushState = history.pushState.bind(history);
history.pushState = function(state: any, title: string, url?: string | URL | null) {
if (WindowBackend.consumeCmdClick() && url) {
WindowBackend.openInNewWindow({ route: url.toString() });
return;
}
return _origPushState(state, title, url);
};

var navigate: Navigator | undefined = undefined;
var video: VideoContextValue | undefined = undefined;

Expand Down Expand Up @@ -97,6 +123,25 @@ const App: Component<RouteSectionProps> = (props) => {
navigate = useNavigate();
video = useVideo();

// New window started by Cmd/Ctrl+click reads the intent left by the parent.
try {
const intent = WindowBackend.consumeNavIntent();
const navigateNow = navigate;
const videoNow = video;
if (intent && navigateNow) {
if (intent.route) {
const route = intent.route;
setTimeout(() => navigateNow(route), NEW_WINDOW_NAV_DELAY_MS);
} else if (intent.url && videoNow) {
const url = intent.url;
setTimeout(() => Globals.handleUrl(url, videoNow, navigateNow), NEW_WINDOW_NAV_DELAY_MS);
}
}
} catch (e) {
console.warn("Failed to restore new-window navigation intent", e);
}


function dragDrop(ev: any){
ev.stopPropagation();
ev.preventDefault();
Expand Down