feat: Add play/pause feature

This commit is contained in:
Julien Quiaios 2026-04-03 19:23:30 -04:00
parent c131b84819
commit f75c5b71f0
Signed by: palozob
GPG Key ID: 10F46E45A96EDCDA
7 changed files with 155 additions and 23 deletions

3
.gitignore vendored
View File

@ -5,4 +5,5 @@ dist/
.env
*.log
torrents/*
torrents/*
idea/

View File

@ -65,6 +65,20 @@ export class SeederRegistry {
return seeder.getState();
}
async pauseTorrent(infoHashHex: string): Promise<boolean> {
const entry = this.seeders.get(infoHashHex);
if (!entry) return false;
await entry.seeder.pause();
return true;
}
async resumeTorrent(infoHashHex: string): Promise<boolean> {
const entry = this.seeders.get(infoHashHex);
if (!entry) return false;
await entry.seeder.resume();
return true;
}
async removeTorrent(infoHashHex: string): Promise<boolean> {
const entry = this.seeders.get(infoHashHex);
if (!entry) return false;

View File

@ -46,6 +46,22 @@ export function torrentsRouter(registry: SeederRegistry) {
});
});
/** Pause a torrent — sends 'stopped' to trackers and halts the announce loop */
app.post("/:hash/pause", async (c) => {
const { hash } = c.req.param();
const ok = await registry.pauseTorrent(hash);
if (!ok) return res.notFound(c);
return c.json({ ok: true });
});
/** Resume a paused torrent — re-announces as 'started' and restarts the loop */
app.post("/:hash/resume", async (c) => {
const { hash } = c.req.param();
const ok = await registry.resumeTorrent(hash);
if (!ok) return res.notFound(c);
return c.json({ ok: true });
});
/** Remove a torrent — sends 'stopped' to trackers then removes it */
app.delete("/:hash", async (c) => {
const { hash } = c.req.param();

View File

@ -8,7 +8,7 @@ import type { TrackerResponse } from "../tracker/TrackerResponse.js";
import { UdpTracker } from "../tracker/UdpTracker.js";
import { SpeedSimulator } from "./SpeedSimulator.js";
export type SeederStatus = "idle" | "running" | "stopping" | "stopped" | "error";
export type SeederStatus = "idle" | "running" | "paused" | "stopping" | "stopped" | "error";
export interface SeederState {
infoHashHex: string;
@ -118,9 +118,8 @@ export class FakeSeeder extends EventEmitter {
this.scheduleNextAnnounce();
}
async stop(): Promise<void> {
if (this.status === "stopped" || this.status === "stopping") return;
this.setStatus("stopping");
async pause(): Promise<void> {
if (this.status !== "running") return;
if (this.announceTimer) {
clearTimeout(this.announceTimer);
@ -131,7 +130,7 @@ export class FakeSeeder extends EventEmitter {
this.tickTimer = null;
}
// Best-effort stopped announce to all trackers
// Notify trackers we're leaving the swarm (best-effort)
await Promise.allSettled(
this.trackers.map((t) =>
t.stop({
@ -147,6 +146,60 @@ export class FakeSeeder extends EventEmitter {
)
);
this.setStatus("paused");
}
async resume(): Promise<void> {
if (this.status !== "paused") return;
this.setStatus("running");
// Restart speed tick
this.lastTickTime = Date.now();
this.tickTimer = setInterval(() => {
const now = Date.now();
const delta = now - this.lastTickTime;
this.lastTickTime = now;
this.uploaded += this.speedSim.tick(delta);
this.emit("stateChange", this.getState());
}, 5_000);
// Re-announce as started then resume loop
await this.doAnnounce("started");
this.scheduleNextAnnounce();
}
async stop(): Promise<void> {
if (this.status === "stopped" || this.status === "stopping") return;
const waspaused = this.status === "paused";
this.setStatus("stopping");
if (this.announceTimer) {
clearTimeout(this.announceTimer);
this.announceTimer = null;
}
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
// If already paused, trackers were already notified — skip the stopped announce
if (!waspaused) {
await Promise.allSettled(
this.trackers.map((t) =>
t.stop({
infoHash: this.torrent.infoHash,
peerId: this.profile.peerId,
port: this.config.announcePort,
uploaded: this.uploaded,
downloaded: this.downloaded,
left: this.left,
userAgent: this.profile.userAgent,
key: this.profile.key,
})
)
);
}
this.setStatus("stopped");
this.emit("stopped");
}

View File

@ -65,9 +65,8 @@
<svelte:window on:keydown={onKeydown} />
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="backdrop" on:click={onBackdrop} role="dialog" aria-modal="true" aria-label="Settings">
<div class="modal">
<div class="backdrop" on:click={onBackdrop} role="presentation">
<dialog open aria-label="Settings">
<header>
<h2>Settings</h2>
<button class="close" on:click={() => dispatch("close")} aria-label="Close"></button>
@ -150,7 +149,7 @@
</form>
{/if}
</div>
</dialog>
</div>
<style>
@ -164,7 +163,7 @@
z-index: 100;
}
.modal {
dialog {
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 10px;
@ -172,6 +171,8 @@
max-width: 420px;
padding: 1.5rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
color: inherit;
margin: 0;
}
header {

View File

@ -1,11 +1,11 @@
<script lang="ts">
import { formatBytes } from "../lib/api.js";
import { removeTorrent } from "../lib/api.js";
import { formatBytes, removeTorrent, pauseTorrent, resumeTorrent } from "../lib/api.js";
import type { TorrentState } from "../lib/api.js";
export let torrents: TorrentState[] = [];
let removing = new Set<string>();
let toggling = new Set<string>();
async function handleRemove(hash: string) {
removing = new Set([...removing, hash]);
@ -18,9 +18,24 @@
}
}
async function handleTogglePause(t: TorrentState) {
toggling = new Set([...toggling, t.infoHashHex]);
try {
if (t.status === "running") {
await pauseTorrent(t.infoHashHex);
} else if (t.status === "paused") {
await resumeTorrent(t.infoHashHex);
}
} finally {
toggling.delete(t.infoHashHex);
toggling = toggling;
}
}
function statusColor(status: TorrentState["status"]): string {
switch (status) {
case "running": return "#4ade80";
case "paused": return "#60a5fa";
case "stopping": return "#facc15";
case "stopped": return "#94a3b8";
case "error": return "#f87171";
@ -62,7 +77,18 @@
<td class="time">
{t.lastAnnounce ? new Date(t.lastAnnounce).toLocaleTimeString() : "—"}
</td>
<td>
<td class="actions">
{#if t.status === "running" || t.status === "paused"}
<button
class:pause={t.status === "running"}
class:resume={t.status === "paused"}
disabled={toggling.has(t.infoHashHex)}
on:click={() => handleTogglePause(t)}
title={t.status === "running" ? "Pause" : "Resume"}
>
{toggling.has(t.infoHashHex) ? "…" : t.status === "running" ? "⏸" : "▶"}
</button>
{/if}
<button
class="remove"
disabled={removing.has(t.infoHashHex)}
@ -136,10 +162,17 @@
cursor: help;
}
td.actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
button.pause,
button.resume,
button.remove {
background: none;
border: 1px solid #f87171;
color: #f87171;
border: 1px solid;
border-radius: 4px;
cursor: pointer;
padding: 0.2rem 0.5rem;
@ -147,12 +180,18 @@
transition: background 0.15s;
}
button.remove:hover {
background: #f8717122;
button.pause,
button.resume,
button.remove {
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
button.remove:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.pause { color: #60a5fa; border-color: #60a5fa; }
button.pause:hover { background: #60a5fa22; }
button.resume { color: #4ade80; border-color: #4ade80; }
button.resume:hover { background: #4ade8022; }
button.remove { color: #f87171; border-color: #f87171; }
button.remove:hover { background: #f8717122; }
</style>

View File

@ -7,7 +7,7 @@ export interface TorrentState {
uploaded: number;
downloaded: number;
left: number;
status: "idle" | "running" | "stopping" | "stopped" | "error";
status: "idle" | "running" | "paused" | "stopping" | "stopped" | "error";
lastAnnounce: string | null;
lastInterval: number;
seeders: number;
@ -45,6 +45,14 @@ export async function removeTorrent(hash: string): Promise<void> {
await fetch(`${BASE}/torrents/${hash}`, { method: "DELETE" });
}
export async function pauseTorrent(hash: string): Promise<void> {
await fetch(`${BASE}/torrents/${hash}/pause`, { method: "POST" });
}
export async function resumeTorrent(hash: string): Promise<void> {
await fetch(`${BASE}/torrents/${hash}/resume`, { method: "POST" });
}
export interface AppConfig {
port: number;
announcePort: number;