feat: Add play/pause feature
This commit is contained in:
parent
c131b84819
commit
f75c5b71f0
|
|
@ -5,4 +5,5 @@ dist/
|
|||
.env
|
||||
*.log
|
||||
|
||||
torrents/*
|
||||
torrents/*
|
||||
idea/
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue