@archastro/astroshot-review 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +19 -0
- package/bin/astroshot-review.mjs +17 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +240 -0
- package/dist/data/friction.d.ts +22 -0
- package/dist/data/friction.js +278 -0
- package/dist/data/hash-cache.d.ts +17 -0
- package/dist/data/hash-cache.js +51 -0
- package/dist/data/index-cache.d.ts +22 -0
- package/dist/data/index-cache.js +64 -0
- package/dist/data/manifest.d.ts +41 -0
- package/dist/data/manifest.js +105 -0
- package/dist/data/model.d.ts +96 -0
- package/dist/data/model.js +1 -0
- package/dist/data/paths.d.ts +27 -0
- package/dist/data/paths.js +98 -0
- package/dist/data/review-store.d.ts +67 -0
- package/dist/data/review-store.js +237 -0
- package/dist/data/scan.d.ts +32 -0
- package/dist/data/scan.js +227 -0
- package/dist/data/store.d.ts +92 -0
- package/dist/data/store.js +408 -0
- package/dist/data/watcher.d.ts +37 -0
- package/dist/data/watcher.js +126 -0
- package/dist/images/halfblocks.d.ts +10 -0
- package/dist/images/halfblocks.js +21 -0
- package/dist/images/png.d.ts +14 -0
- package/dist/images/png.js +45 -0
- package/dist/images/scale.d.ts +31 -0
- package/dist/images/scale.js +102 -0
- package/dist/images/service.d.ts +54 -0
- package/dist/images/service.js +163 -0
- package/dist/images/worker.d.ts +21 -0
- package/dist/images/worker.js +30 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/terminal/graphics-stdout.d.ts +9 -0
- package/dist/terminal/graphics-stdout.js +36 -0
- package/dist/terminal/herdr.d.ts +61 -0
- package/dist/terminal/herdr.js +327 -0
- package/dist/terminal/image-layer.d.ts +122 -0
- package/dist/terminal/image-layer.js +471 -0
- package/dist/terminal/kitty.d.ts +74 -0
- package/dist/terminal/kitty.js +112 -0
- package/dist/terminal/probe.d.ts +49 -0
- package/dist/terminal/probe.js +206 -0
- package/dist/ui/app.d.ts +6 -0
- package/dist/ui/app.js +695 -0
- package/dist/ui/chrome.d.ts +53 -0
- package/dist/ui/chrome.js +69 -0
- package/dist/ui/context.d.ts +16 -0
- package/dist/ui/context.js +8 -0
- package/dist/ui/detail.d.ts +33 -0
- package/dist/ui/detail.js +39 -0
- package/dist/ui/friction.d.ts +42 -0
- package/dist/ui/friction.js +84 -0
- package/dist/ui/help.d.ts +4 -0
- package/dist/ui/help.js +61 -0
- package/dist/ui/hooks.d.ts +9 -0
- package/dist/ui/hooks.js +32 -0
- package/dist/ui/movie-player.d.ts +29 -0
- package/dist/ui/movie-player.js +119 -0
- package/dist/ui/picture.d.ts +19 -0
- package/dist/ui/picture.js +100 -0
- package/dist/ui/selectors.d.ts +26 -0
- package/dist/ui/selectors.js +69 -0
- package/dist/ui/settings.d.ts +5 -0
- package/dist/ui/settings.js +17 -0
- package/dist/ui/stream.d.ts +38 -0
- package/dist/ui/stream.js +118 -0
- package/dist/ui/system.d.ts +4 -0
- package/dist/ui/system.js +34 -0
- package/dist/ui/takeover.d.ts +26 -0
- package/dist/ui/takeover.js +29 -0
- package/dist/ui/text-input.d.ts +8 -0
- package/dist/ui/text-input.js +74 -0
- package/dist/ui/theme.d.ts +21 -0
- package/dist/ui/theme.js +63 -0
- package/dist/video/ffmpeg.d.ts +54 -0
- package/dist/video/ffmpeg.js +206 -0
- package/package.json +71 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Plays a movie into a cell box using ffmpeg frames and the graphics layer.
|
|
4
|
+
* The poster stays underneath until the first frame lands.
|
|
5
|
+
*/
|
|
6
|
+
import { Box, Text } from "ink";
|
|
7
|
+
import { useEffect, useRef, useState } from "react";
|
|
8
|
+
import { FramePlayer, probeVideo } from "../video/ffmpeg.js";
|
|
9
|
+
import { useServices } from "./context.js";
|
|
10
|
+
import { Picture } from "./picture.js";
|
|
11
|
+
import { theme } from "./theme.js";
|
|
12
|
+
const MAX_FRAME_WIDTH = 1024;
|
|
13
|
+
export function MoviePlayer({ videoPath, posterPath, posterVersion = 0, width, height, playback, onPlayback }) {
|
|
14
|
+
const { layer, capabilities, ffmpeg } = useServices();
|
|
15
|
+
const stageRef = useRef(null);
|
|
16
|
+
const handleRef = useRef(null);
|
|
17
|
+
const playerRef = useRef(null);
|
|
18
|
+
const [info, setInfo] = useState(null);
|
|
19
|
+
const [hasFrame, setHasFrame] = useState(false);
|
|
20
|
+
const enabled = capabilities.graphics === "kitty" || capabilities.graphics === "herdr";
|
|
21
|
+
const positionRef = useRef(playback.positionMs);
|
|
22
|
+
positionRef.current = playback.positionMs;
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!enabled)
|
|
25
|
+
return;
|
|
26
|
+
const handle = layer.registerFrames({ z: 1 });
|
|
27
|
+
handle.setNode(stageRef.current);
|
|
28
|
+
handleRef.current = handle;
|
|
29
|
+
layer.scheduleFlush();
|
|
30
|
+
return () => {
|
|
31
|
+
handle.unregister();
|
|
32
|
+
handleRef.current = null;
|
|
33
|
+
};
|
|
34
|
+
}, [enabled, layer]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let cancelled = false;
|
|
37
|
+
setInfo(null);
|
|
38
|
+
void probeVideo(videoPath, ffmpeg).then((result) => {
|
|
39
|
+
if (cancelled)
|
|
40
|
+
return;
|
|
41
|
+
setInfo(result);
|
|
42
|
+
if (result?.durationMs && !playback.durationMs)
|
|
43
|
+
onPlayback({ durationMs: result.durationMs });
|
|
44
|
+
if (!result)
|
|
45
|
+
onPlayback({ error: ffmpeg.ffmpeg ? "Could not read this movie" : "Install ffmpeg to play movies" });
|
|
46
|
+
});
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
|
+
}, [videoPath]);
|
|
52
|
+
// Start/stop/seek the decoder.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
playerRef.current?.stop();
|
|
55
|
+
playerRef.current = null;
|
|
56
|
+
if (!enabled || !info || !playback.playing)
|
|
57
|
+
return;
|
|
58
|
+
if (!ffmpeg.ffmpeg) {
|
|
59
|
+
onPlayback({ playing: false, error: "Install ffmpeg to play movies (brew install ffmpeg)" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const bounds = {
|
|
63
|
+
width: Math.min(MAX_FRAME_WIDTH, width * capabilities.cellWidth),
|
|
64
|
+
height: Math.min(Math.round((MAX_FRAME_WIDTH * 3) / 4), height * capabilities.cellHeight),
|
|
65
|
+
};
|
|
66
|
+
let lastReport = 0;
|
|
67
|
+
const player = new FramePlayer({
|
|
68
|
+
videoPath,
|
|
69
|
+
bounds,
|
|
70
|
+
sourceSize: { width: info.width, height: info.height },
|
|
71
|
+
startMs: positionRef.current,
|
|
72
|
+
ffmpegPath: ffmpeg.ffmpeg,
|
|
73
|
+
onFrame: (frame) => {
|
|
74
|
+
handleRef.current?.pushFrame({ png: frame.png, width: frame.width, height: frame.height });
|
|
75
|
+
if (!hasFrame)
|
|
76
|
+
setHasFrame(true);
|
|
77
|
+
if (frame.tMs - lastReport >= 250) {
|
|
78
|
+
lastReport = frame.tMs;
|
|
79
|
+
onPlayback({ positionMs: frame.tMs });
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
onEnd: () => onPlayback({ playing: false, ended: true, positionMs: playback.durationMs ?? positionRef.current }),
|
|
83
|
+
onError: (error) => onPlayback({ playing: false, error: error.message }),
|
|
84
|
+
});
|
|
85
|
+
playerRef.current = player;
|
|
86
|
+
player.start();
|
|
87
|
+
return () => {
|
|
88
|
+
player.stop();
|
|
89
|
+
};
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
+
}, [enabled, info, playback.playing, playback.seekToken, width, height, videoPath]);
|
|
92
|
+
useEffect(() => () => handleRef.current?.clearFrame(), []);
|
|
93
|
+
return (_jsxs(Box, { ref: stageRef, width: width, height: height, flexShrink: 0, alignItems: "center", justifyContent: "center", overflow: "hidden", children: [!hasFrame ? _jsx(Picture, { src: posterPath, version: posterVersion, width: width, height: height, label: "\u25B6 movie" }) : null, !enabled ? (_jsx(Box, { position: "absolute", children: _jsx(Text, { color: theme.muted, children: "Poster shown \u00B7 press O to open the movie" }) })) : null] }));
|
|
94
|
+
}
|
|
95
|
+
export function formatClock(ms) {
|
|
96
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
97
|
+
const minutes = Math.floor(total / 60);
|
|
98
|
+
const seconds = total % 60;
|
|
99
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
100
|
+
}
|
|
101
|
+
export function ProgressBar({ positionMs, durationMs, chapters, width, playing, }) {
|
|
102
|
+
const clock = `${formatClock(positionMs)} / ${durationMs ? formatClock(durationMs) : "--:--"}`;
|
|
103
|
+
const barWidth = Math.max(4, width - clock.length - 6);
|
|
104
|
+
const ratio = durationMs ? Math.min(1, positionMs / durationMs) : 0;
|
|
105
|
+
const filled = Math.round(ratio * barWidth);
|
|
106
|
+
const markers = new Set(chapters
|
|
107
|
+
.filter((chapter) => chapter.tMs !== undefined && durationMs)
|
|
108
|
+
.map((chapter) => Math.min(barWidth - 1, Math.round((chapter.tMs / durationMs) * barWidth))));
|
|
109
|
+
let bar = "";
|
|
110
|
+
for (let index = 0; index < barWidth; index += 1) {
|
|
111
|
+
if (index === filled)
|
|
112
|
+
bar += "●";
|
|
113
|
+
else if (markers.has(index))
|
|
114
|
+
bar += "┼";
|
|
115
|
+
else
|
|
116
|
+
bar += index < filled ? "━" : "─";
|
|
117
|
+
}
|
|
118
|
+
return (_jsxs(Box, { flexShrink: 0, width: width, height: 1, children: [_jsx(Text, { color: playing ? theme.green : theme.muted, children: playing ? "▶ " : "⏸ " }), _jsx(Text, { color: theme.purple, children: bar }), _jsxs(Text, { color: theme.muted, children: [" ", clock] })] }));
|
|
119
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface PictureProps {
|
|
2
|
+
src: string | null;
|
|
3
|
+
/** Changes when the file's bytes change (mtime), so a re-capture refreshes. */
|
|
4
|
+
version?: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
/** Text shown when the terminal cannot draw pictures. */
|
|
8
|
+
label?: string;
|
|
9
|
+
border?: boolean;
|
|
10
|
+
z?: number;
|
|
11
|
+
/** Allow the image to scale up to this multiple of native to fill its box (default 1 = never upscale). */
|
|
12
|
+
maxUpscale?: number;
|
|
13
|
+
/** Magnification: 1 shows the whole image; >1 crops in. */
|
|
14
|
+
zoom?: number;
|
|
15
|
+
/** Pan center as a fraction of the image, [0,1]. */
|
|
16
|
+
panX?: number;
|
|
17
|
+
panY?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function Picture({ src, version, width, height, label, border, z, maxUpscale, zoom, panX, panY }: PictureProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Shows an image in a reserved cell box. With the Kitty protocol the graphics
|
|
4
|
+
* layer paints real pixels; otherwise (mosh, tmux, herdr, or a plain terminal)
|
|
5
|
+
* it renders the picture as truecolor half-block text, which survives any
|
|
6
|
+
* transport. Falls back to a labeled placeholder when nothing can be drawn.
|
|
7
|
+
*/
|
|
8
|
+
import { Box, Text } from "ink";
|
|
9
|
+
import { useEffect, useRef, useState } from "react";
|
|
10
|
+
import { rgbToHalfBlockLines } from "../images/halfblocks.js";
|
|
11
|
+
import { useServices } from "./context.js";
|
|
12
|
+
import { theme, truncate } from "./theme.js";
|
|
13
|
+
export function Picture({ src, version = 0, width, height, label, border = false, z, maxUpscale = 1, zoom = 1, panX = 0.5, panY = 0.5 }) {
|
|
14
|
+
const { layer, service, capabilities } = useServices();
|
|
15
|
+
const ref = useRef(null);
|
|
16
|
+
const handleRef = useRef(null);
|
|
17
|
+
const [failure, setFailure] = useState(null);
|
|
18
|
+
const mode = capabilities.graphics;
|
|
19
|
+
// Both Kitty and herdr paint through the out-of-band layer.
|
|
20
|
+
const kitty = mode === "kitty" || mode === "herdr";
|
|
21
|
+
const innerWidth = Math.max(1, width - (border ? 2 : 0));
|
|
22
|
+
const innerHeight = Math.max(1, height - (border ? 2 : 0));
|
|
23
|
+
// ---- Kitty: reserve the box and let the out-of-band layer paint it. ----
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!kitty)
|
|
26
|
+
return;
|
|
27
|
+
const handle = layer.register({ src, version, z, maxUpscale, zoom });
|
|
28
|
+
handleRef.current = handle;
|
|
29
|
+
handle.setNode(ref.current);
|
|
30
|
+
// Ink already painted this commit before the effect ran; measure now so
|
|
31
|
+
// the picture is requested without waiting for an unrelated re-render.
|
|
32
|
+
layer.scheduleFlush();
|
|
33
|
+
return () => {
|
|
34
|
+
handle.unregister();
|
|
35
|
+
handleRef.current = null;
|
|
36
|
+
};
|
|
37
|
+
// The layer handle lives for the component; src changes go through setSource.
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, [kitty, layer]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!kitty)
|
|
42
|
+
return;
|
|
43
|
+
handleRef.current?.setSource(src, version);
|
|
44
|
+
setFailure(null);
|
|
45
|
+
}, [kitty, src, version]);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!kitty)
|
|
48
|
+
return;
|
|
49
|
+
handleRef.current?.setView({ zoom, panX, panY, maxUpscale });
|
|
50
|
+
}, [kitty, zoom, panX, panY, maxUpscale]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!kitty || !handleRef.current)
|
|
53
|
+
return;
|
|
54
|
+
const id = handleRef.current.id;
|
|
55
|
+
const timer = setInterval(() => {
|
|
56
|
+
const problem = layer.failure(id);
|
|
57
|
+
if (problem !== failure)
|
|
58
|
+
setFailure(problem);
|
|
59
|
+
}, 400);
|
|
60
|
+
timer.unref();
|
|
61
|
+
return () => clearInterval(timer);
|
|
62
|
+
}, [kitty, layer, failure]);
|
|
63
|
+
// ---- Half-blocks: decode to a tiny RGB buffer and draw two pixels per cell. ----
|
|
64
|
+
const [lines, setLines] = useState(null);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (mode !== "halfblocks")
|
|
67
|
+
return;
|
|
68
|
+
let cancelled = false;
|
|
69
|
+
setFailure(null);
|
|
70
|
+
if (!src) {
|
|
71
|
+
setLines(null);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// One cell is one pixel wide and two pixels tall.
|
|
75
|
+
const target = { width: innerWidth, height: innerHeight * 2 };
|
|
76
|
+
void service.prepare(src, target, "rgb").then((prepared) => {
|
|
77
|
+
if (cancelled)
|
|
78
|
+
return;
|
|
79
|
+
setLines(rgbToHalfBlockLines({ width: prepared.width, height: prepared.height, rgb: prepared.data }));
|
|
80
|
+
}, (error) => {
|
|
81
|
+
if (cancelled)
|
|
82
|
+
return;
|
|
83
|
+
setLines(null);
|
|
84
|
+
setFailure(error instanceof Error ? error.message : String(error));
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
};
|
|
89
|
+
}, [mode, service, src, version, innerWidth, innerHeight]);
|
|
90
|
+
const art = mode === "halfblocks" && src && lines && lines.length > 0 && !failure;
|
|
91
|
+
const placeholder = !art && (!kitty || !src || failure);
|
|
92
|
+
const caption = failure
|
|
93
|
+
? truncate(`Preview unavailable · ${failure}`, innerWidth)
|
|
94
|
+
: !src
|
|
95
|
+
? "No image"
|
|
96
|
+
: mode === "halfblocks"
|
|
97
|
+
? "Rendering…"
|
|
98
|
+
: truncate(label ?? "Preview needs a graphics terminal", innerWidth);
|
|
99
|
+
return (_jsx(Box, { ref: ref, width: width, height: height, flexShrink: 0, borderStyle: border ? "round" : undefined, borderColor: theme.faint, alignItems: "center", justifyContent: "center", overflow: "hidden", children: art ? (_jsx(Box, { flexDirection: "column", flexShrink: 0, alignItems: "center", children: lines.slice(0, innerHeight).map((line, index) => (_jsx(Text, { wrap: "truncate-end", children: line }, index))) })) : placeholder ? (_jsx(Box, { flexShrink: 0, width: innerWidth, height: innerHeight, alignItems: "center", justifyContent: "center", children: _jsx(Text, { color: theme.muted, children: caption }) })) : null }));
|
|
100
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FrictionLog, FrictionRun, ReviewState, Shot } from "../data/model.js";
|
|
2
|
+
export type StreamFilter = "unseen" | "history";
|
|
3
|
+
export declare function reviewStateOf(shot: Shot): ReviewState;
|
|
4
|
+
export declare function filterShots(shots: Shot[], filter: StreamFilter, moviesOnly: boolean): Shot[];
|
|
5
|
+
export interface StreamCounts {
|
|
6
|
+
pending: number;
|
|
7
|
+
seen: number;
|
|
8
|
+
movies: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function streamCounts(shots: Shot[], moviesOnly: boolean): StreamCounts;
|
|
11
|
+
export interface StreamGroup {
|
|
12
|
+
/** Path of the last shot in the group: a stable anchor. */
|
|
13
|
+
id: string;
|
|
14
|
+
worktreePath: string;
|
|
15
|
+
worktree: string;
|
|
16
|
+
worktreeShort: string;
|
|
17
|
+
shots: Shot[];
|
|
18
|
+
}
|
|
19
|
+
/** Contiguous runs of the same worktree, exactly like the app's stream. */
|
|
20
|
+
export declare function contiguousGroups(shots: Shot[]): StreamGroup[];
|
|
21
|
+
/** Same worktree, feature, and run; oldest first by sequence, else capture time. */
|
|
22
|
+
export declare function reviewSiblings(all: Shot[], shot: Shot): Shot[];
|
|
23
|
+
export declare function latestRun(log: FrictionLog): FrictionRun | null;
|
|
24
|
+
export declare function frictionState(log: FrictionLog): ReviewState;
|
|
25
|
+
export declare function filterFrictionLogs(logs: FrictionLog[], filter: StreamFilter): FrictionLog[];
|
|
26
|
+
export declare function frictionSummary(log: FrictionLog): string;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export function reviewStateOf(shot) {
|
|
2
|
+
return shot.review?.state ?? "pending";
|
|
3
|
+
}
|
|
4
|
+
export function filterShots(shots, filter, moviesOnly) {
|
|
5
|
+
const pool = moviesOnly ? shots.filter((shot) => shot.isMovie) : shots;
|
|
6
|
+
return pool.filter((shot) => (filter === "unseen" ? reviewStateOf(shot) !== "seen" : reviewStateOf(shot) === "seen"));
|
|
7
|
+
}
|
|
8
|
+
export function streamCounts(shots, moviesOnly) {
|
|
9
|
+
const pool = moviesOnly ? shots.filter((shot) => shot.isMovie) : shots;
|
|
10
|
+
return {
|
|
11
|
+
pending: pool.filter((shot) => reviewStateOf(shot) !== "seen").length,
|
|
12
|
+
seen: pool.filter((shot) => reviewStateOf(shot) === "seen").length,
|
|
13
|
+
movies: shots.filter((shot) => shot.isMovie).length,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Contiguous runs of the same worktree, exactly like the app's stream. */
|
|
17
|
+
export function contiguousGroups(shots) {
|
|
18
|
+
const groups = [];
|
|
19
|
+
for (const shot of shots) {
|
|
20
|
+
const last = groups.at(-1);
|
|
21
|
+
if (last && last.worktreePath === shot.worktreePath) {
|
|
22
|
+
last.shots.push(shot);
|
|
23
|
+
last.id = shot.path;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
groups.push({
|
|
27
|
+
id: shot.path,
|
|
28
|
+
worktreePath: shot.worktreePath,
|
|
29
|
+
worktree: shot.worktree,
|
|
30
|
+
worktreeShort: shot.worktreeShort,
|
|
31
|
+
shots: [shot],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return groups;
|
|
36
|
+
}
|
|
37
|
+
/** Same worktree, feature, and run; oldest first by sequence, else capture time. */
|
|
38
|
+
export function reviewSiblings(all, shot) {
|
|
39
|
+
return all
|
|
40
|
+
.filter((candidate) => candidate.worktreePath === shot.worktreePath &&
|
|
41
|
+
candidate.feature === shot.feature &&
|
|
42
|
+
candidate.runId === shot.runId)
|
|
43
|
+
.sort((a, b) => {
|
|
44
|
+
if (a.sequence && b.sequence && a.sequence !== b.sequence) {
|
|
45
|
+
return a.sequence < b.sequence ? -1 : 1;
|
|
46
|
+
}
|
|
47
|
+
return a.capturedAt - b.capturedAt;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function latestRun(log) {
|
|
51
|
+
return log.runs[0] ?? null;
|
|
52
|
+
}
|
|
53
|
+
export function frictionState(log) {
|
|
54
|
+
return latestRun(log)?.review?.state ?? "pending";
|
|
55
|
+
}
|
|
56
|
+
export function filterFrictionLogs(logs, filter) {
|
|
57
|
+
return logs.filter((log) => (filter === "unseen" ? frictionState(log) !== "seen" : frictionState(log) === "seen"));
|
|
58
|
+
}
|
|
59
|
+
export function frictionSummary(log) {
|
|
60
|
+
const run = latestRun(log);
|
|
61
|
+
const steps = run?.steps.length ?? 0;
|
|
62
|
+
const improve = run?.steps.reduce((total, step) => total + step.improve.length, 0) ?? 0;
|
|
63
|
+
const parts = [];
|
|
64
|
+
if (steps > 0)
|
|
65
|
+
parts.push(steps === 1 ? "1 step" : `${steps} steps`);
|
|
66
|
+
if (improve > 0)
|
|
67
|
+
parts.push(`${improve} improve`);
|
|
68
|
+
return parts.join(" · ");
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { indexCachePath } from "../data/index-cache.js";
|
|
5
|
+
import { abbreviateHome } from "../data/paths.js";
|
|
6
|
+
import { MetaRow, Rule, SectionLabel } from "./chrome.js";
|
|
7
|
+
import { useServices } from "./context.js";
|
|
8
|
+
import { theme } from "./theme.js";
|
|
9
|
+
export function SettingsPane({ roots, width, height }) {
|
|
10
|
+
const { capabilities, ffmpeg, rootsSource, version } = useServices();
|
|
11
|
+
const inner = width - 2;
|
|
12
|
+
const home = os.homedir();
|
|
13
|
+
const sourceLabel = rootsSource === "app" ? "from the Astroshots app preferences" : rootsSource === "cli" ? "from --root" : "current directory";
|
|
14
|
+
return (_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: width, height: height, paddingX: 1, overflow: "hidden", children: [_jsxs(Box, { flexShrink: 0, height: 1, justifyContent: "space-between", width: inner, children: [_jsx(Text, { color: theme.blue, children: "\u2039 Stream" }), _jsxs(Text, { color: theme.muted, children: ["astroshot review ", version] })] }), _jsx(SectionLabel, { children: "WATCHED FOLDERS" }), _jsx(Text, { color: theme.muted, wrap: "wrap", children: roots.length === 0
|
|
15
|
+
? "Pick the directories that contain your projects. Nothing is watched until you choose at least one."
|
|
16
|
+
: `Every worktree below any of these folders streams into one feed (${sourceLabel}).` }), roots.length === 0 ? (_jsx(Text, { color: theme.faint, children: "No folders yet \u00B7 astroshot review --root ~/projects" })) : (roots.map((root) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.green, children: "\u25CF " }), abbreviateHome(root, home)] }, root)))), _jsx(Rule, { width: inner }), _jsx(SectionLabel, { children: "GRAPHICS" }), _jsx(MetaRow, { label: "Images", value: capabilities.graphics === "kitty" ? "Kitty graphics (pixel-perfect)" : capabilities.graphics === "herdr" ? "herdr pane graphics (pixel-perfect)" : capabilities.graphics === "halfblocks" ? `half-block text · ${capabilities.reason ?? ""}`.trim() : `off · ${capabilities.reason ?? "unsupported"}`, width: inner }), _jsx(MetaRow, { label: "Cell", value: `${capabilities.cellWidth}×${capabilities.cellHeight} px (${capabilities.cellSource})`, width: inner }), _jsx(MetaRow, { label: "Transport", value: capabilities.fileMedium ? "file path (local)" : "inline bytes", width: inner }), _jsx(MetaRow, { label: "Session", value: [capabilities.insideSsh ? "ssh" : "local", capabilities.insideMosh ? "mosh" : null, capabilities.insideHerdr ? "herdr" : null, capabilities.insideTmux ? "tmux" : null].filter(Boolean).join(" · ") || "local", width: inner }), _jsx(Rule, { width: inner }), _jsx(SectionLabel, { children: "MOVIES" }), _jsx(MetaRow, { label: "ffmpeg", value: ffmpeg.ffmpeg ? `${ffmpeg.ffmpeg}${ffmpeg.version ? ` (${ffmpeg.version})` : ""}` : "not found · brew install ffmpeg", width: inner }), _jsx(Text, { color: theme.muted, wrap: "wrap", children: "Movies play in the tray only with Kitty graphics. Otherwise the poster shows and O opens the file in your default player." }), _jsx(Rule, { width: inner }), _jsx(SectionLabel, { children: "HARNESS LAYOUT" }), _jsx(Text, { color: theme.muted, children: "Write frames here so Astroshots can find them:" }), _jsx(Text, { color: theme.muted, children: " .astroshot/<feature>/" }), _jsx(Text, { color: theme.muted, children: " manifest.json" }), _jsx(Text, { color: theme.muted, children: " 0001-slug.png" }), _jsx(Rule, { width: inner }), _jsx(MetaRow, { label: "Index", value: abbreviateHome(indexCachePath(), home), width: inner })] }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Shot } from "../data/model.js";
|
|
2
|
+
import type { StreamCounts, StreamFilter, StreamGroup } from "./selectors.js";
|
|
3
|
+
export type StreamItem = {
|
|
4
|
+
kind: "header";
|
|
5
|
+
group: StreamGroup;
|
|
6
|
+
height: 1;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "shot";
|
|
9
|
+
shot: Shot;
|
|
10
|
+
group: StreamGroup;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
export declare const SHOT_ROW_HEIGHT = 4;
|
|
14
|
+
export declare function flattenStream(groups: StreamGroup[], collapsed: Set<string>): StreamItem[];
|
|
15
|
+
/** Headers of expanded groups are labels, not stops; collapsed ones stay reachable. */
|
|
16
|
+
export declare function isNavigable(item: StreamItem, collapsed: Set<string>): boolean;
|
|
17
|
+
/** Nearest navigable index at or after `from` (direction +1) or before it (−1). */
|
|
18
|
+
export declare function nextNavigable(items: StreamItem[], collapsed: Set<string>, from: number, direction: 1 | -1): number;
|
|
19
|
+
/** First item index so the cursor item is fully visible within `height` lines. */
|
|
20
|
+
export declare function scrollWindow(items: StreamItem[], cursor: number, scrollTop: number, height: number): number;
|
|
21
|
+
export interface StreamProps {
|
|
22
|
+
items: StreamItem[];
|
|
23
|
+
collapsed: Set<string>;
|
|
24
|
+
cursor: number;
|
|
25
|
+
scrollTop: number;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
filter: StreamFilter;
|
|
29
|
+
moviesOnly: boolean;
|
|
30
|
+
counts: StreamCounts;
|
|
31
|
+
focused: boolean;
|
|
32
|
+
scanning: boolean;
|
|
33
|
+
hasRoots: boolean;
|
|
34
|
+
totalShots: number;
|
|
35
|
+
bulkBusy: boolean;
|
|
36
|
+
}
|
|
37
|
+
export declare function FilterBar({ filter, moviesOnly, counts, width, bulkBusy }: Pick<StreamProps, "filter" | "moviesOnly" | "counts" | "width" | "bulkBusy">): import("react").JSX.Element;
|
|
38
|
+
export declare function StreamList(props: StreamProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The Shots stream: filter bar, contiguous worktree groups, and one row per
|
|
4
|
+
* image with a live thumbnail.
|
|
5
|
+
*/
|
|
6
|
+
import { Box, Text } from "ink";
|
|
7
|
+
import { durationLabel } from "../data/manifest.js";
|
|
8
|
+
import { EmptyState, ExecutionPill, MovieBadge, ReviewBadge, WorktreeChip } from "./chrome.js";
|
|
9
|
+
import { useServices } from "./context.js";
|
|
10
|
+
import { Picture } from "./picture.js";
|
|
11
|
+
import { reviewStateOf } from "./selectors.js";
|
|
12
|
+
import { clockTime, theme, truncate } from "./theme.js";
|
|
13
|
+
export const SHOT_ROW_HEIGHT = 4;
|
|
14
|
+
export function flattenStream(groups, collapsed) {
|
|
15
|
+
const items = [];
|
|
16
|
+
for (const group of groups) {
|
|
17
|
+
items.push({ kind: "header", group, height: 1 });
|
|
18
|
+
if (collapsed.has(group.id))
|
|
19
|
+
continue;
|
|
20
|
+
for (const shot of group.shots)
|
|
21
|
+
items.push({ kind: "shot", shot, group, height: SHOT_ROW_HEIGHT });
|
|
22
|
+
}
|
|
23
|
+
return items;
|
|
24
|
+
}
|
|
25
|
+
/** Headers of expanded groups are labels, not stops; collapsed ones stay reachable. */
|
|
26
|
+
export function isNavigable(item, collapsed) {
|
|
27
|
+
return item.kind === "shot" || collapsed.has(item.group.id);
|
|
28
|
+
}
|
|
29
|
+
/** Nearest navigable index at or after `from` (direction +1) or before it (−1). */
|
|
30
|
+
export function nextNavigable(items, collapsed, from, direction) {
|
|
31
|
+
let index = from;
|
|
32
|
+
while (index >= 0 && index < items.length) {
|
|
33
|
+
if (isNavigable(items[index], collapsed))
|
|
34
|
+
return index;
|
|
35
|
+
index += direction;
|
|
36
|
+
}
|
|
37
|
+
// Fall back to the nearest navigable item in the other direction.
|
|
38
|
+
index = from - direction;
|
|
39
|
+
while (index >= 0 && index < items.length) {
|
|
40
|
+
if (isNavigable(items[index], collapsed))
|
|
41
|
+
return index;
|
|
42
|
+
index -= direction;
|
|
43
|
+
}
|
|
44
|
+
return Math.max(0, Math.min(from, items.length - 1));
|
|
45
|
+
}
|
|
46
|
+
/** First item index so the cursor item is fully visible within `height` lines. */
|
|
47
|
+
export function scrollWindow(items, cursor, scrollTop, height) {
|
|
48
|
+
if (items.length === 0)
|
|
49
|
+
return 0;
|
|
50
|
+
const clampedCursor = Math.max(0, Math.min(cursor, items.length - 1));
|
|
51
|
+
let top = Math.max(0, Math.min(scrollTop, items.length - 1));
|
|
52
|
+
if (clampedCursor < top)
|
|
53
|
+
top = clampedCursor;
|
|
54
|
+
const fits = (start) => {
|
|
55
|
+
let used = 0;
|
|
56
|
+
for (let index = start; index <= clampedCursor; index += 1) {
|
|
57
|
+
used += items[index].height;
|
|
58
|
+
if (used > height)
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
};
|
|
63
|
+
while (!fits(top) && top < clampedCursor)
|
|
64
|
+
top += 1;
|
|
65
|
+
return top;
|
|
66
|
+
}
|
|
67
|
+
function thumbnailCols(cellWidth, cellHeight) {
|
|
68
|
+
const rows = SHOT_ROW_HEIGHT - 1;
|
|
69
|
+
const heightPx = rows * cellHeight;
|
|
70
|
+
return Math.max(6, Math.min(16, Math.round((heightPx * (88 / 56)) / cellWidth)));
|
|
71
|
+
}
|
|
72
|
+
export function FilterBar({ filter, moviesOnly, counts, width, bulkBusy }) {
|
|
73
|
+
const title = filter === "unseen" ? `Unseen (${counts.pending})` : `History (${counts.seen})`;
|
|
74
|
+
return (_jsxs(Box, { flexShrink: 0, width: width, height: 1, paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: title }), _jsxs(Text, { children: [counts.movies > 0 ? (_jsx(Text, { color: moviesOnly ? theme.purple : theme.muted, inverse: moviesOnly, children: " m Movies " })) : null, filter === "unseen" && counts.pending > 0 ? (_jsx(Text, { color: bulkBusy ? theme.muted : theme.green, children: bulkBusy ? " S Marking… " : " S Seen all " })) : null, _jsx(Text, { color: theme.blue, children: filter === "unseen" ? " u History " : " u Unseen " })] })] }));
|
|
75
|
+
}
|
|
76
|
+
function GroupHeader({ group, collapsed, selected, width }) {
|
|
77
|
+
const unseen = group.shots.filter((shot) => reviewStateOf(shot) !== "seen").length;
|
|
78
|
+
return (_jsxs(Box, { flexShrink: 0, width: width, height: 1, paddingLeft: 1, backgroundColor: selected ? theme.selection : undefined, children: [_jsx(Text, { color: selected ? theme.brand : theme.muted, children: collapsed ? "▸ " : "▾ " }), _jsx(WorktreeChip, { label: group.worktreeShort }), _jsxs(Text, { color: theme.muted, wrap: "truncate", children: [" ", group.worktree !== group.worktreeShort ? `${truncate(group.worktree, Math.max(8, width - 26))} · ` : "", group.shots.length, " ", group.shots.length === 1 ? "frame" : "frames", unseen > 0 ? _jsxs(Text, { color: theme.amber, children: [" \u00B7 ", unseen, " unseen"] }) : null] })] }));
|
|
79
|
+
}
|
|
80
|
+
function ShotRow({ shot, selected, width, thumbCols, inGroup }) {
|
|
81
|
+
const state = reviewStateOf(shot);
|
|
82
|
+
const textWidth = Math.max(12, width - thumbCols - 4);
|
|
83
|
+
const time = clockTime(shot.capturedAt);
|
|
84
|
+
const titleWidth = Math.max(8, textWidth - time.length - 2);
|
|
85
|
+
const rowHeight = SHOT_ROW_HEIGHT - 1;
|
|
86
|
+
return (_jsx(Box, { flexShrink: 0, width: width, height: SHOT_ROW_HEIGHT, flexDirection: "column", children: _jsxs(Box, { flexShrink: 0, width: width, height: rowHeight, flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: 1, height: rowHeight, backgroundColor: selected ? theme.green : undefined }), _jsx(Picture, { src: shot.path, version: shot.mtimeMs, width: thumbCols, height: rowHeight, label: shot.isMovie ? "▶ movie" : "still" }), _jsxs(Box, { flexDirection: "column", width: textWidth, marginLeft: 1, height: rowHeight, overflow: "hidden", flexShrink: 0, backgroundColor: selected ? theme.selection : undefined, children: [_jsxs(Box, { flexShrink: 0, width: textWidth, height: 1, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate", children: [inGroup ? null : _jsx(WorktreeChip, { label: shot.worktreeShort }), shot.status === "fail" ? _jsx(Text, { color: theme.red, children: "\u25CF " }) : null, _jsx(Text, { bold: true, children: truncate(`${shot.feature} · ${shot.title}`, titleWidth) })] }), _jsx(Text, { color: theme.muted, children: time })] }), _jsx(Text, { color: theme.muted, wrap: "truncate", children: truncate(shot.description || shot.fileName, textWidth) }), _jsxs(Text, { wrap: "truncate", children: [shot.isMovie ? (_jsxs(_Fragment, { children: [_jsx(MovieBadge, { duration: durationLabel(shot.durationMs) }), _jsx(Text, { children: " " })] })) : null, _jsx(ReviewBadge, { state: state, stale: shot.review?.isStale }), shot.status ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(ExecutionPill, { status: shot.status })] })) : null] })] })] }) }));
|
|
87
|
+
}
|
|
88
|
+
export function StreamList(props) {
|
|
89
|
+
const { capabilities } = useServices();
|
|
90
|
+
const { items, collapsed, cursor, scrollTop, width, height, filter, hasRoots, scanning, totalShots } = props;
|
|
91
|
+
const thumbCols = thumbnailCols(capabilities.cellWidth, capabilities.cellHeight);
|
|
92
|
+
if (!hasRoots) {
|
|
93
|
+
return (_jsx(EmptyState, { width: width, title: "Choose folders to watch", body: "Pick the directories that contain your projects. Astroshots watches them for .astroshot/ screenshots.", action: "astroshot review --root <dir>" }));
|
|
94
|
+
}
|
|
95
|
+
if (totalShots === 0) {
|
|
96
|
+
return scanning ? (_jsx(EmptyState, { width: width, title: "Scanning watched folders\u2026", body: "First scan of a large folder can take a bit. Results stream in as they are found." })) : (_jsx(EmptyState, { width: width, title: "Waiting for frames", body: "When any project under a watched folder writes to .astroshot/, shots land here.", action: "r Rescan" }));
|
|
97
|
+
}
|
|
98
|
+
if (items.length === 0) {
|
|
99
|
+
return filter === "unseen" ? (_jsx(EmptyState, { width: width, title: "You\u2019re all caught up", body: "Every current frame has been seen.", action: "u View history" })) : (_jsx(EmptyState, { width: width, title: "No history yet", body: "Frames you mark Seen will appear here.", action: "u Back to unseen" }));
|
|
100
|
+
}
|
|
101
|
+
const rows = [];
|
|
102
|
+
let used = 0;
|
|
103
|
+
for (let index = scrollTop; index < items.length; index += 1) {
|
|
104
|
+
const item = items[index];
|
|
105
|
+
if (used + item.height > height)
|
|
106
|
+
break;
|
|
107
|
+
used += item.height;
|
|
108
|
+
const selected = props.focused && index === cursor;
|
|
109
|
+
if (item.kind === "header") {
|
|
110
|
+
rows.push(_jsx(GroupHeader, { group: item.group, collapsed: collapsed.has(item.group.id), selected: selected, width: width }, `h:${item.group.id}`));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
rows.push(_jsx(ShotRow, { shot: item.shot, selected: selected, width: width, thumbCols: thumbCols, inGroup: true }, item.shot.path));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const remaining = items.length - (scrollTop + rows.length);
|
|
117
|
+
return (_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: width, height: height, overflow: "hidden", children: [rows, remaining > 0 && used < height ? (_jsxs(Text, { color: theme.faint, children: [" \u2193 ", remaining, " more"] })) : null] }));
|
|
118
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function revealInFileManager(target: string): Promise<void>;
|
|
2
|
+
export declare function openWithDefaultApp(target: string): Promise<void>;
|
|
3
|
+
/** Put the image itself (not its path) on the clipboard, like the app's Copy Image. */
|
|
4
|
+
export declare function copyImageToClipboard(imagePath: string): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Desktop integrations: reveal, open, and copy — best effort per platform. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function run(command, args) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
execFile(command, args, (error) => (error ? reject(error) : resolve()));
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export async function revealInFileManager(target) {
|
|
10
|
+
if (process.platform === "darwin")
|
|
11
|
+
return run("open", ["-R", target]);
|
|
12
|
+
if (process.platform === "win32")
|
|
13
|
+
return run("explorer", [`/select,${target}`]);
|
|
14
|
+
return run("xdg-open", [path.dirname(target)]);
|
|
15
|
+
}
|
|
16
|
+
export async function openWithDefaultApp(target) {
|
|
17
|
+
if (process.platform === "darwin")
|
|
18
|
+
return run("open", [target]);
|
|
19
|
+
if (process.platform === "win32")
|
|
20
|
+
return run("cmd", ["/c", "start", "", target]);
|
|
21
|
+
return run("xdg-open", [target]);
|
|
22
|
+
}
|
|
23
|
+
/** Put the image itself (not its path) on the clipboard, like the app's Copy Image. */
|
|
24
|
+
export async function copyImageToClipboard(imagePath) {
|
|
25
|
+
if (process.platform === "darwin") {
|
|
26
|
+
const escaped = imagePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
27
|
+
const kind = /\.png$/i.test(imagePath) ? "«class PNGf»" : "JPEG picture";
|
|
28
|
+
return run("osascript", ["-e", `set the clipboard to (read (POSIX file "${escaped}") as ${kind})`]);
|
|
29
|
+
}
|
|
30
|
+
if (process.platform === "linux") {
|
|
31
|
+
return run("sh", ["-c", `xclip -selection clipboard -t image/png -i "${imagePath.replace(/"/g, '\\"')}"`]);
|
|
32
|
+
}
|
|
33
|
+
throw new Error("Copy image is not supported on this platform");
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Shot } from "../data/model.js";
|
|
2
|
+
import { type PlaybackState } from "./movie-player.js";
|
|
3
|
+
export declare const RAIL_WIDTH = 42;
|
|
4
|
+
export interface ReviewTakeoverProps {
|
|
5
|
+
shot: Shot;
|
|
6
|
+
position: {
|
|
7
|
+
index: number;
|
|
8
|
+
count: number;
|
|
9
|
+
};
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
composer: boolean;
|
|
13
|
+
onComposerSubmit: (text: string) => void;
|
|
14
|
+
onComposerCancel: () => void;
|
|
15
|
+
playback: PlaybackState;
|
|
16
|
+
onPlayback: (patch: Partial<PlaybackState>) => void;
|
|
17
|
+
playing: boolean;
|
|
18
|
+
busy: boolean;
|
|
19
|
+
error: string | null;
|
|
20
|
+
/** 1 shows the whole image; above magnifies (crops in). */
|
|
21
|
+
zoom: number;
|
|
22
|
+
/** Pan center as a fraction of the image, [0,1]. */
|
|
23
|
+
panX: number;
|
|
24
|
+
panY: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function ReviewTakeover(props: ReviewTakeoverProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Full-screen review: header, image stage, and the feedback rail.
|
|
4
|
+
* Pages over run siblings, oldest → newest.
|
|
5
|
+
*/
|
|
6
|
+
import { Box, Text } from "ink";
|
|
7
|
+
import { CommentList } from "./detail.js";
|
|
8
|
+
import { Line, ReviewBadge, Rule, SectionLabel } from "./chrome.js";
|
|
9
|
+
import { MoviePlayer, ProgressBar } from "./movie-player.js";
|
|
10
|
+
import { Picture } from "./picture.js";
|
|
11
|
+
import { reviewStateOf } from "./selectors.js";
|
|
12
|
+
import { TextInput } from "./text-input.js";
|
|
13
|
+
import { abbreviatedDateTime, theme, truncate } from "./theme.js";
|
|
14
|
+
export const RAIL_WIDTH = 42;
|
|
15
|
+
export function ReviewTakeover(props) {
|
|
16
|
+
const { shot, position, width, height, composer, playback, onPlayback, playing, busy, error, zoom, panX, panY } = props;
|
|
17
|
+
const railWidth = width >= 100 ? RAIL_WIDTH : Math.max(28, Math.floor(width * 0.38));
|
|
18
|
+
const stageWidth = Math.max(10, width - railWidth - 1);
|
|
19
|
+
const headerHeight = 3;
|
|
20
|
+
const footerHeight = 1;
|
|
21
|
+
const stageHeight = Math.max(4, height - headerHeight - footerHeight);
|
|
22
|
+
const canPlay = shot.isMovie && shot.videoPath !== null;
|
|
23
|
+
const showPlayer = canPlay && (playing || playback.positionMs > 0);
|
|
24
|
+
const meta = [shot.worktree, shot.feature, shot.url ?? "", abbreviatedDateTime(shot.capturedAt)].filter(Boolean).join(" · ");
|
|
25
|
+
const state = reviewStateOf(shot);
|
|
26
|
+
const railInner = railWidth - 2;
|
|
27
|
+
const imageHeight = showPlayer ? stageHeight - 1 : stageHeight;
|
|
28
|
+
return (_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: width, height: height, children: [_jsxs(Box, { flexShrink: 0, height: 1, width: width, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { bold: true, children: truncate(shot.title, Math.max(8, width - 40)) }), shot.sequence ? (_jsxs(Text, { color: theme.muted, children: [" ", _jsxs(Text, { backgroundColor: theme.selection, children: [" ", shot.sequence, " "] })] })) : null] }), _jsxs(Text, { color: theme.muted, children: [_jsx(Text, { color: theme.blue, children: "\u2039" }), " ", position.index, " / ", position.count, " ", _jsx(Text, { color: theme.blue, children: "\u203A" }), " esc close"] })] }), _jsx(Box, { flexShrink: 0, height: 1, width: width, paddingX: 1, children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: truncate(meta, width - 2) }) }), _jsx(Box, { flexShrink: 0, height: 1, width: width, paddingX: 1, children: _jsx(Rule, { width: width - 2 }) }), _jsxs(Box, { flexShrink: 0, flexDirection: "row", width: width, height: stageHeight, children: [_jsxs(Box, { flexShrink: 0, flexDirection: "column", width: stageWidth, height: stageHeight, alignItems: "center", justifyContent: "center", children: [showPlayer ? (_jsx(MoviePlayer, { videoPath: shot.videoPath, posterPath: shot.path, posterVersion: shot.mtimeMs, width: stageWidth, height: imageHeight, playback: playback, onPlayback: onPlayback })) : (_jsx(Picture, { src: shot.path, version: shot.mtimeMs, width: stageWidth, height: imageHeight, maxUpscale: 8, zoom: zoom, panX: panX, panY: panY, label: shot.isMovie ? "▶ movie poster" : "Screenshot unavailable" })), showPlayer ? (_jsx(ProgressBar, { positionMs: playback.positionMs, durationMs: playback.durationMs ?? shot.durationMs, chapters: shot.chapters, width: stageWidth - 2, playing: playback.playing })) : null] }), _jsx(Box, { flexShrink: 0, width: 1, height: stageHeight, flexDirection: "column", children: Array.from({ length: stageHeight }, (_, index) => (_jsx(Text, { color: theme.faint, children: "\u2502" }, index))) }), _jsxs(Box, { flexShrink: 0, flexDirection: "column", width: railWidth, height: stageHeight, paddingX: 1, overflow: "hidden", children: [_jsxs(Box, { flexShrink: 0, height: 1, justifyContent: "space-between", width: railInner, children: [_jsx(Text, { bold: true, children: "Review" }), _jsx(ReviewBadge, { state: state, stale: shot.review?.isStale })] }), shot.review?.isStale ? (_jsx(Box, { flexShrink: 0, width: railInner, height: 2, children: _jsx(Text, { color: theme.amber, wrap: "wrap", children: "A newer image was captured since this was seen." }) })) : null, _jsx(Box, { flexShrink: 0, width: railInner, height: Math.min(3, Math.max(1, Math.ceil((shot.description || shot.fileName).length / railInner))), overflow: "hidden", children: _jsx(Text, { color: theme.muted, wrap: "wrap", children: truncate(shot.description || shot.fileName, railInner * 3) }) }), _jsx(Line, { width: railInner, children: _jsx(Rule, { width: railInner }) }), _jsxs(Box, { flexShrink: 0, height: 1, justifyContent: "space-between", width: railInner, children: [_jsx(SectionLabel, { children: "FEEDBACK" }), _jsx(Text, { color: theme.muted, children: shot.review?.comments.length ?? 0 })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 100, minHeight: 0, overflow: "hidden", width: railInner, children: _jsx(CommentList, { shot: shot, width: railInner, max: Math.max(2, Math.floor((stageHeight - 12) / 3)) }) }), _jsx(Line, { width: railInner, children: _jsx(Rule, { width: railInner }) }), busy ? (_jsx(Line, { width: railInner, children: _jsx(Text, { color: theme.muted, children: "Saving review\u2026" }) })) : null, error ? (_jsx(Line, { width: railInner, children: _jsx(Text, { color: theme.red, children: truncate(error, railInner) }) })) : null, composer ? (_jsx(TextInput, { placeholder: "Share feedback\u2026", width: railInner, onSubmit: props.onComposerSubmit, onCancel: props.onComposerCancel, submitLabel: "Send Feedback" })) : (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Line, { width: railInner, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.blue, bold: true, children: " c " }), _jsx(Text, { children: "Send Feedback" }), _jsx(Text, { color: theme.green, bold: true, children: " s " }), _jsx(Text, { children: "Seen" })] }) }), _jsx(Line, { width: railInner, children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: zoom > 1 ? "← ↑ → ↓ pan · +/- zoom · 0 reset · esc close" : canPlay ? "← → page · space play · +/- zoom · [ ] chapter" : "← → page · +/- zoom · c send · s seen" }) })] }))] })] })] }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface TextInputProps {
|
|
2
|
+
placeholder: string;
|
|
3
|
+
width: number;
|
|
4
|
+
onSubmit: (value: string) => void;
|
|
5
|
+
onCancel: () => void;
|
|
6
|
+
submitLabel?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function TextInput({ placeholder, width, onSubmit, onCancel, submitLabel }: TextInputProps): import("react").JSX.Element;
|