feat: Add play/pause feature
This commit is contained in:
parent
c131b84819
commit
f75c5b71f0
|
|
@ -5,4 +5,5 @@ dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
torrents/*
|
torrents/*
|
||||||
|
idea/
|
||||||
|
|
@ -65,6 +65,20 @@ export class SeederRegistry {
|
||||||
return seeder.getState();
|
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> {
|
async removeTorrent(infoHashHex: string): Promise<boolean> {
|
||||||
const entry = this.seeders.get(infoHashHex);
|
const entry = this.seeders.get(infoHashHex);
|
||||||
if (!entry) return false;
|
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 */
|
/** Remove a torrent — sends 'stopped' to trackers then removes it */
|
||||||
app.delete("/:hash", async (c) => {
|
app.delete("/:hash", async (c) => {
|
||||||
const { hash } = c.req.param();
|
const { hash } = c.req.param();
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { TrackerResponse } from "../tracker/TrackerResponse.js";
|
||||||
import { UdpTracker } from "../tracker/UdpTracker.js";
|
import { UdpTracker } from "../tracker/UdpTracker.js";
|
||||||
import { SpeedSimulator } from "./SpeedSimulator.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 {
|
export interface SeederState {
|
||||||
infoHashHex: string;
|
infoHashHex: string;
|
||||||
|
|
@ -118,9 +118,8 @@ export class FakeSeeder extends EventEmitter {
|
||||||
this.scheduleNextAnnounce();
|
this.scheduleNextAnnounce();
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
if (this.status === "stopped" || this.status === "stopping") return;
|
if (this.status !== "running") return;
|
||||||
this.setStatus("stopping");
|
|
||||||
|
|
||||||
if (this.announceTimer) {
|
if (this.announceTimer) {
|
||||||
clearTimeout(this.announceTimer);
|
clearTimeout(this.announceTimer);
|
||||||
|
|
@ -131,7 +130,7 @@ export class FakeSeeder extends EventEmitter {
|
||||||
this.tickTimer = null;
|
this.tickTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort stopped announce to all trackers
|
// Notify trackers we're leaving the swarm (best-effort)
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
this.trackers.map((t) =>
|
this.trackers.map((t) =>
|
||||||
t.stop({
|
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.setStatus("stopped");
|
||||||
this.emit("stopped");
|
this.emit("stopped");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,8 @@
|
||||||
|
|
||||||
<svelte:window on:keydown={onKeydown} />
|
<svelte:window on:keydown={onKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<div class="backdrop" on:click={onBackdrop} role="presentation">
|
||||||
<div class="backdrop" on:click={onBackdrop} role="dialog" aria-modal="true" aria-label="Settings">
|
<dialog open aria-label="Settings">
|
||||||
<div class="modal">
|
|
||||||
<header>
|
<header>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<button class="close" on:click={() => dispatch("close")} aria-label="Close">✕</button>
|
<button class="close" on:click={() => dispatch("close")} aria-label="Close">✕</button>
|
||||||
|
|
@ -150,7 +149,7 @@
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -164,7 +163,7 @@
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
dialog {
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
border: 1px solid #2a2a4a;
|
border: 1px solid #2a2a4a;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|
@ -172,6 +171,8 @@
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
color: inherit;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatBytes } from "../lib/api.js";
|
import { formatBytes, removeTorrent, pauseTorrent, resumeTorrent } from "../lib/api.js";
|
||||||
import { removeTorrent } from "../lib/api.js";
|
|
||||||
import type { TorrentState } from "../lib/api.js";
|
import type { TorrentState } from "../lib/api.js";
|
||||||
|
|
||||||
export let torrents: TorrentState[] = [];
|
export let torrents: TorrentState[] = [];
|
||||||
|
|
||||||
let removing = new Set<string>();
|
let removing = new Set<string>();
|
||||||
|
let toggling = new Set<string>();
|
||||||
|
|
||||||
async function handleRemove(hash: string) {
|
async function handleRemove(hash: string) {
|
||||||
removing = new Set([...removing, hash]);
|
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 {
|
function statusColor(status: TorrentState["status"]): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "running": return "#4ade80";
|
case "running": return "#4ade80";
|
||||||
|
case "paused": return "#60a5fa";
|
||||||
case "stopping": return "#facc15";
|
case "stopping": return "#facc15";
|
||||||
case "stopped": return "#94a3b8";
|
case "stopped": return "#94a3b8";
|
||||||
case "error": return "#f87171";
|
case "error": return "#f87171";
|
||||||
|
|
@ -62,7 +77,18 @@
|
||||||
<td class="time">
|
<td class="time">
|
||||||
{t.lastAnnounce ? new Date(t.lastAnnounce).toLocaleTimeString() : "—"}
|
{t.lastAnnounce ? new Date(t.lastAnnounce).toLocaleTimeString() : "—"}
|
||||||
</td>
|
</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
|
<button
|
||||||
class="remove"
|
class="remove"
|
||||||
disabled={removing.has(t.infoHashHex)}
|
disabled={removing.has(t.infoHashHex)}
|
||||||
|
|
@ -136,10 +162,17 @@
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
td.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.pause,
|
||||||
|
button.resume,
|
||||||
button.remove {
|
button.remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid #f87171;
|
border: 1px solid;
|
||||||
color: #f87171;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
|
|
@ -147,12 +180,18 @@
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.remove:hover {
|
button.pause,
|
||||||
background: #f8717122;
|
button.resume,
|
||||||
|
button.remove {
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
}
|
}
|
||||||
|
|
||||||
button.remove:disabled {
|
button.pause { color: #60a5fa; border-color: #60a5fa; }
|
||||||
opacity: 0.5;
|
button.pause:hover { background: #60a5fa22; }
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export interface TorrentState {
|
||||||
uploaded: number;
|
uploaded: number;
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
left: number;
|
left: number;
|
||||||
status: "idle" | "running" | "stopping" | "stopped" | "error";
|
status: "idle" | "running" | "paused" | "stopping" | "stopped" | "error";
|
||||||
lastAnnounce: string | null;
|
lastAnnounce: string | null;
|
||||||
lastInterval: number;
|
lastInterval: number;
|
||||||
seeders: number;
|
seeders: number;
|
||||||
|
|
@ -45,6 +45,14 @@ export async function removeTorrent(hash: string): Promise<void> {
|
||||||
await fetch(`${BASE}/torrents/${hash}`, { method: "DELETE" });
|
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 {
|
export interface AppConfig {
|
||||||
port: number;
|
port: number;
|
||||||
announcePort: number;
|
announcePort: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue