This commit is contained in:
itexpert228 2026-03-07 19:40:49 +03:00
parent a6e1bc21d7
commit e4265fa777

View file

@ -13,7 +13,6 @@ import (
fyneapp "fyne.io/fyne/v2/app" fyneapp "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
appcore "github.com/veggiedefender/torrent-client/internal/app" appcore "github.com/veggiedefender/torrent-client/internal/app"
@ -23,29 +22,37 @@ import (
type desktopUI struct { type desktopUI struct {
controller *appcore.Controller controller *appcore.Controller
app fyne.App
window fyne.Window window fyne.Window
pathEntry *widget.Entry pathEntry *widget.Entry
downloadDirEntry *widget.Entry downloadDirEntry *widget.Entry
progress *widget.ProgressBar
phaseLabel *widget.Label
phaseMetricLabel *widget.Label
messageLabel *widget.Label
errorLabel *widget.Label
nameLabel *widget.Label
sizeLabel *widget.Label
pieceLabel *widget.Label
pieceCountLabel *widget.Label
downloadedLabel *widget.Label
outputLabel *widget.Label
speedLabel *widget.Label
peerCountLabel *widget.Label
mu sync.RWMutex browseButton *widget.Button
trackers []torrent.TrackerStatus browseOutputButton *widget.Button
peers []torrent.PeerStatus startButton *widget.Button
files []torrentfile.File stopButton *widget.Button
progress *widget.ProgressBar
phaseLabel *widget.Label
phaseMetricLabel *widget.Label
statusSummaryLabel *widget.Label
messageLabel *widget.Label
errorLabel *widget.Label
nameLabel *widget.Label
sizeLabel *widget.Label
pieceLabel *widget.Label
pieceCountLabel *widget.Label
downloadedLabel *widget.Label
outputLabel *widget.Label
speedLabel *widget.Label
peerCountLabel *widget.Label
mu sync.RWMutex
trackers []torrent.TrackerStatus
peers []torrent.PeerStatus
files []torrentfile.File
inputError string
loading bool
trackersTable *widget.Table trackersTable *widget.Table
peersTable *widget.Table peersTable *widget.Table
@ -61,55 +68,68 @@ func Start(controller *appcore.Controller) {
func newDesktopUI(controller *appcore.Controller) *desktopUI { func newDesktopUI(controller *appcore.Controller) *desktopUI {
a := fyneapp.NewWithID("github.com/veggiedefender/ztorrent") a := fyneapp.NewWithID("github.com/veggiedefender/ztorrent")
w := a.NewWindow("Ztorrent") w := a.NewWindow("Ztorrent Desktop")
w.Resize(fyne.NewSize(1200, 760)) w.Resize(fyne.NewSize(1280, 800))
ui := &desktopUI{ ui := &desktopUI{
controller: controller, controller: controller,
app: a, window: w,
window: w, stopRefresh: make(chan struct{}),
stopRefresh: make(chan struct{}), pathEntry: widget.NewEntry(),
pathEntry: widget.NewEntry(), downloadDirEntry: widget.NewEntry(),
downloadDirEntry: widget.NewEntry(), progress: widget.NewProgressBar(),
progress: widget.NewProgressBar(), phaseLabel: widget.NewLabel("Phase: Idle"),
phaseLabel: widget.NewLabel("Phase: idle"), phaseMetricLabel: widget.NewLabel("Idle"),
phaseMetricLabel: widget.NewLabel("idle"), statusSummaryLabel: widget.NewLabel("0.0% | 0 B / - | 0 B/s"),
messageLabel: widget.NewLabel(""), messageLabel: widget.NewLabel("Ready"),
errorLabel: widget.NewLabel(""), errorLabel: widget.NewLabel(""),
nameLabel: widget.NewLabel("-"), nameLabel: widget.NewLabel("-"),
sizeLabel: widget.NewLabel("-"), sizeLabel: widget.NewLabel("-"),
pieceLabel: widget.NewLabel("-"), pieceLabel: widget.NewLabel("-"),
pieceCountLabel: widget.NewLabel("-"), pieceCountLabel: widget.NewLabel("-"),
downloadedLabel: widget.NewLabel("0 / 0"), downloadedLabel: widget.NewLabel("0 B / -"),
outputLabel: widget.NewLabel("-"), outputLabel: widget.NewLabel("-"),
speedLabel: widget.NewLabel("0 B/s"), speedLabel: widget.NewLabel("0 B/s"),
peerCountLabel: widget.NewLabel("0"), peerCountLabel: widget.NewLabel("0"),
} }
ui.pathEntry.SetPlaceHolder("/path/to/file.torrent") ui.pathEntry.SetPlaceHolder("/path/to/file.torrent")
ui.downloadDirEntry.SetPlaceHolder("/path/to/download/folder") ui.downloadDirEntry.SetPlaceHolder("/path/to/download/folder")
ui.messageLabel.Wrapping = fyne.TextWrapWord
ui.errorLabel.Wrapping = fyne.TextWrapWord
ui.outputLabel.Wrapping = fyne.TextWrapWord
ui.nameLabel.Wrapping = fyne.TextWrapWord
if home, err := os.UserHomeDir(); err == nil { if home, err := os.UserHomeDir(); err == nil {
ui.downloadDirEntry.SetText(home) ui.downloadDirEntry.SetText(home)
} }
browseButton := widget.NewButton("Browse Torrent", ui.onBrowse) ui.browseButton = widget.NewButton("Browse Torrent", ui.onBrowse)
browseOutputButton := widget.NewButton("Browse Folder", ui.onBrowseOutputFolder) ui.browseOutputButton = widget.NewButton("Browse Folder", ui.onBrowseOutputFolder)
loadButton := widget.NewButton("Load Torrent", ui.onLoad) ui.startButton = widget.NewButton("Start Download", ui.onLoad)
torrentRow := container.NewBorder(nil, nil, nil, browseButton, ui.pathEntry) ui.stopButton = widget.NewButton("Stop", ui.onStop)
outputRow := container.NewBorder(nil, nil, nil, container.NewHBox(browseOutputButton, loadButton), ui.downloadDirEntry) ui.stopButton.Disable()
topRows := container.NewVBox(torrentRow, outputRow)
metaGrid := container.NewGridWithColumns(4, torrentRow := container.NewBorder(nil, nil, nil, ui.browseButton, ui.pathEntry)
actions := container.NewHBox(ui.browseOutputButton, ui.startButton, ui.stopButton)
outputRow := container.NewBorder(nil, nil, nil, actions, ui.downloadDirEntry)
inputCard := widget.NewCard(
"Input",
"Choose .torrent source and download output folder",
container.NewVBox(torrentRow, outputRow),
)
metaGrid := container.NewGridWithColumns(3,
wrapMetric("Name", ui.nameLabel), wrapMetric("Name", ui.nameLabel),
wrapMetric("Size (bytes)", ui.sizeLabel), wrapMetric("Size", ui.sizeLabel),
wrapMetric("Piece length", ui.pieceLabel), wrapMetric("Piece Length", ui.pieceLabel),
wrapMetric("Completed pieces", ui.pieceCountLabel), wrapMetric("Completed Pieces", ui.pieceCountLabel),
wrapMetric("Downloaded", ui.downloadedLabel), wrapMetric("Transferred", ui.downloadedLabel),
wrapMetric("Speed", ui.speedLabel), wrapMetric("Speed", ui.speedLabel),
wrapMetric("Output", ui.outputLabel), wrapMetric("Output", ui.outputLabel),
wrapMetric("Discovered peers", ui.peerCountLabel), wrapMetric("Discovered Peers", ui.peerCountLabel),
wrapMetric("Phase", ui.phaseMetricLabel), wrapMetric("Phase", ui.phaseMetricLabel),
) )
detailsCard := widget.NewCard("Details", "", metaGrid)
ui.trackersTable = ui.newTrackersTable() ui.trackersTable = ui.newTrackersTable()
ui.peersTable = ui.newPeersTable() ui.peersTable = ui.newPeersTable()
@ -120,33 +140,28 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI {
container.NewTabItem("Peers", container.NewMax(ui.peersTable)), container.NewTabItem("Peers", container.NewMax(ui.peersTable)),
container.NewTabItem("Files", container.NewMax(ui.filesTable)), container.NewTabItem("Files", container.NewMax(ui.filesTable)),
) )
tabs.SetTabLocation(container.TabLocationTop)
statusColumn := container.NewVBox( statusColumn := container.NewVBox(
ui.phaseLabel, ui.phaseLabel,
ui.progress, ui.progress,
ui.statusSummaryLabel,
ui.messageLabel, ui.messageLabel,
ui.errorLabel, ui.errorLabel,
) )
statusCard := widget.NewCard("Transfer Status", "", statusColumn)
content := container.NewBorder( header := container.NewVBox(inputCard, statusCard, detailsCard)
topRows, content := container.NewBorder(header, nil, nil, nil, tabs)
nil,
nil,
nil,
container.NewVBox(
statusColumn,
layout.NewSpacer(),
metaGrid,
layout.NewSpacer(),
tabs,
),
)
w.SetContent(content) w.SetContent(content)
w.SetOnClosed(func() { w.SetOnClosed(func() {
close(ui.stopRefresh) close(ui.stopRefresh)
ui.controller.StopTorrent()
}) })
ui.updateFromStatus(ui.controller.Status())
if len(os.Args) > 1 { if len(os.Args) > 1 {
ui.pathEntry.SetText(os.Args[1]) ui.pathEntry.SetText(os.Args[1])
ui.onLoad() ui.onLoad()
@ -173,47 +188,108 @@ func (ui *desktopUI) onBrowse() {
defer reader.Close() defer reader.Close()
ui.pathEntry.SetText(reader.URI().Path()) ui.pathEntry.SetText(reader.URI().Path())
ui.setError("")
}, ui.window) }, ui.window)
fd.SetFilter(filter) fd.SetFilter(filter)
fd.Show() fd.Show()
} }
func (ui *desktopUI) onLoad() { func (ui *desktopUI) onLoad() {
path := strings.TrimSpace(ui.pathEntry.Text) path, downloadRoot, err := ui.validateInputs()
if path == "" {
ui.setError("select a .torrent file first")
return
}
downloadRoot := strings.TrimSpace(ui.downloadDirEntry.Text)
if downloadRoot == "" {
ui.setError("select a download folder first")
return
}
info, err := os.Stat(downloadRoot)
if err != nil { if err != nil {
ui.setError("download folder is not accessible: " + err.Error()) ui.setError(err.Error())
return
}
if !info.IsDir() {
ui.setError("download path must be a folder")
return return
} }
ui.setMessage("Loading torrent...") ui.mu.Lock()
if ui.loading {
ui.mu.Unlock()
return
}
ui.loading = true
ui.inputError = ""
ui.mu.Unlock()
ui.setMessage("Loading metadata and contacting trackers...")
ui.setError("")
ui.applyControlState("loading_metadata")
go func(path, outputRoot string) {
err := ui.controller.StartTorrent(path, outputRoot)
status := ui.controller.Status()
fyne.Do(func() {
ui.mu.Lock()
ui.loading = false
ui.mu.Unlock()
ui.updateFromStatusOnUI(status)
if err != nil {
ui.setMessageOnUI("Failed to load " + filepath.Base(path))
ui.setErrorOnUI(err.Error())
return
}
ui.setMessageOnUI("Loaded " + filepath.Base(path))
})
}(path, downloadRoot)
}
func (ui *desktopUI) onStop() {
ui.mu.Lock()
ui.loading = false
ui.mu.Unlock()
ui.setMessage("Stopping...")
ui.setError("") ui.setError("")
go func() { go func() {
err := ui.controller.StartTorrent(path, downloadRoot) ui.controller.StopTorrent()
status := ui.controller.Status() status := ui.controller.Status()
ui.updateFromStatus(status)
if err != nil { fyne.Do(func() {
ui.setError(err.Error()) ui.updateFromStatusOnUI(status)
} else { if status.Phase == "stopped" || status.Phase == "idle" {
ui.setMessage("Loaded " + filepath.Base(path)) ui.setMessageOnUI("Stopped")
} }
})
}() }()
} }
func (ui *desktopUI) validateInputs() (string, string, error) {
pathRaw := strings.TrimSpace(ui.pathEntry.Text)
if pathRaw == "" {
return "", "", fmt.Errorf("select a .torrent file first")
}
path := filepath.Clean(pathRaw)
info, err := os.Stat(path)
if err != nil {
return "", "", fmt.Errorf("torrent file is not accessible: %w", err)
}
if info.IsDir() {
return "", "", fmt.Errorf("torrent path must point to a file")
}
if ext := strings.ToLower(filepath.Ext(path)); ext != ".torrent" {
return "", "", fmt.Errorf("selected file must have .torrent extension")
}
downloadRootRaw := strings.TrimSpace(ui.downloadDirEntry.Text)
if downloadRootRaw == "" {
return "", "", fmt.Errorf("select a download folder first")
}
downloadRoot := filepath.Clean(downloadRootRaw)
dirInfo, err := os.Stat(downloadRoot)
if err != nil {
return "", "", fmt.Errorf("download folder is not accessible: %w", err)
}
if !dirInfo.IsDir() {
return "", "", fmt.Errorf("download path must be a folder")
}
return path, downloadRoot, nil
}
func (ui *desktopUI) onBrowseOutputFolder() { func (ui *desktopUI) onBrowseOutputFolder() {
fd := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) { fd := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil { if err != nil {
@ -224,12 +300,13 @@ func (ui *desktopUI) onBrowseOutputFolder() {
return return
} }
ui.downloadDirEntry.SetText(uri.Path()) ui.downloadDirEntry.SetText(uri.Path())
ui.setError("")
}, ui.window) }, ui.window)
fd.Show() fd.Show()
} }
func (ui *desktopUI) refreshLoop() { func (ui *desktopUI) refreshLoop() {
ticker := time.NewTicker(1200 * time.Millisecond) ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
@ -244,73 +321,130 @@ func (ui *desktopUI) refreshLoop() {
func (ui *desktopUI) updateFromStatus(status torrent.Status) { func (ui *desktopUI) updateFromStatus(status torrent.Status) {
fyne.Do(func() { fyne.Do(func() {
ui.progress.SetValue(status.Progress) ui.updateFromStatusOnUI(status)
ui.phaseLabel.SetText("Phase: " + status.Phase)
ui.phaseMetricLabel.SetText(status.Phase)
ui.nameLabel.SetText(status.Name)
ui.sizeLabel.SetText(strconv.Itoa(status.Length))
ui.pieceLabel.SetText(strconv.Itoa(status.PieceLength))
if status.PieceCount > 0 {
ui.pieceCountLabel.SetText(fmt.Sprintf("%d / %d", status.CompletedPieces, status.PieceCount))
} else {
ui.pieceCountLabel.SetText("-")
}
ui.downloadedLabel.SetText(fmt.Sprintf("%d / %d", status.DownloadedBytes, status.TotalBytes))
ui.speedLabel.SetText(formatBytesRate(status.DownloadSpeed))
ui.outputLabel.SetText(status.OutputPath)
ui.peerCountLabel.SetText(strconv.Itoa(status.PeerCount))
ui.mu.Lock()
ui.trackers = append(ui.trackers[:0], status.Trackers...)
ui.peers = append(ui.peers[:0], status.Peers...)
ui.files = append(ui.files[:0], status.Files...)
ui.mu.Unlock()
ui.trackersTable.Refresh()
ui.peersTable.Refresh()
ui.filesTable.Refresh()
if status.LastError != "" {
ui.errorLabel.SetText(status.LastError)
} else {
ui.errorLabel.SetText("")
}
}) })
} }
func formatBytesRate(bytesPerSec float64) string { func (ui *desktopUI) updateFromStatusOnUI(status torrent.Status) {
if bytesPerSec < 0 { progress := clampProgress(status.Progress)
bytesPerSec = 0 phaseText := formatPhase(status.Phase)
ui.progress.SetValue(progress)
ui.phaseLabel.SetText("Phase: " + phaseText)
ui.phaseMetricLabel.SetText(phaseText)
ui.statusSummaryLabel.SetText(formatProgressSummary(status))
if status.Name == "" {
ui.nameLabel.SetText("-")
} else {
ui.nameLabel.SetText(status.Name)
} }
units := []string{"B/s", "KB/s", "MB/s", "GB/s", "TB/s"}
value := bytesPerSec if status.Length > 0 {
unit := 0 ui.sizeLabel.SetText(formatBytes(int64(status.Length)))
for value >= 1024 && unit < len(units)-1 { } else {
value /= 1024 ui.sizeLabel.SetText("-")
unit++
} }
if unit == 0 {
return fmt.Sprintf("%.0f %s", value, units[unit]) if status.PieceLength > 0 {
ui.pieceLabel.SetText(formatBytes(int64(status.PieceLength)))
} else {
ui.pieceLabel.SetText("-")
} }
return fmt.Sprintf("%.2f %s", value, units[unit])
if status.PieceCount > 0 {
pieceProgress := float64(status.CompletedPieces) / float64(status.PieceCount)
ui.pieceCountLabel.SetText(fmt.Sprintf("%d / %d (%s)", status.CompletedPieces, status.PieceCount, formatPercent(pieceProgress)))
} else {
ui.pieceCountLabel.SetText("-")
}
ui.downloadedLabel.SetText(formatTransferred(status.DownloadedBytes, status.TotalBytes))
ui.speedLabel.SetText(formatBytesRate(status.DownloadSpeed))
if status.OutputPath == "" {
ui.outputLabel.SetText("-")
} else {
ui.outputLabel.SetText(status.OutputPath)
}
ui.peerCountLabel.SetText(strconv.Itoa(status.PeerCount))
ui.mu.Lock()
ui.trackers = append(ui.trackers[:0], status.Trackers...)
ui.peers = append(ui.peers[:0], status.Peers...)
ui.files = append(ui.files[:0], status.Files...)
manualErr := ui.inputError
ui.mu.Unlock()
ui.trackersTable.Refresh()
ui.peersTable.Refresh()
ui.filesTable.Refresh()
switch {
case status.LastError != "":
ui.errorLabel.SetText("Error: " + status.LastError)
case manualErr != "":
ui.errorLabel.SetText("Error: " + manualErr)
default:
ui.errorLabel.SetText("")
}
ui.applyControlState(status.Phase)
}
func (ui *desktopUI) applyControlState(phase string) {
ui.mu.RLock()
loading := ui.loading
ui.mu.RUnlock()
busy := loading || isBusyPhase(phase)
if busy {
ui.startButton.Disable()
ui.stopButton.Enable()
ui.pathEntry.Disable()
ui.downloadDirEntry.Disable()
ui.browseButton.Disable()
ui.browseOutputButton.Disable()
return
}
ui.startButton.Enable()
ui.stopButton.Disable()
ui.pathEntry.Enable()
ui.downloadDirEntry.Enable()
ui.browseButton.Enable()
ui.browseOutputButton.Enable()
} }
func (ui *desktopUI) setMessage(msg string) { func (ui *desktopUI) setMessage(msg string) {
fyne.Do(func() { fyne.Do(func() {
ui.messageLabel.SetText(msg) ui.setMessageOnUI(msg)
}) })
} }
func (ui *desktopUI) setMessageOnUI(msg string) {
ui.messageLabel.SetText(strings.TrimSpace(msg))
}
func (ui *desktopUI) setError(msg string) { func (ui *desktopUI) setError(msg string) {
fyne.Do(func() { fyne.Do(func() {
if msg == "" { ui.setErrorOnUI(msg)
ui.errorLabel.SetText("")
return
}
ui.errorLabel.SetText("Error: " + msg)
}) })
} }
func (ui *desktopUI) setErrorOnUI(msg string) {
msg = strings.TrimSpace(msg)
ui.mu.Lock()
ui.inputError = msg
ui.mu.Unlock()
if msg == "" {
ui.errorLabel.SetText("")
return
}
ui.errorLabel.SetText("Error: " + msg)
}
func (ui *desktopUI) newTrackersTable() *widget.Table { func (ui *desktopUI) newTrackersTable() *widget.Table {
table := widget.NewTable( table := widget.NewTable(
func() (int, int) { func() (int, int) {
@ -344,7 +478,7 @@ func (ui *desktopUI) newTrackersTable() *widget.Table {
case 0: case 0:
label.SetText(tr.URL) label.SetText(tr.URL)
case 1: case 1:
label.SetText(tr.State) label.SetText(formatPhase(tr.State))
case 2: case 2:
label.SetText(strconv.Itoa(tr.PeerCount)) label.SetText(strconv.Itoa(tr.PeerCount))
case 3: case 3:
@ -352,9 +486,9 @@ func (ui *desktopUI) newTrackersTable() *widget.Table {
} }
}, },
) )
table.SetColumnWidth(0, 420) table.SetColumnWidth(0, 440)
table.SetColumnWidth(1, 90) table.SetColumnWidth(1, 110)
table.SetColumnWidth(2, 70) table.SetColumnWidth(2, 80)
table.SetColumnWidth(3, 420) table.SetColumnWidth(3, 420)
return table return table
} }
@ -382,7 +516,7 @@ func (ui *desktopUI) newPeersTable() *widget.Table {
case 3: case 3:
label.SetText("Pieces") label.SetText("Pieces")
case 4: case 4:
label.SetText("Source tracker") label.SetText("Source Tracker")
case 5: case 5:
label.SetText("Error") label.SetText("Error")
} }
@ -398,7 +532,7 @@ func (ui *desktopUI) newPeersTable() *widget.Table {
case 1: case 1:
label.SetText(strconv.Itoa(int(peer.Port))) label.SetText(strconv.Itoa(int(peer.Port)))
case 2: case 2:
label.SetText(peer.State) label.SetText(formatPhase(peer.State))
case 3: case 3:
label.SetText(strconv.Itoa(peer.DownloadedPieces)) label.SetText(strconv.Itoa(peer.DownloadedPieces))
case 4: case 4:
@ -408,12 +542,12 @@ func (ui *desktopUI) newPeersTable() *widget.Table {
} }
}, },
) )
table.SetColumnWidth(0, 170) table.SetColumnWidth(0, 190)
table.SetColumnWidth(1, 80) table.SetColumnWidth(1, 80)
table.SetColumnWidth(2, 100) table.SetColumnWidth(2, 120)
table.SetColumnWidth(3, 70) table.SetColumnWidth(3, 90)
table.SetColumnWidth(4, 310) table.SetColumnWidth(4, 320)
table.SetColumnWidth(5, 440) table.SetColumnWidth(5, 400)
return table return table
} }
@ -446,11 +580,100 @@ func (ui *desktopUI) newFilesTable() *widget.Table {
case 0: case 0:
label.SetText(file.Path) label.SetText(file.Path)
case 1: case 1:
label.SetText(fmt.Sprintf("%d", file.Length)) label.SetText(formatBytes(int64(file.Length)))
} }
}, },
) )
table.SetColumnWidth(0, 780) table.SetColumnWidth(0, 820)
table.SetColumnWidth(1, 120) table.SetColumnWidth(1, 140)
return table return table
} }
func formatBytesRate(bytesPerSec float64) string {
return formatByteValue(bytesPerSec, true)
}
func formatBytes(bytes int64) string {
return formatByteValue(float64(bytes), false)
}
func formatByteValue(value float64, perSecond bool) string {
if value < 0 {
value = 0
}
units := []string{"B", "KB", "MB", "GB", "TB"}
unit := 0
for value >= 1024 && unit < len(units)-1 {
value /= 1024
unit++
}
suffix := ""
if perSecond {
suffix = "/s"
}
if unit == 0 {
return fmt.Sprintf("%.0f %s%s", value, units[unit], suffix)
}
return fmt.Sprintf("%.2f %s%s", value, units[unit], suffix)
}
func formatTransferred(done, total int64) string {
if total <= 0 {
return fmt.Sprintf("%s / -", formatBytes(done))
}
pct := float64(done) / float64(total)
return fmt.Sprintf("%s / %s (%s)", formatBytes(done), formatBytes(total), formatPercent(pct))
}
func formatPercent(progress float64) string {
progress = clampProgress(progress)
return fmt.Sprintf("%.1f%%", progress*100)
}
func formatProgressSummary(status torrent.Status) string {
return fmt.Sprintf(
"%s | %s | %s",
formatPercent(status.Progress),
formatTransferred(status.DownloadedBytes, status.TotalBytes),
formatBytesRate(status.DownloadSpeed),
)
}
func formatPhase(phase string) string {
phase = strings.TrimSpace(phase)
if phase == "" {
return "Idle"
}
parts := strings.Split(phase, "_")
for i, part := range parts {
if part == "" {
continue
}
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
return strings.Join(parts, " ")
}
func clampProgress(progress float64) float64 {
if progress < 0 {
return 0
}
if progress > 1 {
return 1
}
return progress
}
func isBusyPhase(phase string) bool {
switch phase {
case "loading_metadata", "querying_trackers", "ready", "preparing_download", "downloading", "writing_files":
return true
default:
return false
}
}