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"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget"
appcore "github.com/veggiedefender/torrent-client/internal/app"
@ -23,29 +22,37 @@ import (
type desktopUI struct {
controller *appcore.Controller
app fyne.App
window fyne.Window
pathEntry *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
trackers []torrent.TrackerStatus
peers []torrent.PeerStatus
files []torrentfile.File
browseButton *widget.Button
browseOutputButton *widget.Button
startButton *widget.Button
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
peersTable *widget.Table
@ -61,55 +68,68 @@ func Start(controller *appcore.Controller) {
func newDesktopUI(controller *appcore.Controller) *desktopUI {
a := fyneapp.NewWithID("github.com/veggiedefender/ztorrent")
w := a.NewWindow("Ztorrent")
w.Resize(fyne.NewSize(1200, 760))
w := a.NewWindow("Ztorrent Desktop")
w.Resize(fyne.NewSize(1280, 800))
ui := &desktopUI{
controller: controller,
app: a,
window: w,
stopRefresh: make(chan struct{}),
pathEntry: widget.NewEntry(),
downloadDirEntry: widget.NewEntry(),
progress: widget.NewProgressBar(),
phaseLabel: widget.NewLabel("Phase: idle"),
phaseMetricLabel: widget.NewLabel("idle"),
messageLabel: widget.NewLabel(""),
errorLabel: widget.NewLabel(""),
nameLabel: widget.NewLabel("-"),
sizeLabel: widget.NewLabel("-"),
pieceLabel: widget.NewLabel("-"),
pieceCountLabel: widget.NewLabel("-"),
downloadedLabel: widget.NewLabel("0 / 0"),
outputLabel: widget.NewLabel("-"),
speedLabel: widget.NewLabel("0 B/s"),
peerCountLabel: widget.NewLabel("0"),
controller: controller,
window: w,
stopRefresh: make(chan struct{}),
pathEntry: widget.NewEntry(),
downloadDirEntry: widget.NewEntry(),
progress: widget.NewProgressBar(),
phaseLabel: widget.NewLabel("Phase: Idle"),
phaseMetricLabel: widget.NewLabel("Idle"),
statusSummaryLabel: widget.NewLabel("0.0% | 0 B / - | 0 B/s"),
messageLabel: widget.NewLabel("Ready"),
errorLabel: widget.NewLabel(""),
nameLabel: widget.NewLabel("-"),
sizeLabel: widget.NewLabel("-"),
pieceLabel: widget.NewLabel("-"),
pieceCountLabel: widget.NewLabel("-"),
downloadedLabel: widget.NewLabel("0 B / -"),
outputLabel: widget.NewLabel("-"),
speedLabel: widget.NewLabel("0 B/s"),
peerCountLabel: widget.NewLabel("0"),
}
ui.pathEntry.SetPlaceHolder("/path/to/file.torrent")
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 {
ui.downloadDirEntry.SetText(home)
}
browseButton := widget.NewButton("Browse Torrent", ui.onBrowse)
browseOutputButton := widget.NewButton("Browse Folder", ui.onBrowseOutputFolder)
loadButton := widget.NewButton("Load Torrent", ui.onLoad)
torrentRow := container.NewBorder(nil, nil, nil, browseButton, ui.pathEntry)
outputRow := container.NewBorder(nil, nil, nil, container.NewHBox(browseOutputButton, loadButton), ui.downloadDirEntry)
topRows := container.NewVBox(torrentRow, outputRow)
ui.browseButton = widget.NewButton("Browse Torrent", ui.onBrowse)
ui.browseOutputButton = widget.NewButton("Browse Folder", ui.onBrowseOutputFolder)
ui.startButton = widget.NewButton("Start Download", ui.onLoad)
ui.stopButton = widget.NewButton("Stop", ui.onStop)
ui.stopButton.Disable()
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("Size (bytes)", ui.sizeLabel),
wrapMetric("Piece length", ui.pieceLabel),
wrapMetric("Completed pieces", ui.pieceCountLabel),
wrapMetric("Downloaded", ui.downloadedLabel),
wrapMetric("Size", ui.sizeLabel),
wrapMetric("Piece Length", ui.pieceLabel),
wrapMetric("Completed Pieces", ui.pieceCountLabel),
wrapMetric("Transferred", ui.downloadedLabel),
wrapMetric("Speed", ui.speedLabel),
wrapMetric("Output", ui.outputLabel),
wrapMetric("Discovered peers", ui.peerCountLabel),
wrapMetric("Discovered Peers", ui.peerCountLabel),
wrapMetric("Phase", ui.phaseMetricLabel),
)
detailsCard := widget.NewCard("Details", "", metaGrid)
ui.trackersTable = ui.newTrackersTable()
ui.peersTable = ui.newPeersTable()
@ -120,33 +140,28 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI {
container.NewTabItem("Peers", container.NewMax(ui.peersTable)),
container.NewTabItem("Files", container.NewMax(ui.filesTable)),
)
tabs.SetTabLocation(container.TabLocationTop)
statusColumn := container.NewVBox(
ui.phaseLabel,
ui.progress,
ui.statusSummaryLabel,
ui.messageLabel,
ui.errorLabel,
)
statusCard := widget.NewCard("Transfer Status", "", statusColumn)
content := container.NewBorder(
topRows,
nil,
nil,
nil,
container.NewVBox(
statusColumn,
layout.NewSpacer(),
metaGrid,
layout.NewSpacer(),
tabs,
),
)
header := container.NewVBox(inputCard, statusCard, detailsCard)
content := container.NewBorder(header, nil, nil, nil, tabs)
w.SetContent(content)
w.SetOnClosed(func() {
close(ui.stopRefresh)
ui.controller.StopTorrent()
})
ui.updateFromStatus(ui.controller.Status())
if len(os.Args) > 1 {
ui.pathEntry.SetText(os.Args[1])
ui.onLoad()
@ -173,47 +188,108 @@ func (ui *desktopUI) onBrowse() {
defer reader.Close()
ui.pathEntry.SetText(reader.URI().Path())
ui.setError("")
}, ui.window)
fd.SetFilter(filter)
fd.Show()
}
func (ui *desktopUI) onLoad() {
path := strings.TrimSpace(ui.pathEntry.Text)
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)
path, downloadRoot, err := ui.validateInputs()
if err != nil {
ui.setError("download folder is not accessible: " + err.Error())
return
}
if !info.IsDir() {
ui.setError("download path must be a folder")
ui.setError(err.Error())
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("")
go func() {
err := ui.controller.StartTorrent(path, downloadRoot)
ui.controller.StopTorrent()
status := ui.controller.Status()
ui.updateFromStatus(status)
if err != nil {
ui.setError(err.Error())
} else {
ui.setMessage("Loaded " + filepath.Base(path))
}
fyne.Do(func() {
ui.updateFromStatusOnUI(status)
if status.Phase == "stopped" || status.Phase == "idle" {
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() {
fd := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil {
@ -224,12 +300,13 @@ func (ui *desktopUI) onBrowseOutputFolder() {
return
}
ui.downloadDirEntry.SetText(uri.Path())
ui.setError("")
}, ui.window)
fd.Show()
}
func (ui *desktopUI) refreshLoop() {
ticker := time.NewTicker(1200 * time.Millisecond)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
@ -244,73 +321,130 @@ func (ui *desktopUI) refreshLoop() {
func (ui *desktopUI) updateFromStatus(status torrent.Status) {
fyne.Do(func() {
ui.progress.SetValue(status.Progress)
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("")
}
ui.updateFromStatusOnUI(status)
})
}
func formatBytesRate(bytesPerSec float64) string {
if bytesPerSec < 0 {
bytesPerSec = 0
func (ui *desktopUI) updateFromStatusOnUI(status torrent.Status) {
progress := clampProgress(status.Progress)
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
unit := 0
for value >= 1024 && unit < len(units)-1 {
value /= 1024
unit++
if status.Length > 0 {
ui.sizeLabel.SetText(formatBytes(int64(status.Length)))
} else {
ui.sizeLabel.SetText("-")
}
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) {
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) {
fyne.Do(func() {
if msg == "" {
ui.errorLabel.SetText("")
return
}
ui.errorLabel.SetText("Error: " + msg)
ui.setErrorOnUI(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 {
table := widget.NewTable(
func() (int, int) {
@ -344,7 +478,7 @@ func (ui *desktopUI) newTrackersTable() *widget.Table {
case 0:
label.SetText(tr.URL)
case 1:
label.SetText(tr.State)
label.SetText(formatPhase(tr.State))
case 2:
label.SetText(strconv.Itoa(tr.PeerCount))
case 3:
@ -352,9 +486,9 @@ func (ui *desktopUI) newTrackersTable() *widget.Table {
}
},
)
table.SetColumnWidth(0, 420)
table.SetColumnWidth(1, 90)
table.SetColumnWidth(2, 70)
table.SetColumnWidth(0, 440)
table.SetColumnWidth(1, 110)
table.SetColumnWidth(2, 80)
table.SetColumnWidth(3, 420)
return table
}
@ -382,7 +516,7 @@ func (ui *desktopUI) newPeersTable() *widget.Table {
case 3:
label.SetText("Pieces")
case 4:
label.SetText("Source tracker")
label.SetText("Source Tracker")
case 5:
label.SetText("Error")
}
@ -398,7 +532,7 @@ func (ui *desktopUI) newPeersTable() *widget.Table {
case 1:
label.SetText(strconv.Itoa(int(peer.Port)))
case 2:
label.SetText(peer.State)
label.SetText(formatPhase(peer.State))
case 3:
label.SetText(strconv.Itoa(peer.DownloadedPieces))
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(2, 100)
table.SetColumnWidth(3, 70)
table.SetColumnWidth(4, 310)
table.SetColumnWidth(5, 440)
table.SetColumnWidth(2, 120)
table.SetColumnWidth(3, 90)
table.SetColumnWidth(4, 320)
table.SetColumnWidth(5, 400)
return table
}
@ -446,11 +580,100 @@ func (ui *desktopUI) newFilesTable() *widget.Table {
case 0:
label.SetText(file.Path)
case 1:
label.SetText(fmt.Sprintf("%d", file.Length))
label.SetText(formatBytes(int64(file.Length)))
}
},
)
table.SetColumnWidth(0, 780)
table.SetColumnWidth(1, 120)
table.SetColumnWidth(0, 820)
table.SetColumnWidth(1, 140)
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
}
}