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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
> from git history and grouped by theme rather than exhaustive per-commit
> detail.

## [4.2.0] - unreleased
## [4.2.0] - 2026-10-02

### Added
- **Import a GPS-tagged GoPro video as a session.** Every GoPro since the
Expand Down Expand Up @@ -159,6 +159,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
session header.

### Fixed
- **The Insta360 import dialog no longer spins forever when reopened.** In the
Android app, reopening the camera dialog while a camera was still connected
got stuck on its loading spinner, and only unloading the video got you out.
It now goes straight back to the recording list, and drops back to the
connect form if the camera can't list its recordings.
- **Save to Gallery / export in the Android app did nothing.** Every export
(and the app's background copy of your video) failed on its very first
chunk: the way the app handed video bytes to the shell doesn't exist on
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "doves-dataviewer",
"private": true,
"version": "4.1.0",
"version": "4.2.0",
"description": "Open-source, offline-first motorsport telemetry viewer (Dove's DataViewer / LapWing).",
"license": "GPL-3.0-or-later",
"author": "TheAngryRaven",
Expand Down
38 changes: 24 additions & 14 deletions src/components/insta360/Insta360ImportDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Camera, Loader2, Unplug } from "lucide-react";
import { Button } from "@/components/ui/button";
Expand All @@ -13,6 +13,7 @@ import {
insta360ListFiles,
insta360Status,
} from "@/lib/insta360/ipc";
import { resumeInsta360Session, type Insta360DialogPhase } from "@/lib/insta360/resumeSession";
import type { Insta360CameraFile, Insta360CameraInfo } from "@/lib/insta360/types";

interface Insta360ImportDialogProps {
Expand All @@ -24,7 +25,7 @@ interface Insta360ImportDialogProps {
onDisconnected: () => void;
}

type Phase = "idle" | "connecting" | "listing" | "ready" | "error";
type Phase = Insta360DialogPhase;

/** Insta360 cameras ship with this hotspot password. */
const DEFAULT_PASSPHRASE = "88888888";
Expand Down Expand Up @@ -62,22 +63,31 @@ export function Insta360ImportDialog({ open, onOpenChange, onLoad, onDisconnecte
const [ssidPrefix, setSsidPrefix] = useState("");
const [passphrase, setPassphrase] = useState(DEFAULT_PASSPHRASE);

// Read by the resume below, which must not hijack a connect the user has
// already started while the status call was in flight.
const phaseRef = useRef<Phase>(phase);
useEffect(() => {
phaseRef.current = phase;
}, [phase]);

// Reopening the dialog while a camera is still connected (a stream is
// playing) resumes at the recording list.
// playing) resumes at the recording list. Keyed on `open` alone: with
// `phase` in the deps, the resume's own move to "listing" re-ran this
// effect, whose cleanup cancelled the listing and stranded the spinner.
useEffect(() => {
if (!open || phase !== "idle") return;
if (!open) return;
let alive = true;
void insta360Status()
.then(async (s) => {
if (!alive || !s.connected || !s.camera) return;
setCamera(s.camera);
setPhase("listing");
setFiles(await insta360ListFiles());
if (alive) setPhase("ready");
})
.catch(() => { /* not connected — stay idle */ });
void resumeInsta360Session(
{ status: insta360Status, listFiles: insta360ListFiles },
{
canResume: () => alive && phaseRef.current === "idle",
setCamera,
setFiles,
setPhase,
},
);
return () => { alive = false; };
}, [open, phase]);
}, [open]);

const connect = useCallback(async () => {
setError(null);
Expand Down
68 changes: 68 additions & 0 deletions src/lib/insta360/resumeSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
import { resumeInsta360Session, type Insta360DialogPhase, type ResumeSink } from "./resumeSession";
import type { Insta360CameraFile, Insta360CameraStatus } from "./types";

const camera = { cameraType: "X4", connectType: "wifi" as const };
const file = { id: "a", name: "VID_1.insv" } as Insta360CameraFile;
const connected: Insta360CameraStatus = { connected: true, camera };

function harness(initialPhase: Insta360DialogPhase = "idle") {
let phase = initialPhase;
const phases: Insta360DialogPhase[] = [];
const sink: ResumeSink = {
// Mirrors the dialog: only an idle dialog may resume.
canResume: () => phase === "idle",
setCamera: vi.fn(),
setFiles: vi.fn(),
setPhase: (p) => {
phase = p;
phases.push(p);
},
};
return { sink, phases };
}

describe("resumeInsta360Session", () => {
it("finishes at ready even though its own move to listing ends the idle state (regression)", async () => {
const { sink, phases } = harness();
await resumeInsta360Session({ status: async () => connected, listFiles: async () => [file] }, sink);
expect(phases).toEqual(["listing", "ready"]);
expect(sink.setFiles).toHaveBeenCalledWith([file]);
expect(sink.setCamera).toHaveBeenCalledWith(camera);
});

it("drops back to idle when the listing fails instead of spinning", async () => {
const { sink, phases } = harness();
const listFiles = async (): Promise<Insta360CameraFile[]> => {
throw new Error("gone");
};
await resumeInsta360Session({ status: async () => connected, listFiles }, sink);
expect(phases).toEqual(["listing", "idle"]);
});

it("does nothing when no camera is connected", async () => {
const { sink, phases } = harness();
const listFiles = vi.fn();
await resumeInsta360Session({ status: async () => ({ connected: false }), listFiles }, sink);
expect(phases).toEqual([]);
expect(listFiles).not.toHaveBeenCalled();
});

it("does nothing when the status call fails", async () => {
const { sink, phases } = harness();
const status = async (): Promise<Insta360CameraStatus> => {
throw new Error("no shell");
};
await resumeInsta360Session({ status, listFiles: vi.fn() }, sink);
expect(phases).toEqual([]);
});

it("leaves a user-started connect alone when the status answer arrives late", async () => {
const { sink, phases } = harness("connecting");
const listFiles = vi.fn();
await resumeInsta360Session({ status: async () => connected, listFiles }, sink);
expect(phases).toEqual([]);
expect(sink.setCamera).not.toHaveBeenCalled();
expect(listFiles).not.toHaveBeenCalled();
});
});
48 changes: 48 additions & 0 deletions src/lib/insta360/resumeSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Insta360CameraFile, Insta360CameraInfo, Insta360CameraStatus } from "./types";

/** The Insta360 import dialog's UI phase. */
export type Insta360DialogPhase = "idle" | "connecting" | "listing" | "ready" | "error";

export interface ResumeDeps {
status: () => Promise<Insta360CameraStatus>;
listFiles: () => Promise<Insta360CameraFile[]>;
}

export interface ResumeSink {
/**
* Checked once, after the status answer and before any state is touched:
* false when the dialog closed meanwhile or the user already started a
* connect of their own, so a late status answer cannot hijack that flow.
*/
canResume: () => boolean;
setCamera: (camera: Insta360CameraInfo) => void;
setFiles: (files: Insta360CameraFile[]) => void;
setPhase: (phase: Insta360DialogPhase) => void;
}

/**
* Reopening the dialog while a camera is still connected (a stream is
* playing) resumes at the recording list (plan 0025).
*
* `canResume` is deliberately NOT re-checked once this has moved the phase to
* "listing": that move is this function's own doing, and treating it as a
* reason to abandon the listing is what left the dialog spinning forever. A
* failed listing drops back to idle rather than stranding the spinner.
*/
export async function resumeInsta360Session(deps: ResumeDeps, sink: ResumeSink): Promise<void> {
let status: Insta360CameraStatus;
try {
status = await deps.status();
} catch {
return; // not connected — stay idle
}
if (!sink.canResume() || !status.connected || !status.camera) return;
sink.setCamera(status.camera);
sink.setPhase("listing");
try {
sink.setFiles(await deps.listFiles());
sink.setPhase("ready");
} catch {
sink.setPhase("idle");
}
}
Loading