File size: 4,390 Bytes
37ee895
 
89b6a21
7d5735a
89b6a21
 
 
 
 
 
127bfda
89b6a21
 
7d5735a
89b6a21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127bfda
89b6a21
127bfda
89b6a21
 
127bfda
89b6a21
 
 
 
 
 
 
127bfda
89b6a21
7d5735a
89b6a21
 
 
 
127bfda
89b6a21
 
 
 
 
127bfda
89b6a21
 
 
 
 
 
 
 
37ee895
 
 
 
 
 
 
 
 
 
89b6a21
 
7d5735a
89b6a21
 
 
37ee895
 
7d5735a
37ee895
 
 
 
 
7d5735a
37ee895
 
 
 
7d5735a
37ee895
 
 
 
7d5735a
37ee895
 
 
7d5735a
37ee895
 
 
 
 
 
 
 
 
 
89b6a21
 
 
 
 
32f24ec
 
 
 
 
89b6a21
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import { fetchClipJobs, fetchVisionStatus, postVisionIngest } from "./api.js";
import { renderClipCard, renderVisionStatus } from "./view.js";

// Adaptive polling based on Page Visibility API.
class AdaptivePoller {
    constructor(fn, intervalMs = 2000) {
        this.fn = fn;
        this.baseInterval = intervalMs;
        this.timer = null;
        this.isRunning = false;

        document.addEventListener("visibilitychange", () => {
            if (this.isRunning) {
                this.restart(); // Restart with interval adjusted by visibility
            }
        });
    }

    start() {
        if (this.isRunning) return;
        this.isRunning = true;
        this.tick();
    }

    stop() {
        this.isRunning = false;
        if (this.timer) clearTimeout(this.timer);
    }

    restart() {
        if (this.timer) clearTimeout(this.timer);
        this.tick();
    }

    async tick() {
        if (!this.isRunning) return;

        await this.fn();

        const isHidden = document.hidden;
        const nextInterval = isHidden ? this.baseInterval * 5 : this.baseInterval;

        this.timer = setTimeout(() => this.tick(), nextInterval);
    }
}

export function createClipsController({ els }) {
    const render = (jobs) => {
        if (!els.container) return;

        if (jobs.length === 0) {
            els.container.innerHTML = '<div class="empty-state">No recent clip jobs.</div>';
            return;
        }

        const html = jobs.map(renderClipCard).join("");
        // Simple DOM diffing could be added here for optimization,
        // but innerHTML is fast enough for < 50 items.
        // We check if content changed to avoid text selection loss if user is selecting text.
        if (els.container.innerHTML !== html) {
            els.container.innerHTML = html;
        }

        // Hide skeleton if visible
        if (els.loadingSkeleton) {
            els.loadingSkeleton.style.display = "none";
        }
    };

    const fetchAndRender = async () => {
        try {
            const [clipData, visionData] = await Promise.all([
                fetchClipJobs().catch(() => null),
                fetchVisionStatus().catch(() => null)
            ]);

            if (clipData?.ok && Array.isArray(clipData.items)) {
                render(clipData.items);
            }
            if (visionData?.ok) {
                renderVisionStatus(visionData, els);
            }
        } catch (error) {
            console.error("Clip polling error:", error);
        }
    };

    const handleVisionIngest = async () => {
        if (!els.visionIngestInput || !els.visionIngestInput.files.length) {
            if (els.visionFeedback) els.visionFeedback.textContent = "Select an image first.";
            return;
        }

        const file = els.visionIngestInput.files[0];
        if (els.visionIngestBtn) els.visionIngestBtn.disabled = true;
        if (els.visionFeedback) els.visionFeedback.textContent = "Uploading frame...";

        try {
            const result = await postVisionIngest(file);
            if (result && result.ok) {
                els.visionFeedback.textContent = "Frame analyzed successfully.";
                els.visionFeedback.className = "panel-hint event-level-info";
                els.visionIngestInput.value = ""; // clear
                await fetchAndRender();
            } else {
                els.visionFeedback.textContent = `Error: ${result?.reason || 'Upload failed'}`;
                els.visionFeedback.className = "panel-hint event-level-warn";
            }
        } catch (error) {
            els.visionFeedback.textContent = "Error communicating with server.";
            els.visionFeedback.className = "panel-hint event-level-error";
        } finally {
            if (els.visionIngestBtn) els.visionIngestBtn.disabled = false;
        }
    };

    if (els.visionIngestBtn) {
        els.visionIngestBtn.addEventListener("click", handleVisionIngest);
    }

    const poller = new AdaptivePoller(fetchAndRender, 2000);

    return {
        startPolling: () => poller.start(),
        stopPolling: () => poller.stop(),
        manualRefresh: fetchAndRender,
        // O6/reliability: render from the SSE snapshot instead of polling.
        renderFromSnapshot: (clips) => {
            if (clips && Array.isArray(clips.items)) render(clips.items);
        }
    };
}