diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d16c9fa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Agent Instructions + +Before making any architecture-level change, read +`docs/ARCHITECTURE.md` completely. + +Architecture-level changes include plugin lifecycle, global state ownership, +BrowserView creation/destruction, bounds calculation, Decky/Steam integration +points, persistence format, build/deploy metadata, or cross-module +responsibilities. + +If an architecture-level change updates responsibilities, data flow, +persistence behavior, or integration assumptions, update +`docs/ARCHITECTURE.md` in the same change. + +After every feature change, build a new debug zip file for testing. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d56618d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,291 @@ +# Project Architecture + +This document is required reading before any architecture-level change in this +repository. Architecture-level changes include changes to plugin lifecycle, +global state ownership, BrowserView creation/destruction, bounds calculation, +Decky/Steam integration points, persistence format, build/deploy metadata, or +cross-module responsibilities. + +## Purpose + +`decky-pip` is a Decky Loader plugin that opens a Steam/Deck browser view as a +picture-in-picture overlay while the user is in game mode. The plugin exposes a +Quick Access Menu settings panel for changing the URL, view mode, picture +size, BrowserView visibility, drag bar visibility, and saved URL list. + +The project is intentionally small. Most behavior is client-side TypeScript and +React running inside the Decky frontend environment. + +## Runtime Model + +At runtime, the plugin has two UI surfaces: + +1. The Decky Quick Access Menu content rendered by `Settings`. +2. A global Decky component named `PictureInPicture` rendered by `PipOuter`. + +The global component owns the actual browser overlay. The settings panel only +mutates shared state. + +The high-level flow is: + +```text +Decky loads plugin + -> src/index.tsx creates global StateManager + -> index registers PictureInPicture global component + -> index renders Settings in the QAM + -> Settings updates global state + -> PipOuter observes global state + -> Pip creates/updates/destroys the BrowserView and drag bar +``` + +## Module Map + +### `src/index.tsx` + +Plugin entrypoint. Responsibilities: + +- Calls `definePlugin`. +- Creates the shared `StateManager`. +- Merges default state with persisted `localStorage["pip"]` data. +- Migrates older persisted single-URL state into the current saved URL list. +- Persists selected settings back into `localStorage`. +- Registers the global component through `routerHook.addGlobalComponent`. +- Provides `Settings` as the Decky plugin panel content. +- Removes the global component on dismount. + +Keep plugin lifecycle, persistence bootstrap, and Decky registration here. + +### `src/globalState.tsx` + +Shared state contract and React context. Responsibilities: + +- Defines the `State` interface. +- Exposes `GlobalContext`. +- Exposes `useGlobalState`, which returns current state, a setter, and the raw + `StateManager`. + +Use this module for state shape changes. Any new persistent setting should be +added to `State`, initialized in `index.tsx`, and deliberately included or +excluded from the persistence watcher. + +### `src/settings.tsx` + +Quick Access Menu controls. Responsibilities: + +- Opens the PiP view when the settings panel mounts if it was closed. +- Provides URL edit/save, saved URL selector, saved URL add/remove actions, + BrowserView show/hide toggle, expand toggle, drag bar visibility toggle, + continuous size slider, and close button. +- Temporarily hides the BrowserView around some Decky modal/dropdown + interactions so the overlay does not obscure Decky UI. + +Keep Decky panel controls here. Do not create or destroy BrowserViews from this +module. + +### `src/pip.tsx` + +Core PiP runtime. Responsibilities: + +- Creates the Steam/Deck `BrowserView` via + `Router.WindowStore.GamepadUIMainWindowInstance.CreateBrowserView("pip")`. +- Loads the configured URL. +- Applies visibility and bounds to the browser. In picture mode, the + BrowserView is inset below the drag bar so normal browser gestures are not + intercepted outside the bar. +- Releases the BrowserView on React unmount. +- Renders a fixed-position drag bar at the top of the PiP bounds in picture + mode. Touch/pointer drag on the bar updates `customPosition` in shared state. + The left-aligned resize toggle shows/hides three resize handles: right edge + for width, bottom edge for height, and bottom-right corner for freeform + width/height plus overall size. + The right-aligned menu button opens quick actions for showing/hiding the + BrowserView, switching saved URLs, expanding the window, and closing the PiP. + Opening the menu temporarily hides the BrowserView so the native browser + surface does not cover the menu. + When resize handles are visible, the BrowserView is inset from the right and + bottom edges so the native browser surface does not cover the handles. + When the drag bar is hidden, the BrowserView uses the full PiP bounds and no + drag bar controls are rendered. +- Tracks Deck UI surfaces, including main navigation, QAM, and an estimated + virtual keyboard area. +- Intersects available rectangles and computes final overlay bounds for + `ViewMode.Picture` and `ViewMode.Expand`. + +This is the most sensitive module. BrowserView lifecycle, bounds calculation, +Decky private API assumptions, polling cadence, and UI avoidance all live here. + +### `src/geometry.tsx` + +Pure geometry helper. Responsibilities: + +- Computes the intersection of available rectangles. + +Keep this module side-effect free. + +### `src/util.tsx` + +Shared constants and enums. Responsibilities: + +- Screen size constants. +- Default picture aspect/dimensions. +- `ViewMode` and `Position` enums. + +Changing these values can affect persisted enum values and geometry behavior. +Treat enum reordering as a compatibility change. + +### `src/urlModal.tsx` and `src/modal.tsx` + +Decky modal integration. Responsibilities: + +- `urlModal.tsx` renders the URL input modal and updates global state. +- The URL modal edits both URL and note. Saving a URL upserts it into the saved + URL list and makes it current. +- `modal.tsx` wraps modal components with the existing global state context. + +Keep modal-specific context bridging here. + +### `src/useUIComposition.tsx` + +Decky/Steam composition integration. Responsibilities: + +- Finds Decky's private composition hook with `findModuleChild`. +- Requests `UIComposition.Notification` while the BrowserView is active. + +This module depends on private Decky/Steam implementation details. Changes here +need manual testing on the target Decky/Steam environment. + +## State And Persistence + +Current state: + +```ts +interface State { + viewMode: ViewMode; + position: Position; + customPosition: CustomPosition | null; + visible: boolean; + size: number; + widthScale: number; + heightScale: number; + dragBarVisible: boolean; + url: string; + urlEntries: UrlEntry[]; +} + +interface UrlEntry { + id: string; + url: string; + note: string; +} + +interface CustomPosition { + x: number; + y: number; +} +``` + +Default state is created in `src/index.tsx`: + +- `viewMode`: `ViewMode.Closed` +- `visible`: `true` +- `position`: `Position.TopRight` +- `customPosition`: `null` +- `size`: `1` +- `widthScale`: `1` +- `heightScale`: `1` +- `dragBarVisible`: `true` +- `url`: `https://netflix.com` +- `urlEntries`: contains the current/default URL when older persisted data does + not already include a list + +`position`, `customPosition`, `size`, `widthScale`, `heightScale`, +`dragBarVisible`, `url`, and `urlEntries` are persisted to +`localStorage["pip"]`. `viewMode` and `visible` are runtime state and should +remain non-persistent unless the product behavior intentionally changes. +`visible` controls whether the BrowserView is shown without destroying it, so +restoring visibility does not reload the current page. + +`urlEntries` is the saved URL list. The current URL is still stored separately +as `url` for fast lookup and backward compatibility with older stored data. + +## Bounds Calculation + +`Pip` starts with the full 854x534 screen from `util.tsx`. Expand mode then +narrows the available area when Deck UI surfaces are visible: + +- Main navigation visible: remove the nav width from the left side. +- Quick Access Menu visible: remove the QAM width from the right side. +- Virtual keyboard visible: reserve an estimated 240px at the bottom. + +The available rectangles are intersected for expand mode. Picture mode uses the +full screen as its drag and resize area so opening or closing QAM/settings does +not change PiP position or size. In picture mode, the configured `Position` +only determines the initial PiP placement when no custom drag position or +remembered runtime bounds exist. Once the PiP has been rendered, runtime bounds +are reused across Deck UI visibility changes so closing settings or QAM does +not snap the PiP back to the preset position. When `customPosition` is set, it +overrides the preset `Position` and is clamped into the remaining bounds. +Dragging the PiP in picture mode updates `customPosition`. + +In expand mode, the overlay uses the available area after a fixed 30px margin. +The drag bar is rendered in picture mode when the BrowserView is visible, and +it can remain rendered while its menu is open. Hiding the BrowserView from the +menu closes the menu and drag controls immediately; the settings panel can +restore a hidden BrowserView. The BrowserView starts below the drag bar in +picture mode, so page gestures outside the bar continue to reach the loaded +site. If `dragBarVisible` is false, the BrowserView uses the full PiP bounds. + +Independent width and height resize handles clamp against the current available +area, not only the general uniform size limit. This lets the bottom height +handle grow until the current Deck UI avoidance bounds are reached. The +bottom-right freeform handle derives target width and height from pointer +movement, then decomposes them into the shared `size` value plus independent +`widthScale` and `heightScale` values. + +## External Integration Points + +The project relies on Decky and Steam frontend APIs that may not be stable: + +- `definePlugin` and `routerHook` from `@decky/api`. +- QAM and modal controls from `@decky/ui`. +- `Router.WindowStore.GamepadUIMainWindowInstance.CreateBrowserView`. +- `getGamepadNavigationTrees`. +- `findModuleChild` detection for UI composition. +- React globals configured by `tsconfig.json` through + `window.SP_REACT.createElement` and `window.SP_REACT.Fragment`. + +Prefer keeping these assumptions isolated in existing integration modules. + +## Build And Packaging + +Build setup: + +- `package.json` defines `pnpm build` as `rollup -c`. +- `rollup.config.js` delegates to `@decky/rollup`. +- `plugin.json` contains Decky plugin metadata. +- `deck.json` contains Deck deployment connection defaults. +- `tsconfig.json` uses strict TypeScript and the Decky React JSX factories. + +There are currently no automated tests in the repository. For risky changes, +run the TypeScript/build pipeline and manually test in a Decky environment. + +## Architecture Change Rules For Agents + +Before making an architecture-level change: + +1. Read this document completely. +2. Inspect the modules named in the relevant sections above. +3. Identify whether the change affects lifecycle, shared state, persistence, + BrowserView ownership, bounds math, Decky private APIs, or build metadata. +4. Keep ownership boundaries intact unless the requested change explicitly + requires moving them. +5. Update this document in the same change if responsibilities, data flow, + persistence behavior, or integration assumptions change. + +Recommended verification: + +- Run `pnpm build` when dependencies are installed. +- For BrowserView, composition, navigation/QAM avoidance, or virtual keyboard + changes, manually verify on the target Steam Deck or Decky environment. +- Confirm persisted settings still load from older `localStorage["pip"]` data + when state or enum values change. diff --git a/src/globalState.tsx b/src/globalState.tsx index 00f57a4..981578d 100644 --- a/src/globalState.tsx +++ b/src/globalState.tsx @@ -4,13 +4,28 @@ import { useContext, createContext } from 'react'; import { Position, ViewMode } from './util'; import { useStateValue } from 'cotton-box-react'; +export interface UrlEntry { + id: string + url: string + note: string +} + +export interface CustomPosition { + x: number + y: number +} + export interface State { viewMode: ViewMode, position: Position + customPosition: CustomPosition | null visible: boolean - margin: number size: number + widthScale: number + heightScale: number + dragBarVisible: boolean url: string + urlEntries: UrlEntry[] } export const GlobalContext = createContext(new StateManager({} as State)); diff --git a/src/index.tsx b/src/index.tsx index 378e444..1e5dc2b 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,3 @@ -import merge from 'lodash/merge' import { FaTv } from "react-icons/fa"; import { StateManager } from "cotton-box"; import { quickAccessMenuClasses } from "@decky/ui"; @@ -6,24 +5,92 @@ import { definePlugin, routerHook, } from "@decky/api"; import { PipOuter } from "./pip"; import { Settings } from "./settings"; -import { Position, ViewMode } from "./util"; -import { State, GlobalContext } from "./globalState"; +import { PICTURE_MAX_HEIGHT_SCALE, PICTURE_MAX_SIZE, PICTURE_MAX_WIDTH_SCALE, PICTURE_MIN_SIZE, Position, ViewMode } from "./util"; +import { CustomPosition, State, GlobalContext, UrlEntry } from "./globalState"; + +const defaultUrl = "https://netflix.com"; + +const loadPersistedState = () => { + try { + return JSON.parse(localStorage.getItem('pip') ?? '{}') as Partial; + } catch { + return {}; + } +}; + +const isCustomPosition = (value: unknown): value is CustomPosition => { + const position = value as CustomPosition; + return typeof position?.x === "number" && typeof position?.y === "number"; +}; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +const normalizeUrlEntries = (entries: unknown, currentUrl: string): UrlEntry[] => { + const normalized = Array.isArray(entries) + ? entries.reduce((result, entry) => { + if (typeof entry?.url !== "string" || entry.url.length === 0) { + return result; + } + + if (result.some(({ url }) => url === entry.url)) { + return result; + } + + result.push({ + id: typeof entry.id === "string" && entry.id.length > 0 + ? entry.id + : `url-${result.length}`, + url: entry.url, + note: typeof entry.note === "string" ? entry.note : "" + }); + + return result; + }, []) + : []; + + if (!normalized.some(({ url }) => url === currentUrl)) { + normalized.unshift({ + id: "current", + url: currentUrl, + note: "" + }); + } + + return normalized; +}; export default definePlugin(() => { - const state = new StateManager(merge, State, Partial>( - {}, - { - viewMode: ViewMode.Closed, - visible: true, - position: Position.TopRight, - margin: 30, - size: 1, - url: "https://netflix.com" - }, - JSON.parse(localStorage.getItem('pip') ?? '{}'))); + const persistedState = loadPersistedState(); + const url = typeof persistedState.url === "string" && persistedState.url.length > 0 + ? persistedState.url + : defaultUrl; + + const state = new StateManager({ + viewMode: ViewMode.Closed, + visible: true, + position: persistedState.position ?? Position.TopRight, + customPosition: isCustomPosition(persistedState.customPosition) + ? persistedState.customPosition + : null, + size: typeof persistedState.size === "number" + ? clamp(persistedState.size, PICTURE_MIN_SIZE, PICTURE_MAX_SIZE) + : 1, + widthScale: typeof persistedState.widthScale === "number" + ? clamp(persistedState.widthScale, PICTURE_MIN_SIZE, PICTURE_MAX_WIDTH_SCALE) + : 1, + heightScale: typeof persistedState.heightScale === "number" + ? clamp(persistedState.heightScale, PICTURE_MIN_SIZE, PICTURE_MAX_HEIGHT_SCALE) + : 1, + dragBarVisible: typeof persistedState.dragBarVisible === "boolean" + ? persistedState.dragBarVisible + : true, + url, + urlEntries: normalizeUrlEntries(persistedState.urlEntries, url), + }); - state.watch(({ position, margin, size, url }) => - localStorage.setItem('pip', JSON.stringify({ position, margin, size, url }))); + state.watch(({ position, customPosition, size, widthScale, heightScale, dragBarVisible, url, urlEntries }) => + localStorage.setItem('pip', JSON.stringify({ position, customPosition, size, widthScale, heightScale, dragBarVisible, url, urlEntries }))); routerHook.addGlobalComponent("PictureInPicture", () => { return diff --git a/src/modal.tsx b/src/modal.tsx index b762386..60d4aa7 100644 --- a/src/modal.tsx +++ b/src/modal.tsx @@ -8,9 +8,9 @@ interface ModalContext extends ModalRootProps { value: StateManager } -export const modalWithState = (Component: React.FC) => { - return ({ value, ...props }: ModalContext) => +export const modalWithState = (Component: React.FC) => { + return ({ value, ...props }: T & ModalContext) => - + ; -} \ No newline at end of file +} diff --git a/src/pip.tsx b/src/pip.tsx index 1209a52..81544fc 100644 --- a/src/pip.tsx +++ b/src/pip.tsx @@ -4,12 +4,12 @@ import { getGamepadNavigationTrees, } from "@decky/ui"; import isEqual from "lodash/isEqual"; -import { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useGlobalState } from "./globalState"; import { intersectRectangles } from "./geometry"; import { UIComposition, useUIComposition } from "./useUIComposition"; -import { PICTURE_HEIGHT, PICTURE_WIDTH, Position, SCREEN_HEIGHT, SCREEN_WIDTH, ViewMode } from "./util"; +import { PICTURE_HEIGHT, PICTURE_MAX_HEIGHT_SCALE, PICTURE_MAX_SIZE, PICTURE_MAX_WIDTH_SCALE, PICTURE_MIN_SIZE, PICTURE_WIDTH, Position, SCREEN_HEIGHT, SCREEN_WIDTH, ViewMode } from "./util"; interface BrowserProps { url: string @@ -98,6 +98,572 @@ const getDeckComponentBounds = () => { } } +interface Bounds { + x: number + y: number + width: number + height: number +} + +interface DragStart { + pointerId: number + pointerX: number + pointerY: number + boundsX: number + boundsY: number +} + +interface ResizeStart { + pointerId: number + pointerX: number + pointerY: number + size: number + widthScale: number + heightScale: number +} + +interface ResizeValues { + size: number + widthScale: number + heightScale: number +} + +type ResizeMode = "width" | "height" | "freeform"; + +const dragBarHeight = 14; +const resizeEdgeHandleSize = 18; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), Math.max(min, max)); + +const insetBounds = (bounds: Bounds, margin: number): Bounds => ({ + x: bounds.x + margin, + y: bounds.y + margin, + width: Math.max(0, bounds.width - margin * 2), + height: Math.max(0, bounds.height - margin * 2), +}); + +const clampToArea = (bounds: Bounds, area: Bounds): Bounds => ({ + ...bounds, + x: clamp(bounds.x, area.x, area.x + area.width - bounds.width), + y: clamp(bounds.y, area.y, area.y + area.height - bounds.height), +}); + +const getMaxWidthScale = (area: Bounds, size: number) => + Math.max(PICTURE_MIN_SIZE, Math.min(PICTURE_MAX_WIDTH_SCALE, area.width / (PICTURE_WIDTH * size))); + +const getMaxHeightScale = (area: Bounds, size: number) => + Math.max(PICTURE_MIN_SIZE, Math.min(PICTURE_MAX_HEIGHT_SCALE, area.height / (PICTURE_HEIGHT * size))); + +const getMaxUniformSize = (area: Bounds, widthScale: number, heightScale: number) => + Math.max(PICTURE_MIN_SIZE, Math.min( + PICTURE_MAX_SIZE, + area.width / (PICTURE_WIDTH * widthScale), + area.height / (PICTURE_HEIGHT * heightScale) + )); + +const getFreeformResizeValues = ( + area: Bounds, + start: ResizeStart, + deltaX: number, + deltaY: number +): ResizeValues => { + const minWidthFactor = PICTURE_MIN_SIZE * PICTURE_MIN_SIZE; + const minHeightFactor = PICTURE_MIN_SIZE * PICTURE_MIN_SIZE; + const maxWidthFactor = area.width / PICTURE_WIDTH; + const maxHeightFactor = area.height / PICTURE_HEIGHT; + const widthFactor = clamp( + start.size * start.widthScale + deltaX / PICTURE_WIDTH, + minWidthFactor, + maxWidthFactor + ); + const heightFactor = clamp( + start.size * start.heightScale + deltaY / PICTURE_HEIGHT, + minHeightFactor, + maxHeightFactor + ); + const maxSize = Math.max(PICTURE_MIN_SIZE, Math.min( + PICTURE_MAX_SIZE, + widthFactor / PICTURE_MIN_SIZE, + heightFactor / PICTURE_MIN_SIZE + )); + const size = Number(clamp( + Math.min(widthFactor, heightFactor), + PICTURE_MIN_SIZE, + maxSize + ).toFixed(2)); + + return { + size, + widthScale: Number(clamp( + widthFactor / size, + PICTURE_MIN_SIZE, + getMaxWidthScale(area, size) + ).toFixed(2)), + heightScale: Number(clamp( + heightFactor / size, + PICTURE_MIN_SIZE, + getMaxHeightScale(area, size) + ).toFixed(2)), + }; +}; + +const getPictureBounds = ( + area: Bounds, + position: Position, + pictureWidth: number, + pictureHeight: number +): Bounds => { + const bounds = { + x: area.x, + y: area.y, + width: pictureWidth, + height: pictureHeight, + }; + + switch (position) { + case Position.Top: { + bounds.x += area.width / 2 - pictureWidth / 2; + } break; + case Position.TopRight: { + bounds.x += area.width - pictureWidth; + } break; + case Position.Right: { + bounds.x += area.width - pictureWidth; + bounds.y += area.height / 2 - pictureHeight / 2; + } break; + case Position.BottomRight: { + bounds.x += area.width - pictureWidth; + bounds.y += area.height - pictureHeight; + } break; + case Position.Bottom: { + bounds.x += area.width / 2 - pictureWidth / 2; + bounds.y += area.height - pictureHeight; + } break; + case Position.BottomLeft: { + bounds.y += area.height - pictureHeight; + } break; + case Position.Left: { + bounds.y += area.height / 2 - pictureHeight / 2; + } break; + case Position.TopLeft: { + // do nothing, screen is calculated initially to top left + } break; + } + + return clampToArea(bounds, area); +}; + +interface PipDragBarProps { + bounds: Bounds + dragArea: Bounds + menuOpen: boolean + setMenuOpen: React.Dispatch> + resizeHandlesVisible: boolean + setResizeHandlesVisible: React.Dispatch> +} + +const PipDragBar = ({ + bounds, + dragArea, + menuOpen, + setMenuOpen, + resizeHandlesVisible, + setResizeHandlesVisible +}: PipDragBarProps) => { + const [{ url, urlEntries, viewMode, visible, size, widthScale, heightScale }, setGlobalState] = useGlobalState(); + const dragStart = useRef(null); + const resizeStart = useRef(null); + + const stopButtonPointer = (event: React.PointerEvent) => { + event.stopPropagation(); + }; + + const stopMenuPointer = (event: React.PointerEvent) => { + event.stopPropagation(); + }; + + const handlePointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + dragStart.current = { + pointerId: event.pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + boundsX: bounds.x, + boundsY: bounds.y, + }; + }; + + const handlePointerMove = (event: React.PointerEvent) => { + if (!dragStart.current || dragStart.current.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const nextBounds = clampToArea({ + ...bounds, + x: dragStart.current.boundsX + event.clientX - dragStart.current.pointerX, + y: dragStart.current.boundsY + event.clientY - dragStart.current.pointerY, + }, dragArea); + + setGlobalState(state => ({ + ...state, + visible: true, + viewMode: ViewMode.Picture, + customPosition: { + x: nextBounds.x, + y: nextBounds.y, + } + })); + }; + + const handlePointerEnd = (event: React.PointerEvent) => { + if (!dragStart.current || dragStart.current.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + dragStart.current = null; + }; + + const handleResizePointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + resizeStart.current = { + pointerId: event.pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + size, + widthScale, + heightScale, + }; + + setGlobalState(state => ({ + ...state, + visible: true, + viewMode: ViewMode.Picture, + customPosition: { + x: bounds.x, + y: bounds.y, + }, + })); + }; + + const handleResizePointerMove = (mode: ResizeMode) => (event: React.PointerEvent) => { + if (!resizeStart.current || resizeStart.current.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const deltaX = event.clientX - resizeStart.current.pointerX; + const deltaY = event.clientY - resizeStart.current.pointerY; + const freeformValues = mode == "freeform" + ? getFreeformResizeValues(dragArea, resizeStart.current, deltaX, deltaY) + : null; + + setGlobalState(state => ({ + ...state, + visible: true, + viewMode: ViewMode.Picture, + customPosition: { + x: bounds.x, + y: bounds.y, + }, + size: freeformValues + ? freeformValues.size + : state.size, + widthScale: mode == "width" + ? Number(clamp( + resizeStart.current!.widthScale + deltaX / (PICTURE_WIDTH * resizeStart.current!.size), + PICTURE_MIN_SIZE, + getMaxWidthScale(dragArea, resizeStart.current!.size) + ).toFixed(2)) + : freeformValues + ? freeformValues.widthScale + : state.widthScale, + heightScale: mode == "height" + ? Number(clamp( + resizeStart.current!.heightScale + deltaY / (PICTURE_HEIGHT * resizeStart.current!.size), + PICTURE_MIN_SIZE, + getMaxHeightScale(dragArea, resizeStart.current!.size) + ).toFixed(2)) + : freeformValues + ? freeformValues.heightScale + : state.heightScale, + })); + }; + + const handleResizePointerEnd = (event: React.PointerEvent) => { + if (!resizeStart.current || resizeStart.current.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + resizeStart.current = null; + }; + + const buttonStyle: React.CSSProperties = { + width: 28, + height: dragBarHeight, + border: 0, + padding: 0, + color: '#fff', + background: 'rgba(255, 255, 255, 0.16)', + fontSize: 10, + fontWeight: 700, + lineHeight: `${dragBarHeight}px`, + cursor: 'pointer', + touchAction: 'none', + }; + const resizeToggleWidth = 28; + + const menuWidth = Math.min(240, Math.max(180, bounds.width)); + const menuButtonStyle: React.CSSProperties = { + ...buttonStyle, + marginLeft: 'auto', + }; + const menuItemStyle: React.CSSProperties = { + display: 'block', + width: '100%', + border: 0, + padding: '8px 10px', + color: '#fff', + background: 'transparent', + textAlign: 'left', + fontSize: 13, + lineHeight: '16px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }; + + return <> +
+ + + {resizeHandlesVisible && <> +
+
+
+ } + {menuOpen &&
+ + + + {urlEntries.length > 0 &&
} + {urlEntries.map(entry => )} +
} + ; +}; + const useDeckComponentBounds = () => { const [state, setState] = useState(getDeckComponentBounds()); @@ -119,10 +685,10 @@ const useDeckComponentBounds = () => { export const Pip = () => { const { nav, qam, virtualKeyboard } = useDeckComponentBounds(); - const [{ viewMode, position, size, url, visible, ...settings }] = useGlobalState(); - - const pictureWidth = PICTURE_WIDTH * size; - const pictureHeight = PICTURE_HEIGHT * size; + const [{ viewMode, position, customPosition, size, widthScale, heightScale, dragBarVisible, url, visible }] = useGlobalState(); + const [menuOpen, setMenuOpen] = useState(false); + const [resizeHandlesVisible, setResizeHandlesVisible] = useState(false); + const lastPictureBounds = useRef(null); const availableBounds = [{ x: 0, @@ -158,7 +724,7 @@ export const Pip = () => { }); } - const bounds = intersectRectangles(availableBounds) ?? { + const available = intersectRectangles(availableBounds) ?? { x: 0, y: 0, width: SCREEN_WIDTH, @@ -167,58 +733,67 @@ export const Pip = () => { const margin = viewMode == ViewMode.Expand ? 30 - : settings.margin; - - bounds.x += margin; - bounds.y += margin; - bounds.width -= margin * 2; - bounds.height -= margin * 2; - - switch (viewMode) { - case ViewMode.Expand: { - // do nothing, screen is calculated initially to fullscreen - } break; - - case ViewMode.Picture: { - switch (position) { - case Position.Top: { - bounds.x += bounds.width / 2 - pictureWidth / 2; - } break; - case Position.TopRight: { - bounds.x += bounds.width - pictureWidth; - } break; - case Position.Right: { - bounds.x += bounds.width - pictureWidth; - bounds.y += bounds.height / 2 - pictureHeight / 2; - } break; - case Position.BottomRight: { - bounds.x += bounds.width - pictureWidth; - bounds.y += bounds.height - pictureHeight; - } break; - case Position.Bottom: { - bounds.x += bounds.width / 2 - pictureWidth / 2; - bounds.y += bounds.height - pictureHeight; - } break; - case Position.BottomLeft: { - bounds.y += bounds.height - pictureHeight; - } break; - case Position.Left: { - bounds.y += bounds.height / 2 - pictureHeight / 2; - } break; - case Position.TopLeft: { - // do nothing, screen is calculated initially to top left - } break; - } + : 0; - bounds.width = pictureWidth; - bounds.height = pictureHeight; - } break; - } + const pictureArea = { + x: 0, + y: 0, + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT + }; + const dragArea = viewMode == ViewMode.Picture + ? pictureArea + : insetBounds(available, margin); + const effectiveSize = clamp(size, PICTURE_MIN_SIZE, getMaxUniformSize(dragArea, widthScale, heightScale)); + const effectiveWidthScale = clamp(widthScale, PICTURE_MIN_SIZE, getMaxWidthScale(dragArea, effectiveSize)); + const effectiveHeightScale = clamp(heightScale, PICTURE_MIN_SIZE, getMaxHeightScale(dragArea, effectiveSize)); + const pictureWidth = PICTURE_WIDTH * effectiveSize * effectiveWidthScale; + const pictureHeight = PICTURE_HEIGHT * effectiveSize * effectiveHeightScale; + const pictureBounds = customPosition + ? clampToArea({ + x: customPosition.x, + y: customPosition.y, + width: pictureWidth, + height: pictureHeight, + }, dragArea) + : lastPictureBounds.current + ? clampToArea({ + ...lastPictureBounds.current, + width: pictureWidth, + height: pictureHeight, + }, dragArea) + : getPictureBounds(dragArea, position, pictureWidth, pictureHeight); + const bounds = viewMode == ViewMode.Picture + ? pictureBounds + : dragArea; - return ; + useEffect(() => { + if (viewMode == ViewMode.Picture) { + lastPictureBounds.current = bounds; + } + }, [viewMode, bounds.x, bounds.y, bounds.width, bounds.height]); + const browserBounds = viewMode == ViewMode.Picture && dragBarVisible + ? { + x: bounds.x, + y: bounds.y + dragBarHeight, + width: Math.max(0, bounds.width - (resizeHandlesVisible ? resizeEdgeHandleSize : 0)), + height: Math.max(0, bounds.height - dragBarHeight - (resizeHandlesVisible ? resizeEdgeHandleSize : 0)) + } + : bounds; + + return <> + + {(visible || menuOpen) && viewMode == ViewMode.Picture && dragBarVisible && } + ; } export const PipOuter = () => { @@ -229,4 +804,4 @@ export const PipOuter = () => { } return ; -} \ No newline at end of file +} diff --git a/src/settings.tsx b/src/settings.tsx index ae53cb7..f960f0b 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -10,32 +10,44 @@ import { import { useEffect } from "react"; import { FaEdit } from "react-icons/fa"; -import { Position, ViewMode } from "./util"; +import { PICTURE_MAX_SIZE, PICTURE_MIN_SIZE, ViewMode } from "./util"; import { useGlobalState } from "./globalState"; import { UrlModalWithState } from "./urlModal"; +const addAddressAction = "__add_address__"; +const removeCurrentAddressAction = "__remove_current_address__"; + export const Settings = () => { - const [{ viewMode, position, margin, url, size }, setGlobalState, stateContext] = useGlobalState(); + const [{ viewMode, dragBarVisible, url, urlEntries, size, visible }, setGlobalState, stateContext] = useGlobalState(); useEffect(() => { - setGlobalState(state => ({ - ...state, - visible: true, - viewMode: state.viewMode == ViewMode.Closed - ? ViewMode.Picture - : state.viewMode - })); + setGlobalState(state => state.viewMode == ViewMode.Closed + ? { + ...state, + visible: true, + viewMode: ViewMode.Picture + } + : state); }, []); - const positionOptions = [ - { label: 'Top Left', data: Position.TopLeft }, - { label: 'Top', data: Position.Top }, - { label: 'Top Right', data: Position.TopRight }, - { label: 'Right', data: Position.Right }, - { label: 'Bottom Right', data: Position.BottomRight }, - { label: 'Bottom', data: Position.Bottom }, - { label: 'Bottom Left', data: Position.BottomLeft }, - { label: 'Left', data: Position.Left }, + const currentUrlEntry = urlEntries.find(entry => entry.url === url); + const urlOptions = [ + { + label: '+ Add Address', + data: addAddressAction + }, + ...urlEntries.map(entry => ({ + label: entry.note.length > 0 + ? `${entry.note}: ${entry.url}` + : entry.url, + data: entry.id + })), + ...(currentUrlEntry && urlEntries.length > 1 + ? [{ + label: 'Remove Current Address', + data: removeCurrentAddressAction + }] + : []) ]; return <> @@ -47,6 +59,7 @@ export const Settings = () => { layout="below" onClick={() => setGlobalState(state => ({ ...state, + visible: true, viewMode: ViewMode.Picture }))}> Open @@ -58,16 +71,65 @@ export const Settings = () => { showModal()}> + onClick={() => showModal()}>
  
- {url} + {currentUrlEntry?.note.length + ? currentUrlEntry.note + : url}
+ + { + if (option.data === addAddressAction) { + showModal(); + return; + } + + if (option.data === removeCurrentAddressAction && currentUrlEntry) { + setGlobalState(state => { + const nextEntries = state.urlEntries.filter(entry => entry.id !== currentUrlEntry.id); + const nextUrl = nextEntries[0]?.url ?? state.url; + + return { + ...state, + url: nextUrl, + urlEntries: nextEntries + }; + }); + return; + } + + const entry = urlEntries.find(({ id }) => id === option.data); + if (!entry) { + return; + } + + setGlobalState(state => ({ + ...state, + url: entry.url + })); + }} /> + + + { + setGlobalState(state => ({ + ...state, + visible + })) + }} /> + { } {viewMode == ViewMode.Picture && <> - - setGlobalState(state => ({ - ...state, - visible: false - }))} - onChange={option => + { setGlobalState(state => ({ ...state, - visible: true, - position: option.data, + dragBarVisible, viewMode: ViewMode.Picture - }))} /> + })) + }} /> { setGlobalState(state => ({ ...state, size, - visible: true, - viewMode: ViewMode.Picture - }))} - min={0.70} - max={1.30} - step={0.15} - notchCount={3} - notchTicksVisible={true} - notchLabels={[ - { label: "S", notchIndex: 0, value: 0.70 }, - { label: "M", notchIndex: 1, value: 1 }, - { label: "L", notchIndex: 2, value: 1.30 } - ]} /> - - - - setGlobalState(state => ({ - ...state, - margin, - visible: true, viewMode: ViewMode.Picture }))} - min={0} - max={60} - step={15} - notchCount={3} - notchTicksVisible={true} - notchLabels={[ - { label: "S", notchIndex: 0, value: 0 }, - { label: "M", notchIndex: 1, value: 30 }, - { label: "L", notchIndex: 2, value: 60 }, - ]} /> + min={PICTURE_MIN_SIZE} + max={PICTURE_MAX_SIZE} + step={0.01} /> } {viewMode != ViewMode.Closed && <> @@ -161,4 +187,4 @@ export const Settings = () => { } ; -}; \ No newline at end of file +}; diff --git a/src/urlModal.tsx b/src/urlModal.tsx index bad65b1..32a14fb 100644 --- a/src/urlModal.tsx +++ b/src/urlModal.tsx @@ -3,14 +3,26 @@ import { ConfirmModal, ModalRootProps } from "@decky/ui"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { modalWithState } from "./modal"; import { useGlobalState } from "./globalState"; -export const UrlModal = (props: ModalRootProps) => { - const [{ url }, setGlobalState] = useGlobalState(); - const [field, setField] = useState(url); +const createUrlEntryId = () => + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +interface UrlModalProps extends ModalRootProps { + mode?: "add" | "edit" +} + +export const UrlModal = ({ mode = "edit", ...props }: UrlModalProps) => { + const [{ url, urlEntries, visible }, setGlobalState] = useGlobalState(); + const previousVisible = useRef(visible); + const currentEntry = mode == "edit" + ? urlEntries.find(entry => entry.url === url) + : null; + const [field, setField] = useState(mode == "edit" ? url : ""); + const [note, setNote] = useState(currentEntry?.note ?? ""); useEffect(() => { setGlobalState(state => ({ @@ -20,30 +32,67 @@ export const UrlModal = (props: ModalRootProps) => { return () => setGlobalState(state => ({ ...state, - visible: true + visible: previousVisible.current })); }, []) return { + const nextUrl = field.trim(); + const nextNote = note.trim(); + + if (nextUrl.length === 0) { + setGlobalState(state => ({ + ...state, + visible: previousVisible.current + })); + return; + } + setGlobalState(state => ({ ...state, - visible: true, - url: field + visible: previousVisible.current, + url: nextUrl, + urlEntries: state.urlEntries.some(entry => entry.url === nextUrl) + ? state.urlEntries.map(entry => entry.url === nextUrl + ? { + ...entry, + note: nextNote + } + : entry) + : [ + ...state.urlEntries, + { + id: createUrlEntryId(), + url: nextUrl, + note: nextNote + } + ] })); }} onCancel={() => { setGlobalState(state => ({ ...state, - visible: true + visible: previousVisible.current })) }}> - setField(e.target.value)} /> +
+
+
URL
+ setField(e.target.value)} /> +
+
+
Note
+ setNote(e.target.value)} /> +
+
; } -export const UrlModalWithState = modalWithState(UrlModal); \ No newline at end of file +export const UrlModalWithState = modalWithState(UrlModal); diff --git a/src/util.tsx b/src/util.tsx index dcc0c02..85287ad 100644 --- a/src/util.tsx +++ b/src/util.tsx @@ -3,6 +3,10 @@ export const SCREEN_HEIGHT = 534; export const MARGIN = 20; export const PICTURE_WIDTH = SCREEN_WIDTH * 0.4; export const PICTURE_HEIGHT = PICTURE_WIDTH * (1.0 / 1.85); +export const PICTURE_MIN_SIZE = 0.50; +export const PICTURE_MAX_SIZE = 1.60; +export const PICTURE_MAX_WIDTH_SCALE = SCREEN_WIDTH / PICTURE_WIDTH; +export const PICTURE_MAX_HEIGHT_SCALE = SCREEN_HEIGHT / PICTURE_HEIGHT; export enum ViewMode { Expand = 1, @@ -19,4 +23,4 @@ export enum Position { BottomLeft, Left, TopLeft -} \ No newline at end of file +}