From d6693f131574128f4a5da07f52b1f72113b2c938 Mon Sep 17 00:00:00 2001 From: VIkill33 Date: Tue, 7 Jul 2026 22:19:43 +0800 Subject: [PATCH 1/7] docs: add architecture guide --- AGENTS.md | 14 +++ docs/ARCHITECTURE.md | 227 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/ARCHITECTURE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..864679e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# 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. + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..987560a --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,227 @@ +# 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 +position, picture size, and margin. + +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 +``` + +## Module Map + +### `src/index.tsx` + +Plugin entrypoint. Responsibilities: + +- Calls `definePlugin`. +- Creates the shared `StateManager`. +- Merges default state with persisted `localStorage["pip"]` data. +- 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, expand toggle, position selector, size slider, margin + 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. +- Releases the BrowserView on React unmount. +- 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. +- `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; + visible: boolean; + margin: number; + size: number; + url: string; +} +``` + +Default state is created in `src/index.tsx`: + +- `viewMode`: `ViewMode.Closed` +- `visible`: `true` +- `position`: `Position.TopRight` +- `margin`: `30` +- `size`: `1` +- `url`: `https://netflix.com` + +Only `position`, `margin`, `size`, and `url` are persisted to +`localStorage["pip"]`. `viewMode` and `visible` are runtime state and should +remain non-persistent unless the product behavior intentionally changes. + +## Bounds Calculation + +`Pip` starts with the full 854x534 screen from `util.tsx`, 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. Margins are then applied. In picture +mode, the configured `Position` determines where the PiP rectangle is placed +inside the remaining bounds. In expand mode, the overlay uses the available area +after a fixed 30px margin. + +## 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. + From fdecce8a3c12863255af8e6acbf339fd019049c3 Mon Sep 17 00:00:00 2001 From: VIkill33 Date: Tue, 7 Jul 2026 22:57:24 +0800 Subject: [PATCH 2/7] feat: improve PiP controls --- docs/ARCHITECTURE.md | 56 ++++++++-- src/globalState.tsx | 13 +++ src/index.tsx | 84 ++++++++++++--- src/modal.tsx | 8 +- src/pip.tsx | 246 +++++++++++++++++++++++++++++++++---------- src/settings.tsx | 100 +++++++++++++++--- src/urlModal.tsx | 66 ++++++++++-- 7 files changed, 466 insertions(+), 107 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 987560a..708d084 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,7 +11,7 @@ cross-module responsibilities. `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 -position, picture size, and margin. +position, picture size, margin, and saved URL list. The project is intentionally small. Most behavior is client-side TypeScript and React running inside the Decky frontend environment. @@ -35,7 +35,7 @@ Decky loads plugin -> index renders Settings in the QAM -> Settings updates global state -> PipOuter observes global state - -> Pip creates/updates/destroys the BrowserView + -> Pip creates/updates/destroys the BrowserView and drag bar ``` ## Module Map @@ -47,6 +47,7 @@ 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. @@ -72,8 +73,9 @@ excluded from the persistence watcher. Quick Access Menu controls. Responsibilities: - Opens the PiP view when the settings panel mounts if it was closed. -- Provides URL edit, expand toggle, position selector, size slider, margin - slider, and close button. +- Provides URL edit/save, saved URL selector, saved URL add/remove actions, + expand toggle, position selector, continuous size slider, margin slider, and + close button. - Temporarily hides the BrowserView around some Decky modal/dropdown interactions so the overlay does not obscure Decky UI. @@ -87,8 +89,12 @@ 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. +- 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. - Tracks Deck UI surfaces, including main navigation, QAM, and an estimated virtual keyboard area. - Intersects available rectangles and computes final overlay bounds for @@ -121,6 +127,8 @@ Treat enum reordering as a compatibility change. 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. @@ -143,10 +151,23 @@ Current state: interface State { viewMode: ViewMode; position: Position; + customPosition: CustomPosition | null; visible: boolean; margin: number; size: number; url: string; + urlEntries: UrlEntry[]; +} + +interface UrlEntry { + id: string; + url: string; + note: string; +} + +interface CustomPosition { + x: number; + y: number; } ``` @@ -155,13 +176,20 @@ Default state is created in `src/index.tsx`: - `viewMode`: `ViewMode.Closed` - `visible`: `true` - `position`: `Position.TopRight` +- `customPosition`: `null` - `margin`: `30` - `size`: `1` - `url`: `https://netflix.com` +- `urlEntries`: contains the current/default URL when older persisted data does + not already include a list + +`position`, `customPosition`, `margin`, `size`, `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. -Only `position`, `margin`, `size`, and `url` are persisted to -`localStorage["pip"]`. `viewMode` and `visible` are runtime state and should -remain non-persistent unless the product behavior intentionally changes. +`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 @@ -174,8 +202,15 @@ available area when Deck UI surfaces are visible: The available rectangles are intersected. Margins are then applied. In picture mode, the configured `Position` determines where the PiP rectangle is placed -inside the remaining bounds. In expand mode, the overlay uses the available area -after a fixed 30px margin. +inside the remaining bounds. When `customPosition` is set, it overrides the +preset `Position` and is clamped into the remaining bounds. Dragging the PiP in +picture mode updates `customPosition`. Selecting a preset position clears +`customPosition`. + +In expand mode, the overlay uses the available area after a fixed 30px margin. +The drag bar is only rendered in picture mode while the BrowserView is visible. +The BrowserView starts below the drag bar in picture mode, so page gestures +outside the bar continue to reach the loaded site. ## External Integration Points @@ -224,4 +259,3 @@ Recommended verification: 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..4f1b7c5 100644 --- a/src/globalState.tsx +++ b/src/globalState.tsx @@ -4,13 +4,26 @@ 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 url: string + urlEntries: UrlEntry[] } export const GlobalContext = createContext(new StateManager({} as State)); diff --git a/src/index.tsx b/src/index.tsx index 378e444..0162450 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"; @@ -7,23 +6,78 @@ 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 { 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 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, + margin: persistedState.margin ?? 30, + size: persistedState.size ?? 1, + 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, margin, size, url, urlEntries }) => + localStorage.setItem('pip', JSON.stringify({ position, customPosition, margin, size, 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..2f066f8 100644 --- a/src/pip.tsx +++ b/src/pip.tsx @@ -4,7 +4,7 @@ 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"; @@ -98,6 +98,167 @@ const getDeckComponentBounds = () => { } } +interface Bounds { + x: number + y: number + width: number + height: number +} + +interface DragStart { + pointerId: number + pointerX: number + pointerY: number + boundsX: number + boundsY: number +} + +const dragBarHeight = 28; + +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 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); +}; + +const PipDragBar = ({ bounds, dragArea }: { bounds: Bounds, dragArea: Bounds }) => { + const [, setGlobalState] = useGlobalState(); + const dragStart = useRef(null); + + 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; + }; + + return
; +}; + const useDeckComponentBounds = () => { const [state, setState] = useState(getDeckComponentBounds()); @@ -119,7 +280,7 @@ const useDeckComponentBounds = () => { export const Pip = () => { const { nav, qam, virtualKeyboard } = useDeckComponentBounds(); - const [{ viewMode, position, size, url, visible, ...settings }] = useGlobalState(); + const [{ viewMode, position, customPosition, size, url, visible, ...settings }] = useGlobalState(); const pictureWidth = PICTURE_WIDTH * size; const pictureHeight = PICTURE_HEIGHT * size; @@ -158,7 +319,7 @@ export const Pip = () => { }); } - const bounds = intersectRectangles(availableBounds) ?? { + const available = intersectRectangles(availableBounds) ?? { x: 0, y: 0, width: SCREEN_WIDTH, @@ -169,56 +330,33 @@ export const Pip = () => { ? 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; - } - - bounds.width = pictureWidth; - bounds.height = pictureHeight; - } break; - } - - return ; + const dragArea = insetBounds(available, margin); + const bounds = viewMode == ViewMode.Picture + ? customPosition + ? clampToArea({ + x: customPosition.x, + y: customPosition.y, + width: pictureWidth, + height: pictureHeight, + }, dragArea) + : getPictureBounds(dragArea, position, pictureWidth, pictureHeight) + : dragArea; + const browserBounds = viewMode == ViewMode.Picture + ? { + x: bounds.x, + y: bounds.y + dragBarHeight, + width: bounds.width, + height: Math.max(0, bounds.height - dragBarHeight) + } + : bounds; + + return <> + + {visible && viewMode == ViewMode.Picture && } + ; } export const PipOuter = () => { @@ -229,4 +367,4 @@ export const PipOuter = () => { } return ; -} \ No newline at end of file +} diff --git a/src/settings.tsx b/src/settings.tsx index ae53cb7..c48e434 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -14,8 +14,11 @@ import { Position, 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, position, customPosition, margin, url, urlEntries, size }, setGlobalState, stateContext] = useGlobalState(); useEffect(() => { setGlobalState(state => ({ @@ -38,6 +41,26 @@ export const Settings = () => { { 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 <> {viewMode == ViewMode.Closed && <> @@ -58,16 +81,56 @@ 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, + visible: true, + url: nextUrl, + urlEntries: nextEntries + }; + }); + return; + } + + const entry = urlEntries.find(({ id }) => id === option.data); + if (!entry) { + return; + } + + setGlobalState(state => ({ + ...state, + visible: true, + url: entry.url + })); + }} /> + { ...state, visible: true, position: option.data, + customPosition: null, viewMode: ViewMode.Picture }))} /> + {customPosition && <> + + setGlobalState(state => ({ + ...state, + customPosition: null, + visible: true, + viewMode: ViewMode.Picture + }))}> + Reset Dragged Position + + + } { 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 } - ]} /> + min={0.50} + max={1.60} + step={0.01} /> { }
; -}; \ No newline at end of file +}; diff --git a/src/urlModal.tsx b/src/urlModal.tsx index bad65b1..1f48108 100644 --- a/src/urlModal.tsx +++ b/src/urlModal.tsx @@ -8,9 +8,20 @@ import { useEffect, 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 }, setGlobalState] = useGlobalState(); + 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 => ({ @@ -26,12 +37,38 @@ export const UrlModal = (props: ModalRootProps) => { return { + const nextUrl = field.trim(); + const nextNote = note.trim(); + + if (nextUrl.length === 0) { + setGlobalState(state => ({ + ...state, + visible: true + })); + return; + } + setGlobalState(state => ({ ...state, visible: true, - url: field + 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={() => { @@ -40,10 +77,21 @@ export const UrlModal = (props: ModalRootProps) => { visible: true })) }}> - 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); From 0a4d0707c4fc37000e0fd2aaf8dd987a8648daf3 Mon Sep 17 00:00:00 2001 From: VIkill33 Date: Tue, 7 Jul 2026 23:38:48 +0800 Subject: [PATCH 3/7] feat: add PiP drag bar quick menu --- docs/ARCHITECTURE.md | 36 ++++--- src/globalState.tsx | 2 +- src/index.tsx | 17 ++- src/pip.tsx | 240 ++++++++++++++++++++++++++++++++++++++----- src/settings.tsx | 76 ++------------ src/util.tsx | 4 +- 6 files changed, 262 insertions(+), 113 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 708d084..3a0beef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,7 +11,7 @@ cross-module responsibilities. `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 -position, picture size, margin, and saved URL list. +size, 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. @@ -74,8 +74,8 @@ 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, - expand toggle, position selector, continuous size slider, margin slider, and - close button. + 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. @@ -95,6 +95,12 @@ Core PiP runtime. Responsibilities: - 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 two left-aligned drag bar buttons decrease/increase picture size by 10%. + The right-aligned menu button opens quick actions for 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 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 @@ -153,8 +159,8 @@ interface State { position: Position; customPosition: CustomPosition | null; visible: boolean; - margin: number; size: number; + dragBarVisible: boolean; url: string; urlEntries: UrlEntry[]; } @@ -177,15 +183,15 @@ Default state is created in `src/index.tsx`: - `visible`: `true` - `position`: `Position.TopRight` - `customPosition`: `null` -- `margin`: `30` - `size`: `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`, `margin`, `size`, `url`, and `urlEntries` are -persisted to `localStorage["pip"]`. `viewMode` and `visible` are runtime state -and should remain non-persistent unless the product behavior intentionally +`position`, `customPosition`, `size`, `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. `urlEntries` is the saved URL list. The current URL is still stored separately @@ -200,17 +206,17 @@ available area when Deck UI surfaces are visible: - 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. Margins are then applied. In picture -mode, the configured `Position` determines where the PiP rectangle is placed -inside the remaining bounds. When `customPosition` is set, it overrides the -preset `Position` and is clamped into the remaining bounds. Dragging the PiP in -picture mode updates `customPosition`. Selecting a preset position clears -`customPosition`. +The available rectangles are intersected. In picture mode, the configured +`Position` only determines the initial PiP placement when no custom drag +position exists. 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 only rendered in picture mode while the BrowserView is visible. The BrowserView starts below the drag bar in picture mode, so page gestures -outside the bar continue to reach the loaded site. +outside the bar continue to reach the loaded site. If `dragBarVisible` is +false, the BrowserView uses the full PiP bounds. ## External Integration Points diff --git a/src/globalState.tsx b/src/globalState.tsx index 4f1b7c5..28651c8 100644 --- a/src/globalState.tsx +++ b/src/globalState.tsx @@ -20,8 +20,8 @@ export interface State { position: Position customPosition: CustomPosition | null visible: boolean - margin: number size: number + dragBarVisible: boolean url: string urlEntries: UrlEntry[] } diff --git a/src/index.tsx b/src/index.tsx index 0162450..90e56a8 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,7 +5,7 @@ import { definePlugin, routerHook, } from "@decky/api"; import { PipOuter } from "./pip"; import { Settings } from "./settings"; -import { Position, ViewMode } from "./util"; +import { PICTURE_MAX_SIZE, PICTURE_MIN_SIZE, Position, ViewMode } from "./util"; import { CustomPosition, State, GlobalContext, UrlEntry } from "./globalState"; const defaultUrl = "https://netflix.com"; @@ -23,6 +23,9 @@ const isCustomPosition = (value: unknown): value is 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) => { @@ -70,14 +73,18 @@ export default definePlugin(() => { customPosition: isCustomPosition(persistedState.customPosition) ? persistedState.customPosition : null, - margin: persistedState.margin ?? 30, - size: persistedState.size ?? 1, + size: typeof persistedState.size === "number" + ? clamp(persistedState.size, PICTURE_MIN_SIZE, PICTURE_MAX_SIZE) + : 1, + dragBarVisible: typeof persistedState.dragBarVisible === "boolean" + ? persistedState.dragBarVisible + : true, url, urlEntries: normalizeUrlEntries(persistedState.urlEntries, url), }); - state.watch(({ position, customPosition, margin, size, url, urlEntries }) => - localStorage.setItem('pip', JSON.stringify({ position, customPosition, margin, size, url, urlEntries }))); + state.watch(({ position, customPosition, size, dragBarVisible, url, urlEntries }) => + localStorage.setItem('pip', JSON.stringify({ position, customPosition, size, dragBarVisible, url, urlEntries }))); routerHook.addGlobalComponent("PictureInPicture", () => { return diff --git a/src/pip.tsx b/src/pip.tsx index 2f066f8..67cf4ef 100644 --- a/src/pip.tsx +++ b/src/pip.tsx @@ -9,7 +9,7 @@ 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_SIZE, PICTURE_MIN_SIZE, PICTURE_WIDTH, Position, SCREEN_HEIGHT, SCREEN_WIDTH, ViewMode } from "./util"; interface BrowserProps { url: string @@ -113,7 +113,7 @@ interface DragStart { boundsY: number } -const dragBarHeight = 28; +const dragBarHeight = 14; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), Math.max(min, max)); @@ -178,8 +178,42 @@ const getPictureBounds = ( }; const PipDragBar = ({ bounds, dragArea }: { bounds: Bounds, dragArea: Bounds }) => { - const [, setGlobalState] = useGlobalState(); + const [{ url, urlEntries, viewMode }, setGlobalState] = useGlobalState(); const dragStart = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + + const resize = (ratio: number) => { + setGlobalState(state => ({ + ...state, + visible: true, + viewMode: ViewMode.Picture, + size: clamp(Number((state.size * ratio).toFixed(2)), PICTURE_MIN_SIZE, PICTURE_MAX_SIZE), + })); + }; + + const stopButtonPointer = (event: React.PointerEvent) => { + event.stopPropagation(); + }; + + const stopMenuPointer = (event: React.PointerEvent) => { + event.stopPropagation(); + }; + + useEffect(() => { + if (!menuOpen) { + return; + } + + setGlobalState(state => ({ + ...state, + visible: false, + })); + + return () => setGlobalState(state => ({ + ...state, + visible: true, + })); + }, [menuOpen]); const handlePointerDown = (event: React.PointerEvent) => { if (event.button !== 0) { @@ -238,25 +272,179 @@ const PipDragBar = ({ bounds, dragArea }: { bounds: Bounds, dragArea: Bounds }) dragStart.current = null; }; - return
; + 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 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 <> +
+ + + + {menuOpen &&
+ + + {urlEntries.length > 0 &&
} + {urlEntries.map(entry => )} +
} + ; }; const useDeckComponentBounds = () => { @@ -280,7 +468,7 @@ const useDeckComponentBounds = () => { export const Pip = () => { const { nav, qam, virtualKeyboard } = useDeckComponentBounds(); - const [{ viewMode, position, customPosition, size, url, visible, ...settings }] = useGlobalState(); + const [{ viewMode, position, customPosition, size, dragBarVisible, url, visible }] = useGlobalState(); const pictureWidth = PICTURE_WIDTH * size; const pictureHeight = PICTURE_HEIGHT * size; @@ -328,7 +516,7 @@ export const Pip = () => { const margin = viewMode == ViewMode.Expand ? 30 - : settings.margin; + : 0; const dragArea = insetBounds(available, margin); const bounds = viewMode == ViewMode.Picture @@ -341,7 +529,7 @@ export const Pip = () => { }, dragArea) : getPictureBounds(dragArea, position, pictureWidth, pictureHeight) : dragArea; - const browserBounds = viewMode == ViewMode.Picture + const browserBounds = viewMode == ViewMode.Picture && dragBarVisible ? { x: bounds.x, y: bounds.y + dragBarHeight, @@ -355,7 +543,7 @@ export const Pip = () => { url={url} visible={visible} {...browserBounds} /> - {visible && viewMode == ViewMode.Picture && } + {visible && viewMode == ViewMode.Picture && dragBarVisible && } ; } diff --git a/src/settings.tsx b/src/settings.tsx index c48e434..f4f0057 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -10,7 +10,7 @@ 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"; @@ -18,7 +18,7 @@ const addAddressAction = "__add_address__"; const removeCurrentAddressAction = "__remove_current_address__"; export const Settings = () => { - const [{ viewMode, position, customPosition, margin, url, urlEntries, size }, setGlobalState, stateContext] = useGlobalState(); + const [{ viewMode, dragBarVisible, url, urlEntries, size }, setGlobalState, stateContext] = useGlobalState(); useEffect(() => { setGlobalState(state => ({ @@ -30,17 +30,6 @@ export const Settings = () => { })); }, []); - 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 = [ { @@ -147,39 +136,18 @@ export const Settings = () => { } {viewMode == ViewMode.Picture && <> - - setGlobalState(state => ({ - ...state, - visible: false - }))} - onChange={option => + { setGlobalState(state => ({ ...state, + dragBarVisible, visible: true, - position: option.data, - customPosition: null, viewMode: ViewMode.Picture - }))} /> + })) + }} /> - {customPosition && <> - - setGlobalState(state => ({ - ...state, - customPosition: null, - visible: true, - viewMode: ViewMode.Picture - }))}> - Reset Dragged Position - - - } { visible: true, viewMode: ViewMode.Picture }))} - min={0.50} - max={1.60} + min={PICTURE_MIN_SIZE} + max={PICTURE_MAX_SIZE} step={0.01} /> - - - 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 }, - ]} /> - } {viewMode != ViewMode.Closed && <> diff --git a/src/util.tsx b/src/util.tsx index dcc0c02..bdb3761 100644 --- a/src/util.tsx +++ b/src/util.tsx @@ -3,6 +3,8 @@ 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 enum ViewMode { Expand = 1, @@ -19,4 +21,4 @@ export enum Position { BottomLeft, Left, TopLeft -} \ No newline at end of file +} From 5c6f0736c6ea63d7cebd0242ab1343f51d50d734 Mon Sep 17 00:00:00 2001 From: VIkill33 Date: Tue, 7 Jul 2026 23:46:44 +0800 Subject: [PATCH 4/7] fix: keep PiP quick menu visible --- src/pip.tsx | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/pip.tsx b/src/pip.tsx index 67cf4ef..a2103fa 100644 --- a/src/pip.tsx +++ b/src/pip.tsx @@ -177,10 +177,16 @@ const getPictureBounds = ( return clampToArea(bounds, area); }; -const PipDragBar = ({ bounds, dragArea }: { bounds: Bounds, dragArea: Bounds }) => { +interface PipDragBarProps { + bounds: Bounds + dragArea: Bounds + menuOpen: boolean + setMenuOpen: React.Dispatch> +} + +const PipDragBar = ({ bounds, dragArea, menuOpen, setMenuOpen }: PipDragBarProps) => { const [{ url, urlEntries, viewMode }, setGlobalState] = useGlobalState(); const dragStart = useRef(null); - const [menuOpen, setMenuOpen] = useState(false); const resize = (ratio: number) => { setGlobalState(state => ({ @@ -199,22 +205,6 @@ const PipDragBar = ({ bounds, dragArea }: { bounds: Bounds, dragArea: Bounds }) event.stopPropagation(); }; - useEffect(() => { - if (!menuOpen) { - return; - } - - setGlobalState(state => ({ - ...state, - visible: false, - })); - - return () => setGlobalState(state => ({ - ...state, - visible: true, - })); - }, [menuOpen]); - const handlePointerDown = (event: React.PointerEvent) => { if (event.button !== 0) { return; @@ -469,6 +459,7 @@ const useDeckComponentBounds = () => { export const Pip = () => { const { nav, qam, virtualKeyboard } = useDeckComponentBounds(); const [{ viewMode, position, customPosition, size, dragBarVisible, url, visible }] = useGlobalState(); + const [menuOpen, setMenuOpen] = useState(false); const pictureWidth = PICTURE_WIDTH * size; const pictureHeight = PICTURE_HEIGHT * size; @@ -541,9 +532,13 @@ export const Pip = () => { return <> - {visible && viewMode == ViewMode.Picture && dragBarVisible && } + {visible && viewMode == ViewMode.Picture && dragBarVisible && } ; } From edd753ebc7437ce8921b9591ad0660ba96b3dd8e Mon Sep 17 00:00:00 2001 From: VIkill33 Date: Wed, 8 Jul 2026 11:14:13 +0800 Subject: [PATCH 5/7] feat: add edge resize handles --- AGENTS.md | 1 + docs/ARCHITECTURE.md | 17 +++- src/globalState.tsx | 2 + src/index.tsx | 10 +- src/pip.tsx | 220 +++++++++++++++++++++++++++++++++++-------- 5 files changed, 206 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 864679e..d16c9fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,3 +12,4 @@ 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 index 3a0beef..7b0090f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,10 +95,13 @@ Core PiP runtime. Responsibilities: - 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 two left-aligned drag bar buttons decrease/increase picture size by 10%. + The left-aligned resize toggle shows/hides three resize handles: right edge + for width, bottom edge for height, and bottom-right corner for uniform size. The right-aligned menu button opens quick actions for 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 @@ -160,6 +163,8 @@ interface State { customPosition: CustomPosition | null; visible: boolean; size: number; + widthScale: number; + heightScale: number; dragBarVisible: boolean; url: string; urlEntries: UrlEntry[]; @@ -184,15 +189,17 @@ Default state is created in `src/index.tsx`: - `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`, `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. +`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. `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. diff --git a/src/globalState.tsx b/src/globalState.tsx index 28651c8..981578d 100644 --- a/src/globalState.tsx +++ b/src/globalState.tsx @@ -21,6 +21,8 @@ export interface State { customPosition: CustomPosition | null visible: boolean size: number + widthScale: number + heightScale: number dragBarVisible: boolean url: string urlEntries: UrlEntry[] diff --git a/src/index.tsx b/src/index.tsx index 90e56a8..9d5c0d0 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -76,6 +76,12 @@ export default definePlugin(() => { 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_SIZE) + : 1, + heightScale: typeof persistedState.heightScale === "number" + ? clamp(persistedState.heightScale, PICTURE_MIN_SIZE, PICTURE_MAX_SIZE) + : 1, dragBarVisible: typeof persistedState.dragBarVisible === "boolean" ? persistedState.dragBarVisible : true, @@ -83,8 +89,8 @@ export default definePlugin(() => { urlEntries: normalizeUrlEntries(persistedState.urlEntries, url), }); - state.watch(({ position, customPosition, size, dragBarVisible, url, urlEntries }) => - localStorage.setItem('pip', JSON.stringify({ position, customPosition, size, dragBarVisible, url, urlEntries }))); + 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/pip.tsx b/src/pip.tsx index a2103fa..c907cbf 100644 --- a/src/pip.tsx +++ b/src/pip.tsx @@ -113,7 +113,19 @@ interface DragStart { boundsY: number } +interface ResizeStart { + pointerId: number + pointerX: number + pointerY: number + size: number + widthScale: number + heightScale: number +} + +type ResizeMode = "width" | "height" | "uniform"; + const dragBarHeight = 14; +const resizeEdgeHandleSize = 18; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), Math.max(min, max)); @@ -182,20 +194,21 @@ interface PipDragBarProps { dragArea: Bounds menuOpen: boolean setMenuOpen: React.Dispatch> + resizeHandlesVisible: boolean + setResizeHandlesVisible: React.Dispatch> } -const PipDragBar = ({ bounds, dragArea, menuOpen, setMenuOpen }: PipDragBarProps) => { - const [{ url, urlEntries, viewMode }, setGlobalState] = useGlobalState(); +const PipDragBar = ({ + bounds, + dragArea, + menuOpen, + setMenuOpen, + resizeHandlesVisible, + setResizeHandlesVisible +}: PipDragBarProps) => { + const [{ url, urlEntries, viewMode, size, widthScale, heightScale }, setGlobalState] = useGlobalState(); const dragStart = useRef(null); - - const resize = (ratio: number) => { - setGlobalState(state => ({ - ...state, - visible: true, - viewMode: ViewMode.Picture, - size: clamp(Number((state.size * ratio).toFixed(2)), PICTURE_MIN_SIZE, PICTURE_MAX_SIZE), - })); - }; + const resizeStart = useRef(null); const stopButtonPointer = (event: React.PointerEvent) => { event.stopPropagation(); @@ -262,6 +275,93 @@ const PipDragBar = ({ bounds, dragArea, menuOpen, setMenuOpen }: PipDragBarProps 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 uniformDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY; + + setGlobalState(state => ({ + ...state, + visible: true, + viewMode: ViewMode.Picture, + customPosition: { + x: bounds.x, + y: bounds.y, + }, + size: mode == "uniform" + ? Number(clamp( + resizeStart.current!.size + uniformDelta / PICTURE_WIDTH, + PICTURE_MIN_SIZE, + PICTURE_MAX_SIZE + ).toFixed(2)) + : state.size, + widthScale: mode == "width" + ? Number(clamp( + resizeStart.current!.widthScale + deltaX / (PICTURE_WIDTH * resizeStart.current!.size), + PICTURE_MIN_SIZE, + PICTURE_MAX_SIZE + ).toFixed(2)) + : state.widthScale, + heightScale: mode == "height" + ? Number(clamp( + resizeStart.current!.heightScale + deltaY / (PICTURE_HEIGHT * resizeStart.current!.size), + PICTURE_MIN_SIZE, + PICTURE_MAX_SIZE + ).toFixed(2)) + : 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, @@ -275,6 +375,7 @@ const PipDragBar = ({ bounds, dragArea, menuOpen, setMenuOpen }: PipDragBarProps cursor: 'pointer', touchAction: 'none', }; + const resizeToggleWidth = 28; const menuWidth = Math.min(240, Math.max(180, bounds.width)); const menuButtonStyle: React.CSSProperties = { @@ -320,38 +421,23 @@ const PipDragBar = ({ bounds, dragArea, menuOpen, setMenuOpen }: PipDragBarProps overflow: 'hidden', }} /> - + {resizeHandlesVisible && <> +
+
+
+ } {menuOpen &&
{ export const Pip = () => { const { nav, qam, virtualKeyboard } = useDeckComponentBounds(); - const [{ viewMode, position, customPosition, size, dragBarVisible, url, visible }] = useGlobalState(); + const [{ viewMode, position, customPosition, size, widthScale, heightScale, dragBarVisible, url, visible }] = useGlobalState(); const [menuOpen, setMenuOpen] = useState(false); + const [resizeHandlesVisible, setResizeHandlesVisible] = useState(false); - const pictureWidth = PICTURE_WIDTH * size; - const pictureHeight = PICTURE_HEIGHT * size; + const pictureWidth = PICTURE_WIDTH * size * widthScale; + const pictureHeight = PICTURE_HEIGHT * size * heightScale; const availableBounds = [{ x: 0, @@ -524,8 +668,8 @@ export const Pip = () => { ? { x: bounds.x, y: bounds.y + dragBarHeight, - width: bounds.width, - height: Math.max(0, bounds.height - dragBarHeight) + width: Math.max(0, bounds.width - (resizeHandlesVisible ? resizeEdgeHandleSize : 0)), + height: Math.max(0, bounds.height - dragBarHeight - (resizeHandlesVisible ? resizeEdgeHandleSize : 0)) } : bounds; @@ -538,7 +682,9 @@ export const Pip = () => { bounds={bounds} dragArea={dragArea} menuOpen={menuOpen} - setMenuOpen={setMenuOpen} />} + setMenuOpen={setMenuOpen} + resizeHandlesVisible={resizeHandlesVisible} + setResizeHandlesVisible={setResizeHandlesVisible} />} ; } From 35048059d17c25ad641dbf3a5d50590a5cb4c091 Mon Sep 17 00:00:00 2001 From: VIkill33 Date: Wed, 8 Jul 2026 11:30:25 +0800 Subject: [PATCH 6/7] feat: add PiP visibility toggle --- docs/ARCHITECTURE.md | 28 ++++++++++++++++++---------- src/index.tsx | 6 +++--- src/pip.tsx | 43 ++++++++++++++++++++++++++++++++++--------- src/settings.tsx | 32 ++++++++++++++++++++------------ src/urlModal.tsx | 13 +++++++------ src/util.tsx | 2 ++ 6 files changed, 84 insertions(+), 40 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7b0090f..2de0681 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,7 +11,7 @@ cross-module responsibilities. `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, drag bar visibility, and saved URL list. +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. @@ -74,8 +74,8 @@ 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, - expand toggle, drag bar visibility toggle, continuous size slider, and close - button. + 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. @@ -97,9 +97,10 @@ Core PiP runtime. Responsibilities: 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 uniform size. - The right-aligned menu button opens quick actions for 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. + 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 @@ -200,6 +201,8 @@ Default state is created in `src/index.tsx`: `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. @@ -220,10 +223,15 @@ position exists. When `customPosition` is set, it overrides the preset picture mode updates `customPosition`. In expand mode, the overlay uses the available area after a fixed 30px margin. -The drag bar is only rendered in picture mode while the BrowserView is visible. -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. +The drag bar is rendered in picture mode when the BrowserView is visible, and +it can remain rendered while its menu is open so the menu 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. ## External Integration Points diff --git a/src/index.tsx b/src/index.tsx index 9d5c0d0..1e5dc2b 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,7 +5,7 @@ import { definePlugin, routerHook, } from "@decky/api"; import { PipOuter } from "./pip"; import { Settings } from "./settings"; -import { PICTURE_MAX_SIZE, PICTURE_MIN_SIZE, Position, ViewMode } from "./util"; +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"; @@ -77,10 +77,10 @@ export default definePlugin(() => { ? clamp(persistedState.size, PICTURE_MIN_SIZE, PICTURE_MAX_SIZE) : 1, widthScale: typeof persistedState.widthScale === "number" - ? clamp(persistedState.widthScale, PICTURE_MIN_SIZE, PICTURE_MAX_SIZE) + ? clamp(persistedState.widthScale, PICTURE_MIN_SIZE, PICTURE_MAX_WIDTH_SCALE) : 1, heightScale: typeof persistedState.heightScale === "number" - ? clamp(persistedState.heightScale, PICTURE_MIN_SIZE, PICTURE_MAX_SIZE) + ? clamp(persistedState.heightScale, PICTURE_MIN_SIZE, PICTURE_MAX_HEIGHT_SCALE) : 1, dragBarVisible: typeof persistedState.dragBarVisible === "boolean" ? persistedState.dragBarVisible diff --git a/src/pip.tsx b/src/pip.tsx index c907cbf..daa8f26 100644 --- a/src/pip.tsx +++ b/src/pip.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useRef, useState } from "react"; import { useGlobalState } from "./globalState"; import { intersectRectangles } from "./geometry"; import { UIComposition, useUIComposition } from "./useUIComposition"; -import { PICTURE_HEIGHT, PICTURE_MAX_SIZE, PICTURE_MIN_SIZE, 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 @@ -143,6 +143,19 @@ const clampToArea = (bounds: Bounds, area: Bounds): Bounds => ({ 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 getPictureBounds = ( area: Bounds, position: Position, @@ -206,7 +219,7 @@ const PipDragBar = ({ resizeHandlesVisible, setResizeHandlesVisible }: PipDragBarProps) => { - const [{ url, urlEntries, viewMode, size, widthScale, heightScale }, setGlobalState] = useGlobalState(); + const [{ url, urlEntries, viewMode, visible, size, widthScale, heightScale }, setGlobalState] = useGlobalState(); const dragStart = useRef(null); const resizeStart = useRef(null); @@ -327,21 +340,21 @@ const PipDragBar = ({ ? Number(clamp( resizeStart.current!.size + uniformDelta / PICTURE_WIDTH, PICTURE_MIN_SIZE, - PICTURE_MAX_SIZE + getMaxUniformSize(dragArea, resizeStart.current!.widthScale, resizeStart.current!.heightScale) ).toFixed(2)) : state.size, widthScale: mode == "width" ? Number(clamp( resizeStart.current!.widthScale + deltaX / (PICTURE_WIDTH * resizeStart.current!.size), PICTURE_MIN_SIZE, - PICTURE_MAX_SIZE + getMaxWidthScale(dragArea, resizeStart.current!.size) ).toFixed(2)) : state.widthScale, heightScale: mode == "height" ? Number(clamp( resizeStart.current!.heightScale + deltaY / (PICTURE_HEIGHT * resizeStart.current!.size), PICTURE_MIN_SIZE, - PICTURE_MAX_SIZE + getMaxHeightScale(dragArea, resizeStart.current!.size) ).toFixed(2)) : state.heightScale, })); @@ -528,6 +541,16 @@ const PipDragBar = ({ boxShadow: '0 8px 18px rgba(0, 0, 0, 0.38)', boxSizing: 'border-box', }}> +