Ztorrent/ui/window.go
itexpert228 bba337ee60 govno
2026-03-06 14:47:54 +03:00

456 lines
11 KiB
Go

package ui
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"fyne.io/fyne/v2"
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"
"github.com/veggiedefender/torrent-client/internal/torrent"
"github.com/veggiedefender/torrent-client/internal/torrentfile"
)
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
trackersTable *widget.Table
peersTable *widget.Table
filesTable *widget.Table
stopRefresh chan struct{}
}
func Start(controller *appcore.Controller) {
desktop := newDesktopUI(controller)
desktop.window.ShowAndRun()
}
func newDesktopUI(controller *appcore.Controller) *desktopUI {
a := fyneapp.NewWithID("github.com/veggiedefender/ztorrent")
w := a.NewWindow("Ztorrent")
w.Resize(fyne.NewSize(1200, 760))
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"),
}
ui.pathEntry.SetPlaceHolder("/path/to/file.torrent")
ui.downloadDirEntry.SetPlaceHolder("/path/to/download/folder")
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)
metaGrid := container.NewGridWithColumns(4,
wrapMetric("Name", ui.nameLabel),
wrapMetric("Size (bytes)", ui.sizeLabel),
wrapMetric("Piece length", ui.pieceLabel),
wrapMetric("Completed pieces", ui.pieceCountLabel),
wrapMetric("Downloaded", ui.downloadedLabel),
wrapMetric("Speed", ui.speedLabel),
wrapMetric("Output", ui.outputLabel),
wrapMetric("Discovered peers", ui.peerCountLabel),
wrapMetric("Phase", ui.phaseMetricLabel),
)
ui.trackersTable = ui.newTrackersTable()
ui.peersTable = ui.newPeersTable()
ui.filesTable = ui.newFilesTable()
tabs := container.NewAppTabs(
container.NewTabItem("Trackers", container.NewMax(ui.trackersTable)),
container.NewTabItem("Peers", container.NewMax(ui.peersTable)),
container.NewTabItem("Files", container.NewMax(ui.filesTable)),
)
statusColumn := container.NewVBox(
ui.phaseLabel,
ui.progress,
ui.messageLabel,
ui.errorLabel,
)
content := container.NewBorder(
topRows,
nil,
nil,
nil,
container.NewVBox(
statusColumn,
layout.NewSpacer(),
metaGrid,
layout.NewSpacer(),
tabs,
),
)
w.SetContent(content)
w.SetOnClosed(func() {
close(ui.stopRefresh)
})
if len(os.Args) > 1 {
ui.pathEntry.SetText(os.Args[1])
ui.onLoad()
}
go ui.refreshLoop()
return ui
}
func wrapMetric(name string, value fyne.CanvasObject) fyne.CanvasObject {
return container.NewVBox(widget.NewLabel(name), value)
}
func (ui *desktopUI) onBrowse() {
filter := storage.NewExtensionFileFilter([]string{".torrent"})
fd := dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil {
ui.setError(err.Error())
return
}
if reader == nil {
return
}
defer reader.Close()
ui.pathEntry.SetText(reader.URI().Path())
}, 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)
if err != nil {
ui.setError("download folder is not accessible: " + err.Error())
return
}
if !info.IsDir() {
ui.setError("download path must be a folder")
return
}
ui.setMessage("Loading torrent...")
ui.setError("")
go func() {
err := ui.controller.StartTorrent(path, downloadRoot)
status := ui.controller.Status()
ui.updateFromStatus(status)
if err != nil {
ui.setError(err.Error())
} else {
ui.setMessage("Loaded " + filepath.Base(path))
}
}()
}
func (ui *desktopUI) onBrowseOutputFolder() {
fd := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil {
ui.setError(err.Error())
return
}
if uri == nil {
return
}
ui.downloadDirEntry.SetText(uri.Path())
}, ui.window)
fd.Show()
}
func (ui *desktopUI) refreshLoop() {
ticker := time.NewTicker(1200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ui.stopRefresh:
return
case <-ticker.C:
ui.updateFromStatus(ui.controller.Status())
}
}
}
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("")
}
})
}
func formatBytesRate(bytesPerSec float64) string {
if bytesPerSec < 0 {
bytesPerSec = 0
}
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])
}
func (ui *desktopUI) setMessage(msg string) {
fyne.Do(func() {
ui.messageLabel.SetText(msg)
})
}
func (ui *desktopUI) setError(msg string) {
fyne.Do(func() {
if msg == "" {
ui.errorLabel.SetText("")
return
}
ui.errorLabel.SetText("Error: " + msg)
})
}
func (ui *desktopUI) newTrackersTable() *widget.Table {
table := widget.NewTable(
func() (int, int) {
ui.mu.RLock()
defer ui.mu.RUnlock()
return len(ui.trackers) + 1, 4
},
func() fyne.CanvasObject {
return widget.NewLabel("")
},
func(id widget.TableCellID, obj fyne.CanvasObject) {
label := obj.(*widget.Label)
if id.Row == 0 {
switch id.Col {
case 0:
label.SetText("Tracker")
case 1:
label.SetText("State")
case 2:
label.SetText("Peers")
case 3:
label.SetText("Error")
}
return
}
ui.mu.RLock()
defer ui.mu.RUnlock()
tr := ui.trackers[id.Row-1]
switch id.Col {
case 0:
label.SetText(tr.URL)
case 1:
label.SetText(tr.State)
case 2:
label.SetText(strconv.Itoa(tr.PeerCount))
case 3:
label.SetText(tr.Error)
}
},
)
table.SetColumnWidth(0, 420)
table.SetColumnWidth(1, 90)
table.SetColumnWidth(2, 70)
table.SetColumnWidth(3, 420)
return table
}
func (ui *desktopUI) newPeersTable() *widget.Table {
table := widget.NewTable(
func() (int, int) {
ui.mu.RLock()
defer ui.mu.RUnlock()
return len(ui.peers) + 1, 6
},
func() fyne.CanvasObject {
return widget.NewLabel("")
},
func(id widget.TableCellID, obj fyne.CanvasObject) {
label := obj.(*widget.Label)
if id.Row == 0 {
switch id.Col {
case 0:
label.SetText("Address")
case 1:
label.SetText("Port")
case 2:
label.SetText("State")
case 3:
label.SetText("Pieces")
case 4:
label.SetText("Source tracker")
case 5:
label.SetText("Error")
}
return
}
ui.mu.RLock()
defer ui.mu.RUnlock()
peer := ui.peers[id.Row-1]
switch id.Col {
case 0:
label.SetText(peer.Address)
case 1:
label.SetText(strconv.Itoa(int(peer.Port)))
case 2:
label.SetText(peer.State)
case 3:
label.SetText(strconv.Itoa(peer.DownloadedPieces))
case 4:
label.SetText(peer.Source)
case 5:
label.SetText(peer.Error)
}
},
)
table.SetColumnWidth(0, 170)
table.SetColumnWidth(1, 80)
table.SetColumnWidth(2, 100)
table.SetColumnWidth(3, 70)
table.SetColumnWidth(4, 310)
table.SetColumnWidth(5, 440)
return table
}
func (ui *desktopUI) newFilesTable() *widget.Table {
table := widget.NewTable(
func() (int, int) {
ui.mu.RLock()
defer ui.mu.RUnlock()
return len(ui.files) + 1, 2
},
func() fyne.CanvasObject {
return widget.NewLabel("")
},
func(id widget.TableCellID, obj fyne.CanvasObject) {
label := obj.(*widget.Label)
if id.Row == 0 {
switch id.Col {
case 0:
label.SetText("File")
case 1:
label.SetText("Size")
}
return
}
ui.mu.RLock()
defer ui.mu.RUnlock()
file := ui.files[id.Row-1]
switch id.Col {
case 0:
label.SetText(file.Path)
case 1:
label.SetText(fmt.Sprintf("%d", file.Length))
}
},
)
table.SetColumnWidth(0, 780)
table.SetColumnWidth(1, 120)
return table
}