diff --git a/Grayjay.Desktop.Web/src/backend/WindowBackend.ts b/Grayjay.Desktop.Web/src/backend/WindowBackend.ts index 4924e38b..35abee76 100644 --- a/Grayjay.Desktop.Web/src/backend/WindowBackend.ts +++ b/Grayjay.Desktop.Web/src/backend/WindowBackend.ts @@ -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 { return await Backend.GET("/window/startWindow") } + static async openInNewWindow(payload: { url?: string; route?: string }): Promise { + 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 { return await Backend.GET("/window/Ready"); } diff --git a/Grayjay.Desktop.Web/src/contexts/VideoProvider.tsx b/Grayjay.Desktop.Web/src/contexts/VideoProvider.tsx index 858ac48e..a192d94a 100644 --- a/Grayjay.Desktop.Web/src/contexts/VideoProvider.tsx +++ b/Grayjay.Desktop.Web/src/contexts/VideoProvider.tsx @@ -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, @@ -81,7 +82,11 @@ export const VideoProvider: ParentComponent = (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); @@ -91,7 +96,11 @@ export const VideoProvider: ParentComponent = (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(() => { diff --git a/Grayjay.Desktop.Web/src/index.tsx b/Grayjay.Desktop.Web/src/index.tsx index 7a3ff452..b16812de 100644 --- a/Grayjay.Desktop.Web/src/index.tsx +++ b/Grayjay.Desktop.Web/src/index.tsx @@ -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'; @@ -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 | 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; @@ -97,6 +123,25 @@ const App: Component = (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();