This commit is contained in:
itexpert228 2026-03-07 19:40:49 +03:00
parent 5eae46041a
commit bd5ebf2fd7
No known key found for this signature in database

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,14 +22,20 @@ import (
type desktopUI struct {
controller *appcore.Controller
app fyne.App
window fyne.Window
pathEntry *widget.Entry
downloadDirEntry *widget.Entry
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
@ -46,6 +51,8 @@ type desktopUI struct {
trackers []torrent.TrackerStatus
peers []torrent.PeerStatus
files []torrentfile.File
inputError string
loading bool
trackersTable *widget.Table
peersTable *widget.Table
@ -61,26 +68,26 @@ 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(""),
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 / 0"),
downloadedLabel: widget.NewLabel("0 B / -"),
outputLabel: widget.NewLabel("-"),
speedLabel: widget.NewLabel("0 B/s"),
peerCountLabel: widget.NewLabel("0"),
@ -88,28 +95,41 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI {
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,71 +321,128 @@ 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.updateFromStatusOnUI(status)
})
}
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)
ui.sizeLabel.SetText(strconv.Itoa(status.Length))
ui.pieceLabel.SetText(strconv.Itoa(status.PieceLength))
}
if status.Length > 0 {
ui.sizeLabel.SetText(formatBytes(int64(status.Length)))
} else {
ui.sizeLabel.SetText("-")
}
if status.PieceLength > 0 {
ui.pieceLabel.SetText(formatBytes(int64(status.PieceLength)))
} else {
ui.pieceLabel.SetText("-")
}
if status.PieceCount > 0 {
ui.pieceCountLabel.SetText(fmt.Sprintf("%d / %d", status.CompletedPieces, status.PieceCount))
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(fmt.Sprintf("%d / %d", status.DownloadedBytes, status.TotalBytes))
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()
if status.LastError != "" {
ui.errorLabel.SetText(status.LastError)
} else {
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 formatBytesRate(bytesPerSec float64) string {
if bytesPerSec < 0 {
bytesPerSec = 0
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
}
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 unit == 0 {
return fmt.Sprintf("%.0f %s", value, units[unit])
}
return fmt.Sprintf("%.2f %s", value, units[unit])
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() {
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 {
@ -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
}
}