228
This commit is contained in:
parent
5eae46041a
commit
bd5ebf2fd7
1 changed files with 376 additions and 153 deletions
529
ui/window.go
529
ui/window.go
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue