Compare commits

...
19 Commits
Author SHA1 Message Date
Levi b329fee107 release: v1.6.6 2024-12-27 19:11:18 +08:00
Levi 3ce95f21fd fix: some torrents can not resovle (#857) 2024-12-27 19:06:17 +08:00
Levi f54ed5df5a feat: batch operation task (#860) 2024-12-27 18:52:23 +08:00
Levi 727b7edb84 feat: file tree view responsive support (#856) 2024-12-27 11:34:56 +08:00
Levi f5891ee03d feat: add batch create tips (#858) 2024-12-27 11:17:17 +08:00
Levi 724276ac69 release: v1.6.5 2024-12-25 22:56:09 +08:00
Levi a3f559c8e5 feat: improve file select tree view (#853) 2024-12-25 22:55:33 +08:00
Levi edd189a8a8 fix: failed to delete a running task (#852) 2024-12-24 22:23:20 +08:00
Levi c970378fd7 fix: http download retries too many times (#849) 2024-12-24 20:28:04 +08:00
Levi 8b61343aef fix: extension task missing fields (#850) 2024-12-23 01:58:29 +08:00
Levi 2cb93a03e2 perf: extension onStart hook will block task create (#844) 2024-12-21 09:58:33 +08:00
Levi 81dfc073a6 release: v1.6.4 2024-12-09 20:30:33 +08:00
Levi 427921ccab perf: write to disk only when progress updated (#839) 2024-12-09 20:29:50 +08:00
Levi f4876c8a0e fix: HTTP fetch crash when modified request extra via extension (#832) 2024-12-08 12:07:08 +08:00
Navy.Wang 35a3625b11 feat: add QNAP platform build (#828) 2024-11-29 13:38:17 +08:00
ilkei dc9b501e35 feat: optimize docker build (#827) 2024-11-27 13:40:28 +08:00
Levi 85fe92a378 release: v1.6.3 2024-11-18 18:54:46 +08:00
Levi 983d9ad09e fix: windows location fix patch not effect (#819) 2024-11-18 18:54:15 +08:00
Levi d9f757aaf0 fix: revert flutter version to v3.16.8 (#821) 2024-11-18 18:31:25 +08:00
60 changed files with 1348 additions and 3516 deletions

No files matched your search

+48 -14
View File
@@ -4,17 +4,17 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
platform: platform:
description: "Build platform" description: 'Build platform'
required: true required: true
default: "all" default: 'all'
test: test:
description: "Test mode" description: 'Test mode'
required: true required: true
default: "false" default: "false"
env: env:
GO_VERSION: "1.23" GO_VERSION: "1.23"
FLUTTER_VERSION: "3.24.3" FLUTTER_VERSION: "3.16.8"
permissions: permissions:
contents: write contents: write
@@ -35,7 +35,7 @@ jobs:
build-windows: build-windows:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }} if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
runs-on: windows-latest runs-on: windows-latest
needs: [get-release] needs: [ get-release ]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
@@ -137,7 +137,7 @@ jobs:
build-macos-arm64-lib: build-macos-arm64-lib:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }} if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
runs-on: macos-latest runs-on: macos-latest
needs: [get-release] needs: [ get-release ]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
@@ -155,7 +155,7 @@ jobs:
build-macos: build-macos:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }} if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
runs-on: macos-13 runs-on: macos-13
needs: [get-release, build-macos-arm64-lib] needs: [ get-release, build-macos-arm64-lib ]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
@@ -214,7 +214,7 @@ jobs:
build-linux: build-linux:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux' }} if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [get-release] needs: [ get-release ]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
@@ -252,7 +252,7 @@ jobs:
build-snap: build-snap:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'snap' }} if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'snap' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [get-release] needs: [ get-release ]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
@@ -357,7 +357,7 @@ jobs:
build-android: build-android:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android' }} if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [get-release] needs: [ get-release ]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
@@ -366,7 +366,7 @@ jobs:
- uses: actions/setup-java@v3 - uses: actions/setup-java@v3
with: with:
distribution: "zulu" distribution: "zulu"
java-version: "17" java-version: "11"
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
@@ -395,7 +395,7 @@ jobs:
build-ios: build-ios:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }} if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
runs-on: macos-latest runs-on: macos-latest
needs: [get-release] needs: [ get-release ]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
@@ -428,7 +428,7 @@ jobs:
build-web: build-web:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'web' }} if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'web' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [get-release] needs: [ get-release ]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
@@ -441,6 +441,7 @@ jobs:
env: env:
VERSION: ${{ needs.get-release.outputs.version }} VERSION: ${{ needs.get-release.outputs.version }}
run: | run: |
VERSION=${VERSION:-$(date +%y%m%d%H%M)} # Use current time as version if not specified
cd ui/flutter cd ui/flutter
flutter build web --web-renderer html flutter build web --web-renderer html
cd ../../ cd ../../
@@ -468,6 +469,39 @@ jobs:
|| true || true
done done
done done
sudo apt update -y
sudo apt install -y pv python2 bsdmainutils
wget -O qdk2_0.32.bionic_amd64.deb "https://github.com/qnap-dev/qdk2/releases/download/v0.32/qdk2_0.32.bionic_amd64.deb"
dpkg -X qdk2_0.32.bionic_amd64.deb qdk2 # Direct installs will fail due to missing dependencies!
[[ -d qdk2 ]] || exit 1
export PATH=$(pwd)/qdk2/usr/bin:$(pwd)/qdk2/usr/share/qdk2/QDK/bin:${PATH}
wget -O Gopeed.template.tar.gz "https://github.com/GopeedLab/QpkgBuild/raw/refs/heads/master/template/Gopeed.template.tar.gz"
tar -zxf Gopeed.template.tar.gz
[[ -d Gopeed ]] || exit 1
goos=linux
goarch_arr=(amd64 arm64)
for goarch in "${goarch_arr[@]}"; do
qarch=x86_64
[[ "${goarch}" == "arm64" ]] && qarch=arm_64
name=gopeed-web-v${VERSION}-${goos}-${goarch}
cp dist/${name}/${name}/* Gopeed/${qarch}/
done
cd Gopeed
sed -i -e 's/__QPKG_VER__/${VERSION}/g' qpkg.cfg
qbuild || exit 1
for f in build/*; do echo ">>> build=[${f}]"; done
goos=qnap
goarch_arr=(amd64 arm64)
for goarch in "${goarch_arr[@]}"; do
qarch=x86_64
[[ "${goarch}" == "arm64" ]] && qarch=arm_64
sname=Gopeed_${VERSION}_${qarch}.qpkg
dname=gopeed-${goos}-v${VERSION}-${goarch}.qpkg
echo "sname=${sname}, dname=${dname}"
[[ -f build/${sname} ]] && cp -ra build/${sname} ../dist/zip/${dname}
done
for f in ../dist/zip/*; do echo ">>> dist=[${f}]"; done
cd ..
- name: Upload - name: Upload
uses: shogo82148/actions-upload-release-asset@v1 uses: shogo82148/actions-upload-release-asset@v1
with: with:
@@ -477,7 +511,7 @@ jobs:
build-docker: build-docker:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'docker' }} if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'docker' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [get-release] needs: [ get-release ]
steps: steps:
- name: Remove unnecessary files - name: Remove unnecessary files
run: | run: |
+4 -4
View File
@@ -28,7 +28,7 @@ on:
env: env:
GO_VERSION: "1.23" GO_VERSION: "1.23"
FLUTTER_VERSION: "3.24.3" FLUTTER_VERSION: "3.16.8"
jobs: jobs:
# lint: # lint:
@@ -73,17 +73,17 @@ jobs:
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
- if: runner.os == 'windows-latest' - if: runner.os == 'windows'
run: | run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter cd ui/flutter
flutter build windows flutter build windows
- if: runner.os == 'macos-latest' - if: runner.os == 'macOS'
run: | run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter cd ui/flutter
flutter build macos flutter build macos
- if: runner.os == 'ubuntu-latest' - if: runner.os == 'Linux'
run: | run: |
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev
+2 -3
View File
@@ -8,14 +8,13 @@ RUN CGO_ENABLED=0 go build -tags nosqlite,web \
-ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION -X github.com/GopeedLab/gopeed/pkg/base.InDocker=true" \ -ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION -X github.com/GopeedLab/gopeed/pkg/base.InDocker=true" \
-o dist/gopeed github.com/GopeedLab/gopeed/cmd/web -o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
FROM alpine:3.14.2 FROM alpine:3.18
LABEL maintainer="monkeyWie" LABEL maintainer="monkeyWie"
WORKDIR /app WORKDIR /app
COPY --from=go /app/dist/gopeed ./ COPY --from=go /app/dist/gopeed ./
COPY entrypoint.sh ./entrypoint.sh COPY entrypoint.sh ./entrypoint.sh
RUN apk update && \ RUN apk update && \
apk upgrade --no-cache && \ apk add --no-cache su-exec ; \
apk add --no-cache bash su-exec; \
chmod +x ./entrypoint.sh && \ chmod +x ./entrypoint.sh && \
rm -rf /var/cache/apk/* rm -rf /var/cache/apk/*
VOLUME ["/app/storage"] VOLUME ["/app/storage"]
+2 -2
View File
@@ -115,7 +115,7 @@ This project is divided into two parts, the front end uses `flutter`, the back e
### Environment ### Environment
1. Golang 1.23+ 1. Golang 1.23+
2. Flutter 3.24+ 2. Flutter 3.16+
### Clone ### Clone
@@ -174,7 +174,7 @@ command:
- android - android
```bash ```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter cd ui/flutter
flutter build apk flutter build apk
``` ```
+2 -2
View File
@@ -103,7 +103,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
### 環境 ### 環境
1. Go 言語 1.23+ 1. Go 言語 1.23+
2. Flutter 3.24+ 2. Flutter 3.16+
### クローン ### クローン
@@ -162,7 +162,7 @@ gomobile init
- android - android
```bash ```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter cd ui/flutter
flutter build apk flutter build apk
``` ```
+2 -2
View File
@@ -111,7 +111,7 @@ Dự án này được chia thành hai phần, phần giao diện sử dụng `f
### Environment ### Environment
1. Golang 1.23+ 1. Golang 1.23+
2. Flutter 3.24+ 2. Flutter 3.16+
### Clone ### Clone
@@ -170,7 +170,7 @@ command:
- android - android
```bash ```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter cd ui/flutter
flutter build apk flutter build apk
``` ```
+2 -2
View File
@@ -115,7 +115,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
### 环境要求 ### 环境要求
1. Golang 1.23+ 1. Golang 1.23+
2. Flutter 3.24+ 2. Flutter 3.16+
### 克隆项目 ### 克隆项目
@@ -174,7 +174,7 @@ gomobile init
- android - android
```bash ```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter cd ui/flutter
flutter build apk flutter build apk
``` ```
+3 -3
View File
@@ -86,7 +86,7 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
更多關於安裝的內容請參考[安裝文檔](https://docs.gopeed.com/zh/install.html) 更多關於安裝的內容請參考[安裝文檔](https://docs.gopeed.com/zh/install.html)
### 使用 CLI 安裝 ### 使用CLI安裝
使用`go install`安裝: 使用`go install`安裝:
@@ -111,7 +111,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
### 開發環境 ### 開發環境
1. Golang 1.23+ 1. Golang 1.23+
2. Flutter 3.24+ 2. Flutter 3.16+
### 克隆項目 ### 克隆項目
@@ -170,7 +170,7 @@ gomobile init
- android - android
```bash ```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter cd ui/flutter
flutter build apk flutter build apk
``` ```
+7 -4
View File
@@ -1,12 +1,15 @@
version: '3'
services: services:
gopeed: gopeed:
container_name: gopeed container_name: gopeed
ports: ports:
- "9999:9999" # HTTP port (host:container) - 9999:9999 # HTTP port (host:container)
image: liwei2633/gopeed environment:
- PUID=0
- PGID=0
- UMASK=022
volumes: volumes:
- ~/gopeed/Downloads:/app/Downloads # mount download path - ~/gopeed/Downloads:/app/Downloads # mount download path
#- ~/gopeed/storage:/app/storage # if you need to mount storage path, uncomment this line #- ~/gopeed/storage:/app/storage # if you need to mount storage path, uncomment this line
restart: unless-stopped restart: unless-stopped
image: liwei2633/gopeed
# command: -u Username -p Password # optional authentication
+18 -6
View File
@@ -11,7 +11,9 @@ import (
"github.com/GopeedLab/gopeed/pkg/util" "github.com/GopeedLab/gopeed/pkg/util"
"github.com/anacrolix/torrent" "github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo" "github.com/anacrolix/torrent/metainfo"
"io"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@@ -359,17 +361,27 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
schema := util.ParseSchema(req.URL) schema := util.ParseSchema(req.URL)
if schema == "MAGNET" { if schema == "MAGNET" {
f.torrent, err = client.AddMagnet(req.URL) f.torrent, err = client.AddMagnet(req.URL)
} else if schema == "DATA" { } else {
var reader io.Reader
if schema == "DATA" {
_, data := util.ParseDataUri(req.URL) _, data := util.ParseDataUri(req.URL)
buf := bytes.NewBuffer(data) reader = bytes.NewBuffer(data)
var metaInfo *metainfo.MetaInfo } else {
metaInfo, err = metainfo.Load(buf) reader, err = os.Open(req.URL)
if err != nil { if err != nil {
return
}
defer reader.(io.Closer).Close()
}
var metaInfo *metainfo.MetaInfo
metaInfo, err = metainfo.Load(reader)
// Hotfix for https://github.com/anacrolix/torrent/issues/992, ignore "expected EOF" error
// TODO remove this after the issue is fixed
if err != nil && !strings.Contains(err.Error(), "expected EOF") {
return err return err
} }
f.torrent, err = client.AddTorrent(metaInfo) f.torrent, err = client.AddTorrent(metaInfo)
} else {
f.torrent, err = client.AddTorrentFromFile(req.URL)
} }
if err != nil { if err != nil {
return return
+11 -2
View File
@@ -96,7 +96,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
} }
if !reflect.DeepEqual(want, fetcher.Meta().Res) { if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want) t.Errorf("Resolve Single File Resolve() got = %v, want %v", fetcher.Meta().Res, want)
} }
}) })
@@ -136,7 +136,16 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
Hash: "ccbc92b0cd8deec16a2ef4be242a8c9243b1cedb", Hash: "ccbc92b0cd8deec16a2ef4be242a8c9243b1cedb",
} }
if !reflect.DeepEqual(want, fetcher.Meta().Res) { if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want) t.Errorf("Resolve Multi Files Resolve() got = %v, want %v", fetcher.Meta().Res, want)
}
})
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/test.unclean.torrent",
})
if err != nil {
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
} }
}) })
Binary file not shown.
+40 -26
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"mime" "mime"
"net"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/url" "net/url"
@@ -25,6 +26,9 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
) )
const connectTimeout = 15 * time.Second
const readTimeout = 15 * time.Second
type RequestError struct { type RequestError struct {
Code int Code int
Msg string Msg string
@@ -103,7 +107,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
if base.HttpCodePartialContent == httpResp.StatusCode || (base.HttpCodeOK == httpResp.StatusCode && httpResp.Header.Get(base.HttpHeaderAcceptRanges) == base.HttpHeaderBytes && strings.HasPrefix(httpResp.Header.Get(base.HttpHeaderContentRange), base.HttpHeaderBytes)) { if base.HttpCodePartialContent == httpResp.StatusCode || (base.HttpCodeOK == httpResp.StatusCode && httpResp.Header.Get(base.HttpHeaderAcceptRanges) == base.HttpHeaderBytes && strings.HasPrefix(httpResp.Header.Get(base.HttpHeaderContentRange), base.HttpHeaderBytes)) {
// response 206 status code, support breakpoint continuation // response 206 status code, support breakpoint continuation
res.Range = true res.Range = true
// 解析资源大小: bytes 0-1000/1001 => 1001 // parse content length from Content-Range header, eg: bytes 0-1000/1001
contentTotal := path.Base(httpResp.Header.Get(base.HttpHeaderContentRange)) contentTotal := path.Base(httpResp.Header.Get(base.HttpHeaderContentRange))
if contentTotal != "" { if contentTotal != "" {
parse, err := strconv.ParseInt(contentTotal, 10, 64) parse, err := strconv.ParseInt(contentTotal, 10, 64)
@@ -146,7 +150,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
file.Name = filename file.Name = filename
} }
} }
// Get file filePath by URL // get file filePath by URL
if file.Name == "" { if file.Name == "" {
file.Name = path.Base(httpReq.URL.Path) file.Name = path.Base(httpReq.URL.Path)
} }
@@ -162,9 +166,6 @@ func (f *Fetcher) Resolve(req *base.Request) error {
func (f *Fetcher) Create(opts *base.Options) error { func (f *Fetcher) Create(opts *base.Options) error {
f.meta.Opts = opts f.meta.Opts = opts
if err := base.ParseReqExtra[fhttp.ReqExtra](f.meta.Req); err != nil {
return err
}
if err := base.ParseOptsExtra[fhttp.OptsExtra](f.meta.Opts); err != nil { if err := base.ParseOptsExtra[fhttp.OptsExtra](f.meta.Opts); err != nil {
return err return err
} }
@@ -198,6 +199,12 @@ func (f *Fetcher) Start() (err error) {
if err != nil { if err != nil {
return err return err
} }
// Avoid request extra modified by extension
if err = base.ParseReqExtra[fhttp.ReqExtra](f.meta.Req); err != nil {
return err
}
if f.chunks == nil { if f.chunks == nil {
f.chunks = f.splitChunk() f.chunks = f.splitChunk()
} }
@@ -247,23 +254,42 @@ func (f *Fetcher) Wait() (err error) {
return <-f.doneCh return <-f.doneCh
} }
type fetchResult struct {
err error
}
func (f *Fetcher) fetch() { func (f *Fetcher) fetch() {
var ctx context.Context var ctx context.Context
ctx, f.cancel = context.WithCancel(context.Background()) ctx, f.cancel = context.WithCancel(context.Background())
f.eg, _ = errgroup.WithContext(ctx) f.eg, _ = errgroup.WithContext(ctx)
chunkErrs := make([]error, len(f.chunks))
for i := 0; i < len(f.chunks); i++ { for i := 0; i < len(f.chunks); i++ {
i := i i := i
f.eg.Go(func() error { f.eg.Go(func() error {
return f.fetchChunk(i, ctx) err := f.fetchChunk(i, ctx)
// if canceled, fail fast
if errors.Is(err, context.Canceled) {
return err
}
chunkErrs[i] = err
return nil
}) })
} }
go func() { go func() {
err := f.eg.Wait() err := f.eg.Wait()
// check if canceled // error returned only if canceled, just return
if errors.Is(err, context.Canceled) { if err != nil {
return return
} }
// check all fetch results, if any error, return
for _, chunkErr := range chunkErrs {
if chunkErr != nil {
err = chunkErr
break
}
}
f.file.Close() f.file.Close()
// Update file last modified time // Update file last modified time
if f.config.UseServerCtime && f.meta.Res.Files[0].Ctime != nil { if f.config.UseServerCtime && f.meta.Res.Files[0].Ctime != nil {
@@ -286,25 +312,12 @@ func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) {
for { for {
// if chunk is completed, return // if chunk is completed, return
if f.meta.Res.Range && chunk.Downloaded >= chunk.End-chunk.Begin+1 { if f.meta.Res.Range && chunk.Downloaded >= chunk.End-chunk.Begin+1 {
return return nil
} }
if chunk.retryTimes >= maxRetries { if chunk.retryTimes >= maxRetries {
if !f.meta.Res.Range {
return return
} }
// check if all failed
allFailed := true
for _, c := range f.chunks {
if chunk.Downloaded < chunk.End-chunk.Begin+1 && c.retryTimes < maxRetries {
allFailed = false
break
}
}
if allFailed {
return
}
}
var ( var (
httpReq *http.Request httpReq *http.Request
@@ -330,10 +343,9 @@ func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) {
err = NewRequestError(resp.StatusCode, resp.Status) err = NewRequestError(resp.StatusCode, resp.Status)
return err return err
} }
// Http request success, reset retry times reader := NewTimeoutReader(resp.Body, readTimeout)
chunk.retryTimes = 0
for { for {
n, err := resp.Body.Read(buf) n, err := reader.Read(buf)
if n > 0 { if n > 0 {
_, err := f.file.WriteAt(buf[:n], chunk.Begin+chunk.Downloaded) _, err := f.file.WriteAt(buf[:n], chunk.Begin+chunk.Downloaded)
if err != nil { if err != nil {
@@ -348,7 +360,6 @@ func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) {
return err return err
} }
} }
return nil
}() }()
if err != nil { if err != nil {
// If canceled, do not retry // If canceled, do not retry
@@ -449,6 +460,9 @@ func (f *Fetcher) splitChunk() (chunks []*chunk) {
func (f *Fetcher) buildClient() *http.Client { func (f *Fetcher) buildClient() *http.Client {
transport := &http.Transport{ transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: connectTimeout,
}).DialContext,
Proxy: f.ctl.GetProxy(f.meta.Req.Proxy), Proxy: f.ctl.GetProxy(f.meta.Req.Proxy),
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
InsecureSkipVerify: f.meta.Req.SkipVerifyCert, InsecureSkipVerify: f.meta.Req.SkipVerifyCert,
+8
View File
@@ -132,6 +132,14 @@ func TestFetcher_DownloadLimit(t *testing.T) {
downloadNormal(listener, 8, t) downloadNormal(listener, 8, t)
} }
func TestFetcher_DownloadResponseBodyReadTimeout(t *testing.T) {
listener := test.StartTestLimitServer(16, readTimeout.Milliseconds()+5000)
defer listener.Close()
downloadError(listener, 1, t)
downloadError(listener, 4, t)
}
func TestFetcher_DownloadResume(t *testing.T) { func TestFetcher_DownloadResume(t *testing.T) {
listener := test.StartTestFileServer() listener := test.StartTestFileServer()
defer listener.Close() defer listener.Close()
+40
View File
@@ -0,0 +1,40 @@
package http
import (
"context"
"io"
"time"
)
type TimeoutReader struct {
reader io.Reader
timeout time.Duration
}
func NewTimeoutReader(r io.Reader, timeout time.Duration) *TimeoutReader {
return &TimeoutReader{
reader: r,
timeout: timeout,
}
}
func (tr *TimeoutReader) Read(p []byte) (n int, err error) {
ctx, cancel := context.WithTimeout(context.Background(), tr.timeout)
defer cancel()
done := make(chan struct{})
var readErr error
var bytesRead int
go func() {
bytesRead, readErr = tr.reader.Read(p)
close(done)
}()
select {
case <-done:
return bytesRead, readErr
case <-ctx.Done():
return 0, ctx.Err()
}
}
@@ -0,0 +1,51 @@
package http
import (
"bytes"
"context"
"errors"
"io"
"testing"
"time"
)
func TestTimeoutReader_Read(t *testing.T) {
data := []byte("Hello, World!")
reader := bytes.NewReader(data)
timeoutReader := NewTimeoutReader(reader, 1*time.Second)
buf := make([]byte, len(data))
n, err := timeoutReader.Read(buf)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if n != len(data) {
t.Fatalf("expected to read %d bytes, read %d", len(data), n)
}
if !bytes.Equal(buf, data) {
t.Fatalf("expected %s, got %s", data, buf)
}
}
func TestTimeoutReader_ReadTimeout(t *testing.T) {
reader := &slowReader{delay: 2 * time.Second}
timeoutReader := NewTimeoutReader(reader, 1*time.Second)
buf := make([]byte, 8192)
_, err := timeoutReader.Read(buf)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected %v, got %v", context.DeadlineExceeded, err)
}
}
type slowReader struct {
delay time.Duration
}
func (sr *slowReader) Read(p []byte) (n int, err error) {
time.Sleep(sr.delay)
return 0, io.EOF
}
+29 -18
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
"github.com/armon/go-socks5" "github.com/armon/go-socks5"
@@ -36,7 +37,7 @@ const (
) )
func StartTestFileServer() net.Listener { func StartTestFileServer() net.Listener {
return startTestServer(func() http.Handler { return startTestServer(func(sl *shutdownListener) http.Handler {
return http.FileServer(http.Dir(Dir)) return http.FileServer(http.Dir(Dir))
}) })
} }
@@ -52,7 +53,7 @@ func (s *SlowFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
func StartTestSlowFileServer(delay time.Duration) net.Listener { func StartTestSlowFileServer(delay time.Duration) net.Listener {
return startTestServer(func() http.Handler { return startTestServer(func(sl *shutdownListener) http.Handler {
return &SlowFileServer{ return &SlowFileServer{
delay: delay, delay: delay,
handler: http.FileServer(http.Dir(Dir)), handler: http.FileServer(http.Dir(Dir)),
@@ -61,7 +62,7 @@ func StartTestSlowFileServer(delay time.Duration) net.Listener {
} }
func StartTestCustomServer() net.Listener { func StartTestCustomServer() net.Listener {
return startTestServer(func() http.Handler { return startTestServer(func(sl *shutdownListener) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
file, err := os.Open(BuildFile) file, err := os.Open(BuildFile)
@@ -88,7 +89,7 @@ func StartTestCustomServer() net.Listener {
func StartTestRetryServer() net.Listener { func StartTestRetryServer() net.Listener {
counter := 0 counter := 0
return startTestServer(func() http.Handler { return startTestServer(func(sl *shutdownListener) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
counter++ counter++
@@ -108,7 +109,7 @@ func StartTestRetryServer() net.Listener {
} }
func StartTestPostServer() net.Listener { func StartTestPostServer() net.Listener {
return startTestServer(func() http.Handler { return startTestServer(func(sl *shutdownListener) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
if request.Method == "POST" && request.Header.Get("Authorization") != "" { if request.Method == "POST" && request.Header.Get("Authorization") != "" {
@@ -132,7 +133,7 @@ func StartTestPostServer() net.Listener {
func StartTestErrorServer() net.Listener { func StartTestErrorServer() net.Listener {
counter := 0 counter := 0
return startTestServer(func() http.Handler { return startTestServer(func(sl *shutdownListener) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
counter++ counter++
@@ -149,7 +150,7 @@ func StartTestErrorServer() net.Listener {
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener { func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
var connections atomic.Int32 var connections atomic.Int32
return startTestServer(func() http.Handler { return startTestServer(func(sl *shutdownListener) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
defer func() { defer func() {
@@ -165,12 +166,14 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
if r == "" { if r == "" {
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
writer.WriteHeader(200) writer.WriteHeader(200)
(writer.(http.Flusher)).Flush()
file, err := os.Open(BuildFile) file, err := os.Open(BuildFile)
if err != nil { if err != nil {
panic(err) panic(err)
} }
defer file.Close() defer file.Close()
slowCopy(writer, file, delay) slowCopy(sl, writer, file, delay)
} else { } else {
// split range // split range
s := strings.Split(r, "=") s := strings.Split(r, "=")
@@ -204,6 +207,8 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize)) writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize))
writer.Header().Set("Accept-Ranges", "bytes") writer.Header().Set("Accept-Ranges", "bytes")
writer.WriteHeader(206) writer.WriteHeader(206)
(writer.(http.Flusher)).Flush()
file, err := os.Open(BuildFile) file, err := os.Open(BuildFile)
if err != nil { if err != nil {
writer.WriteHeader(500) writer.WriteHeader(500)
@@ -211,7 +216,7 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
} }
defer file.Close() defer file.Close()
file.Seek(start, 0) file.Seek(start, 0)
slowCopyN(writer, file, end-start+1, delay) slowCopyN(sl, writer, file, end-start+1, delay)
} }
}) })
return mux return mux
@@ -219,9 +224,12 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
} }
// slowCopyN copies n bytes from src to dst, speed limit is bytes per second // slowCopyN copies n bytes from src to dst, speed limit is bytes per second
func slowCopy(dst io.Writer, src io.Reader, delay int64) (written int64, err error) { func slowCopy(sl *shutdownListener, dst io.Writer, src io.Reader, delay int64) (written int64, err error) {
buf := make([]byte, 32*1024) buf := make([]byte, 32*1024)
for { for {
if sl.isShutdown {
return 0, errors.New("server shutdown")
}
nr, er := src.Read(buf) nr, er := src.Read(buf)
if nr > 0 { if nr > 0 {
nw, ew := dst.Write(buf[0:nr]) nw, ew := dst.Write(buf[0:nr])
@@ -250,8 +258,8 @@ func slowCopy(dst io.Writer, src io.Reader, delay int64) (written int64, err err
return written, err return written, err
} }
func slowCopyN(dst io.Writer, src io.Reader, n int64, delay int64) (written int64, err error) { func slowCopyN(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delay int64) (written int64, err error) {
written, err = slowCopy(dst, io.LimitReader(src, n), delay) written, err = slowCopy(sl, dst, io.LimitReader(src, n), delay)
if written == n { if written == n {
return n, nil return n, nil
} }
@@ -262,7 +270,7 @@ func slowCopyN(dst io.Writer, src io.Reader, n int64, delay int64) (written int6
return return
} }
func startTestServer(serverHandle func() http.Handler) net.Listener { func startTestServer(serverHandle func(sl *shutdownListener) http.Handler) net.Listener {
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
panic(err) panic(err)
@@ -272,7 +280,7 @@ func startTestServer(serverHandle func() http.Handler) net.Listener {
panic(err) panic(err)
} }
defer file.Close() defer file.Close()
// 随机生成一个文件 // Write random data
l := int64(8192) l := int64(8192)
buf := make([]byte, l) buf := make([]byte, l)
size := int64(0) size := int64(0)
@@ -289,21 +297,24 @@ func startTestServer(serverHandle func() http.Handler) net.Listener {
size += l size += l
} }
server := &http.Server{} server := &http.Server{}
server.Handler = serverHandle() sl := &shutdownListener{
go server.Serve(listener)
return &shutdownListener{
server: server, server: server,
Listener: listener, Listener: listener,
} }
server.Handler = serverHandle(sl)
go server.Serve(listener)
return sl
} }
type shutdownListener struct { type shutdownListener struct {
server *http.Server server *http.Server
isShutdown bool
net.Listener net.Listener
} }
func (c *shutdownListener) Close() error { func (c *shutdownListener) Close() error {
c.isShutdown = true
closeErr := c.server.Shutdown(context.Background()) closeErr := c.server.Shutdown(context.Background())
if err := ifExistAndRemove(BuildFile); err != nil { if err := ifExistAndRemove(BuildFile); err != nil {
fmt.Println(err) fmt.Println(err)
+28 -16
View File
@@ -204,33 +204,29 @@ func (d *Downloader) Setup() error {
current := task.fetcher.Progress().TotalDownloaded() current := task.fetcher.Progress().TotalDownloaded()
tick := float64(d.cfg.RefreshInterval) / 1000 tick := float64(d.cfg.RefreshInterval) / 1000
downloadDataChanged := false
if task.Status == base.DownloadStatusRunning { if task.Status == base.DownloadStatusRunning {
downloadDataChanged = current != task.Progress.Downloaded
task.Progress.Used = task.timer.Used() task.Progress.Used = task.timer.Used()
task.Progress.Speed = task.calcSpeed(task.speedArr, current-task.Progress.Downloaded, tick) task.Progress.Speed = task.calcSpeed(task.speedArr, current-task.Progress.Downloaded, tick)
task.Progress.Downloaded = current task.Progress.Downloaded = current
} }
uploadDataChanged := false
if task.Uploading { if task.Uploading {
uploader := task.fetcher.(fetcher.Uploader) uploader := task.fetcher.(fetcher.Uploader)
currentUploaded := uploader.UploadedBytes() currentUploaded := uploader.UploadedBytes()
uploadDataChanged = currentUploaded != task.Progress.Uploaded
task.Progress.UploadSpeed = task.calcSpeed(task.uploadSpeedArr, currentUploaded-task.Progress.Uploaded, tick) task.Progress.UploadSpeed = task.calcSpeed(task.uploadSpeedArr, currentUploaded-task.Progress.Uploaded, tick)
task.Progress.Uploaded = currentUploaded task.Progress.Uploaded = currentUploaded
} }
d.emit(EventKeyProgress, task) d.emit(EventKeyProgress, task)
// store fetcher progress // store fetcher progress when download/upload data changed
data, err := task.fetcherManager.Store(task.fetcher) if !downloadDataChanged && !uploadDataChanged {
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("serialize fetcher failed: %s", task.ID)
return
}
if err := d.storage.Put(bucketSave, task.ID, data); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist fetcher failed: %s", task.ID)
return
}
if err := d.storage.Put(bucketTask, task.ID, task); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID)
return return
} }
d.saveTask(task)
}() }()
} }
} }
@@ -273,6 +269,23 @@ func (d *Downloader) setupFetcher(fm fetcher.FetcherManager, fetcher fetcher.Fet
fetcher.Setup(ctl) fetcher.Setup(ctl)
} }
func (d *Downloader) saveTask(task *Task) error {
data, err := task.fetcherManager.Store(task.fetcher)
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("serialize fetcher failed: %s", task.ID)
return err
}
if err := d.storage.Put(bucketSave, task.ID, data); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist fetcher failed: %s", task.ID)
return err
}
if err := d.storage.Put(bucketTask, task.ID, task); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID)
return err
}
return nil
}
func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) { func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
rrId, err := gonanoid.New() rrId, err := gonanoid.New()
if err != nil { if err != nil {
@@ -813,7 +826,7 @@ func (d *Downloader) watch(task *Task) {
} }
// When delete a not resolved task, need check if the task resource is nil // When delete a not resolved task, need check if the task resource is nil
if task.Meta.Res == nil { if task.Meta.Res == nil || d.GetTask(task.ID) == nil {
return return
} }
@@ -1005,8 +1018,6 @@ func (d *Downloader) doStart(task *Task) (err error) {
return return
} }
isCreate = task.Status == base.DownloadStatusReady isCreate = task.Status == base.DownloadStatusReady
d.triggerOnStart(task)
task.updateStatus(base.DownloadStatusRunning) task.updateStatus(base.DownloadStatusRunning)
return return
@@ -1023,6 +1034,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
task.lock.Lock() task.lock.Lock()
defer task.lock.Unlock() defer task.lock.Unlock()
d.triggerOnStart(task)
if task.Meta.Res == nil { if task.Meta.Res == nil {
err := task.fetcher.Resolve(task.Meta.Req) err := task.fetcher.Resolve(task.Meta.Req)
if err != nil { if err != nil {
@@ -1064,7 +1076,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
if err := task.fetcher.Start(); err != nil { if err := task.fetcher.Start(); err != nil {
return err return err
} }
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil { if err := d.saveTask(task); err != nil {
return err return err
} }
d.emit(EventKeyStart, task) d.emit(EventKeyStart, task)
+8 -6
View File
@@ -702,20 +702,22 @@ type OnDoneContext struct {
Task *Task `json:"task"` Task *Task `json:"task"`
} }
// ExtensionTask is a wrapper of Task, it's used to interact with extension scripts.
// Avoid extension scripts modifying task directly, use ExtensionTask to encapsulate task,
// only some fields can be modified, such as request info.
type ExtensionTask struct { type ExtensionTask struct {
*Task
download *Downloader download *Downloader
*Task
} }
func NewExtensionTask(download *Downloader, task *Task) *ExtensionTask { func NewExtensionTask(download *Downloader, task *Task) *ExtensionTask {
// restricts extension scripts to only modify request info
newTask := task.clone() newTask := task.clone()
// Assign the pointer of the properties that the extension supports modification newTask.Meta.Req = task.Meta.Req
newTask.Meta = task.Meta
newTask.Status = task.Status
return &ExtensionTask{ return &ExtensionTask{
Task: newTask,
download: download, download: download,
Task: newTask,
} }
} }
Binary file not shown.
Binary file not shown.
+21 -4
View File
@@ -129,12 +129,20 @@ Future<void> continueTask(String id) async {
return _parse(() => _client.dio.put("/api/v1/tasks/$id/continue"), null); return _parse(() => _client.dio.put("/api/v1/tasks/$id/continue"), null);
} }
Future<void> pauseAllTasks() async { Future<void> pauseAllTasks(List<String>? ids) async {
return _parse(() => _client.dio.put("/api/v1/tasks/pause"), null); return _parse(
() => _client.dio.put("/api/v1/tasks/pause", queryParameters: {
"id": ids,
}),
null);
} }
Future<void> continueAllTasks() async { Future<void> continueAllTasks(List<String>? ids) async {
return _parse(() => _client.dio.put("/api/v1/tasks/continue"), null); return _parse(
() => _client.dio.put("/api/v1/tasks/continue", queryParameters: {
"id": ids,
}),
null);
} }
Future<void> deleteTask(String id, bool force) async { Future<void> deleteTask(String id, bool force) async {
@@ -142,6 +150,15 @@ Future<void> deleteTask(String id, bool force) async {
() => _client.dio.delete("/api/v1/tasks/$id?force=$force"), null); () => _client.dio.delete("/api/v1/tasks/$id?force=$force"), null);
} }
Future<void> deleteTasks(List<String>? ids, bool force) async {
return _parse(
() => _client.dio.delete("/api/v1/tasks", queryParameters: {
"id": ids,
"force": force,
}),
null);
}
Future<DownloaderConfig> getConfig() async { Future<DownloaderConfig> getConfig() async {
return _parse(() => _client.dio.get("/api/v1/config"), return _parse(() => _client.dio.get("/api/v1/config"),
(data) => DownloaderConfig.fromJson(data)); (data) => DownloaderConfig.fromJson(data));
@@ -173,7 +173,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
}); });
// Check initial link if app was in cold state (terminated) // Check initial link if app was in cold state (terminated)
final uri = await _appLinks.getInitialLink(); final uri = await _appLinks.getInitialAppLink();
if (uri != null) { if (uri != null) {
await _toCreate(uri); await _toCreate(uri);
} }
@@ -218,11 +218,11 @@ class AppController extends GetxController with WindowListener, TrayListener {
), ),
MenuItem( MenuItem(
label: "startAll".tr, label: "startAll".tr,
onClick: (menuItem) async => {continueAllTasks()}, onClick: (menuItem) async => {continueAllTasks(null)},
), ),
MenuItem( MenuItem(
label: "pauseAll".tr, label: "pauseAll".tr,
onClick: (menuItem) async => {pauseAllTasks()}, onClick: (menuItem) async => {pauseAllTasks(null)},
), ),
MenuItem( MenuItem(
label: 'setting'.tr, label: 'setting'.tr,
@@ -1,4 +1,5 @@
import 'package:autoscale_tabbarview/autoscale_tabbarview.dart'; import 'package:autoscale_tabbarview/autoscale_tabbarview.dart';
import 'package:checkable_treeview/checkable_treeview.dart';
import 'package:desktop_drop/desktop_drop.dart'; import 'package:desktop_drop/desktop_drop.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -16,10 +17,11 @@ import '../../../../database/database.dart';
import '../../../../util/input_formatter.dart'; import '../../../../util/input_formatter.dart';
import '../../../../util/message.dart'; import '../../../../util/message.dart';
import '../../../../util/util.dart'; import '../../../../util/util.dart';
import '../../../../util/extensions.dart';
import '../../../routes/app_pages.dart'; import '../../../routes/app_pages.dart';
import '../../../views/compact_checkbox.dart'; import '../../../views/compact_checkbox.dart';
import '../../../views/directory_selector.dart'; import '../../../views/directory_selector.dart';
import '../../../views/file_list_view.dart'; import '../../../views/file_tree_view.dart';
import '../../app/controllers/app_controller.dart'; import '../../app/controllers/app_controller.dart';
import '../../history/views/history_view.dart'; import '../../history/views/history_view.dart';
import '../controllers/create_controller.dart'; import '../controllers/create_controller.dart';
@@ -40,6 +42,7 @@ class CreateView extends GetView<CreateController> {
final _httpCookieController = TextEditingController(); final _httpCookieController = TextEditingController();
final _httpRefererController = TextEditingController(); final _httpRefererController = TextEditingController();
final _btTrackerController = TextEditingController(); final _btTrackerController = TextEditingController();
final _fileTreeViewKey = GlobalKey<TreeViewState<int>>();
final _availableSchemes = ["http:", "https:", "magnet:"]; final _availableSchemes = ["http:", "https:", "magnet:"];
@@ -698,7 +701,13 @@ class CreateView extends GetView<CreateController> {
child: Form( child: Form(
key: createFormKey, key: createFormKey,
autovalidateMode: AutovalidateMode.always, autovalidateMode: AutovalidateMode.always,
child: FileListView(files: rr.res.files)), child: FileTreeView(
files: rr.res.files,
initialValues: rr.res.files.asMap().keys.toList(),
onSelectionChanged: (List<int> values) {
controller.selectedIndexes.value = values;
},
)),
); );
}, },
), ),
@@ -6,5 +6,4 @@ class TaskController extends GetxController {
final tabIndex = 0.obs; final tabIndex = 0.obs;
final scaffoldKey = GlobalKey<ScaffoldState>(); final scaffoldKey = GlobalKey<ScaffoldState>();
final selectTask = Rx<Task?>(null); final selectTask = Rx<Task?>(null);
final copyUrlDone = false.obs;
} }
@@ -9,5 +9,13 @@ class TaskDownloadingController extends TaskListController {
Status.pause, Status.pause,
Status.wait, Status.wait,
Status.error Status.error
], (a, b) => b.createdAt.compareTo(a.createdAt)); ], (a, b) {
if (a.status == Status.running && b.status != Status.running) {
return -1;
} else if (a.status != Status.running && b.status == Status.running) {
return 1;
} else {
return b.updatedAt.compareTo(a.updatedAt);
}
});
} }
@@ -12,6 +12,7 @@ abstract class TaskListController extends GetxController {
TaskListController(this.statuses, this.compare); TaskListController(this.statuses, this.compare);
final tasks = <Task>[].obs; final tasks = <Task>[].obs;
final selectedTaskIds = <String>[].obs;
final isRunning = false.obs; final isRunning = false.obs;
late final Timer _timer; late final Timer _timer;
@@ -9,6 +9,7 @@ class TaskDownloadedView extends GetView<TaskDownloadedController> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BuildTaskListView(tasks: controller.tasks); return BuildTaskListView(
tasks: controller.tasks, selectedTaskIds: controller.selectedTaskIds);
} }
} }
@@ -9,6 +9,7 @@ class TaskDownloadingView extends GetView<TaskDownloadingController> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BuildTaskListView(tasks: controller.tasks); return BuildTaskListView(
tasks: controller.tasks, selectedTaskIds: controller.selectedTaskIds);
} }
} }
@@ -7,10 +7,9 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../../api/api.dart' as api; import '../../../../api/api.dart' as api;
import '../../../../util/browser_download/browser_download.dart'; import '../../../../util/browser_download/browser_download.dart';
import '../../../../util/file_icon.dart';
import '../../../../util/icons.dart';
import '../../../../util/util.dart'; import '../../../../util/util.dart';
import '../../../views/breadcrumb_view.dart'; import '../../../views/breadcrumb_view.dart';
import '../../../views/file_icon.dart';
import '../controllers/task_files_controller.dart'; import '../controllers/task_files_controller.dart';
class TaskFilesView extends GetView<TaskFilesController> { class TaskFilesView extends GetView<TaskFilesController> {
@@ -65,9 +64,8 @@ class TaskFilesView extends GetView<TaskFilesController> {
[meta.opts.path, meta.res!.name, fileRelativePath]); [meta.opts.path, meta.res!.name, fileRelativePath]);
final fileName = basename(filePath); final fileName = basename(filePath);
return ListTile( return ListTile(
leading: file.isDirectory leading:
? const Icon(Icons.folder) Icon(fileIcon(fileName, isFolder: file.isDirectory)),
: Icon(FaIcons.allIcons[findIcon(fileName)]),
title: Text(fileName), title: Text(fileName),
subtitle: file.isDirectory subtitle: file.isDirectory
? Text('items'.trParams({ ? Text('items'.trParams({
+117 -11
View File
@@ -1,24 +1,28 @@
import 'package:contextmenu/contextmenu.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import '../../api/api.dart'; import '../../api/api.dart';
import '../../api/model/task.dart'; import '../../api/model/task.dart';
import '../../util/file_icon.dart';
import '../../util/icons.dart';
import '../../util/message.dart'; import '../../util/message.dart';
import '../../util/util.dart'; import '../../util/util.dart';
import '../modules/app/controllers/app_controller.dart'; import '../modules/app/controllers/app_controller.dart';
import '../modules/task/controllers/task_controller.dart'; import '../modules/task/controllers/task_controller.dart';
import '../modules/task/controllers/task_downloaded_controller.dart';
import '../modules/task/controllers/task_downloading_controller.dart';
import '../modules/task/views/task_view.dart'; import '../modules/task/views/task_view.dart';
import '../routes/app_pages.dart'; import '../routes/app_pages.dart';
import 'file_icon.dart';
class BuildTaskListView extends GetView { class BuildTaskListView extends GetView {
final List<Task> tasks; final List<Task> tasks;
final List<String> selectedTaskIds;
const BuildTaskListView({ const BuildTaskListView({
Key? key, Key? key,
required this.tasks, required this.tasks,
required this.selectedTaskIds,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -57,11 +61,15 @@ class BuildTaskListView extends GetView {
return task.status == Status.running; return task.status == Status.running;
} }
bool isSelect() {
return selectedTaskIds.contains(task.id);
}
bool isFolderTask() { bool isFolderTask() {
return task.isFolder; return task.isFolder;
} }
Future<void> showDeleteDialog(String id) { Future<void> showDeleteDialog(List<String> ids) {
final appController = Get.find<AppController>(); final appController = Get.find<AppController>();
final context = Get.context!; final context = Get.context!;
@@ -70,7 +78,8 @@ class BuildTaskListView extends GetView {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
title: Text('deleteTask'.tr), title: Text(
'deleteTask'.trParams({'count': ids.length.toString()})),
content: Obx(() => CheckboxListTile( content: Obx(() => CheckboxListTile(
value: appController value: appController
.downloaderConfig.value.extra.lastDeleteTaskKeep, .downloaderConfig.value.extra.lastDeleteTaskKeep,
@@ -96,7 +105,7 @@ class BuildTaskListView extends GetView {
final force = !appController final force = !appController
.downloaderConfig.value.extra.lastDeleteTaskKeep; .downloaderConfig.value.extra.lastDeleteTaskKeep;
await appController.saveConfig(); await appController.saveConfig();
await deleteTask(id, force); await deleteTasks(ids, force);
Get.back(); Get.back();
} catch (e) { } catch (e) {
showErrorMessage(e); showErrorMessage(e);
@@ -144,12 +153,35 @@ class BuildTaskListView extends GetView {
list.add(IconButton( list.add(IconButton(
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
onPressed: () { onPressed: () {
showDeleteDialog(task.id); showDeleteDialog([task.id]);
}, },
)); ));
return list; return list;
} }
Widget buildContextItem(IconData icon, String label, Function() onTap,
{bool enabled = true}) {
return ListTile(
dense: true,
visualDensity: const VisualDensity(vertical: -1),
minLeadingWidth: 12,
leading: Icon(icon, size: 18),
title: Text(label,
style: const TextStyle(
fontWeight: FontWeight.bold, // Make the text bold
)),
onTap: () async {
Get.back();
try {
await onTap();
} catch (e) {
showErrorMessage(e);
}
},
enabled: enabled,
);
}
double getProgress() { double getProgress() {
final totalSize = task.meta.res?.size ?? 0; final totalSize = task.meta.res?.size ?? 0;
return totalSize <= 0 ? 0 : task.progress.downloaded / totalSize; return totalSize <= 0 ? 0 : task.progress.downloaded / totalSize;
@@ -168,9 +200,79 @@ class BuildTaskListView extends GetView {
} }
final taskController = Get.find<TaskController>(); final taskController = Get.find<TaskController>();
final taskListController = taskController.tabIndex.value == 0
? Get.find<TaskDownloadingController>()
: Get.find<TaskDownloadedController>();
return Card( // Filter selected task ids that are still in the task list
filterSelectedTaskIds(Iterable<String> selectedTaskIds) => selectedTaskIds
.where((id) => tasks.any((task) => task.id == id))
.toList();
return ContextMenuArea(
width: 140,
builder: (context) => [
buildContextItem(Icons.checklist, 'selectAll'.tr, () {
if (tasks.isEmpty) return;
if (selectedTaskIds.isNotEmpty) {
taskListController.selectedTaskIds([]);
} else {
taskListController.selectedTaskIds(tasks.map((e) => e.id).toList());
}
}),
buildContextItem(Icons.check, 'select'.tr, () {
if (isSelect()) {
taskListController.selectedTaskIds(taskListController
.selectedTaskIds
.where((element) => element != task.id)
.toList());
} else {
taskListController.selectedTaskIds(
[...taskListController.selectedTaskIds, task.id]);
}
}),
const Divider(
indent: 8,
endIndent: 8,
),
buildContextItem(Icons.play_arrow, 'continue'.tr, () async {
try {
await continueAllTasks(filterSelectedTaskIds(
{...taskListController.selectedTaskIds, task.id}));
} finally {
taskListController.selectedTaskIds([]);
}
}, enabled: !isDone() && !isRunning()),
buildContextItem(Icons.pause, 'pause'.tr, () async {
try {
await pauseAllTasks(filterSelectedTaskIds(
{...taskListController.selectedTaskIds, task.id}));
} finally {
taskListController.selectedTaskIds([]);
}
}, enabled: !isDone() && isRunning()),
buildContextItem(Icons.delete, 'delete'.tr, () async {
try {
await showDeleteDialog(filterSelectedTaskIds(
{...taskListController.selectedTaskIds, task.id}));
} finally {
taskListController.selectedTaskIds([]);
}
}),
],
child: Obx(
() => Card(
elevation: 4.0, elevation: 4.0,
shape: isSelect()
? RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
side: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2.0,
),
)
: null,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
taskController.scaffoldKey.currentState?.openEndDrawer(); taskController.scaffoldKey.currentState?.openEndDrawer();
@@ -184,9 +286,11 @@ class BuildTaskListView extends GetView {
children: [ children: [
ListTile( ListTile(
title: Text(task.name), title: Text(task.name),
leading: isFolderTask() leading: Icon(
? const Icon(FaIcons.folder) fileIcon(task.name,
: Icon(FaIcons.allIcons[findIcon(task.name)])), isFolder: isFolderTask(),
isBitTorrent: task.protocol == Protocol.bt),
)),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -215,6 +319,8 @@ class BuildTaskListView extends GetView {
), ),
], ],
), ),
)).padding(horizontal: 14, top: 8); )).padding(horizontal: 14, top: 8),
),
);
} }
} }
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../../icon/gopeed_icons.dart';
final Map<IconData, List<String>> iconConfigMap = {
Gopeed.install: ['exe', 'msi', 'dmg', 'deb', 'rpm'],
Gopeed.android: ['apk'],
Gopeed.app_store_ios: ['ipa'],
Gopeed.file_bt: ['torrent'],
Gopeed.cd: ['iso'],
Gopeed.html5: ['html', 'htm'],
Gopeed.file_alt: ['txt', 'md', 'log', 'csv', 'tsv', 'json', 'yaml', 'yml'],
Gopeed.file_pdf: ['pdf'],
Gopeed.file_word: ['doc', 'docx'],
Gopeed.file_excel: ['xls', 'xlsx'],
Gopeed.file_powerpoint: ['ppt', 'pptx'],
Gopeed.file_archive: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'],
Gopeed.file_image: [
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'tiff',
'svg',
'webp'
],
Gopeed.file_audio: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a'],
Gopeed.file_video: ['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm'],
Gopeed.file_code: [
'js',
'css',
'json',
'xml',
'java',
'cpp',
'dart',
'py',
'rb',
'php',
'ts',
'swift',
'go',
'rs'
],
Gopeed.file: [''],
};
final Map<String, IconData> _iconCache = Map.fromEntries(
iconConfigMap.entries.expand(
(entry) => entry.value.map((ext) => MapEntry(ext, entry.key)),
),
);
String fileExt(String? name) {
if (name == null) {
return '';
}
final ext = name.split('.').last;
if (ext.length > 8) {
return '';
}
return ext.toLowerCase();
}
IconData fileIcon(String? name,
{bool isFolder = false, bool isBitTorrent = false}) {
if (isFolder) {
return isBitTorrent ? Gopeed.folder_bt : Gopeed.folder;
}
final ext = fileExt(name);
return _iconCache[ext] ?? Gopeed.file;
}
@@ -1,303 +0,0 @@
import 'dart:ui' as ui;
import 'package:fluent_ui/fluent_ui.dart' as fluent;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../api/model/resource.dart';
import 'package:path/path.dart' as path;
import '../../util/file_icon.dart';
import '../../util/icons.dart';
import '../../util/util.dart';
import '../modules/app/controllers/app_controller.dart';
import '../modules/create/controllers/create_controller.dart';
class FileListView extends GetView {
final List<FileInfo> files;
FileListView({
Key? key,
required this.files,
}) : super(key: key);
final parentController = Get.find<CreateController>();
final appController = Get.find<AppController>();
List findChildFileIdsR(List children) {
List res = [];
for (var k in children) {
if (parentController.fileInfos[k]['type'] == 'folder') {
res.addAll(
findChildFileIdsR(parentController.fileInfos[k]['children']));
} else {
res.add(parentController.fileInfos[k]['fileId']);
}
}
return res;
}
void clearDescendantFolderSizeR(int id) {
if (parentController.fileInfos[id]['type'] == 'folder') {
parentController.fileInfos[id]['size'] = 0;
for (var childId in parentController.fileInfos[id]['children']) {
clearDescendantFolderSizeR(childId);
}
}
}
void reduceAncestorFolderSizeR(int id, int size) {
int parentId = parentController.fileInfos[id]['parentId'];
if (parentId != -1) {
parentController.fileInfos[parentId]['size'] -= size;
reduceAncestorFolderSizeR(parentId, size);
}
}
int addDescendantFolderSizeR(int id) {
if (parentController.fileInfos[id]['type'] == 'folder') {
for (var childId in parentController.fileInfos[id]['children']) {
parentController.fileInfos[id]['size'] +=
addDescendantFolderSizeR(childId);
}
}
return parentController.fileInfos[id]['size'];
}
void addAncestorFolderSizeR(int id, int size) {
int parentId = parentController.fileInfos[id]['parentId'];
if (parentId != -1) {
parentController.fileInfos[parentId]['size'] += size;
addAncestorFolderSizeR(parentId, size);
}
}
List<fluent.TreeViewItem> buildTreeViewItemsR(int level, int parentId) {
List<fluent.TreeViewItem> res = [];
List children = parentController.fileInfos
.where((e) => e['level'] == level && e['parentId'] == parentId)
.toList();
for (int j = 0; j < children.length; j++) {
Map fileInfo = children[j];
if (fileInfo['type'] == 'folder') {
// folder
res.add(fluent.TreeViewItem(
// expanded: false, bug on init
value: fileInfo['id'],
onInvoked: (item, reason) async {
if (reason == fluent.TreeViewItemInvokeReason.selectionToggle) {
List childrenIds = findChildFileIdsR(fileInfo['children']);
if (item.selected == true) {
parentController.selectedIndexes.addAll(childrenIds.cast());
parentController.fileInfos[fileInfo['id']]['size'] = 0;
addDescendantFolderSizeR(item.value);
addAncestorFolderSizeR(item.value, fileInfo['size']);
parentController.fileInfos.refresh();
} else {
parentController.selectedIndexes
.removeWhere((index) => childrenIds.contains(index));
reduceAncestorFolderSizeR(item.value, fileInfo['size']);
clearDescendantFolderSizeR(item.value);
parentController.fileInfos.refresh();
}
}
},
leading: Obx(() {
return parentController.openedFolders.contains(fileInfo['id'])
? const Icon(FaIcons.folder_open,
color: Colors.lightBlueAccent)
: const Icon(FaIcons.folder, color: Colors.lightBlueAccent);
}),
onExpandToggle: (fluent.TreeViewItem item, bool getExpanded) async {
getExpanded
? parentController.openedFolders.add(item.value)
: parentController.openedFolders.remove(item.value);
},
content: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
Expanded(
child: Text(
fileInfo['name'],
overflow: TextOverflow.ellipsis,
// style: context.textTheme.titleSmall,
),
),
Obx(() {
return parentController.fileInfos[fileInfo['id']]['size'] != 0
? Text(
Util.fmtByte(
parentController.fileInfos[fileInfo['id']]['size']),
// style: context.textTheme.labelMedium,
overflow: TextOverflow.ellipsis,
)
: const SizedBox.shrink();
})
]),
children: buildTreeViewItemsR(
// parentController.fileInfos.where((e) => e['level'] > level).toList(),
level + 1,
fileInfo['id'])));
} else {
// file
res.add(fluent.TreeViewItem(
value: {'id': fileInfo['id'], 'fileId': fileInfo['fileId']},
selected: fileInfo['selected'],
onInvoked: (item, reason) async {
if (reason == fluent.TreeViewItemInvokeReason.selectionToggle) {
if (item.selected == true) {
parentController.selectedIndexes.add(item.value['fileId']);
addAncestorFolderSizeR(item.value['id'], fileInfo['size']);
parentController.fileInfos.refresh();
} else {
parentController.selectedIndexes.remove(item.value['fileId']);
reduceAncestorFolderSizeR(item.value['id'], fileInfo['size']);
parentController.fileInfos.refresh();
}
}
},
collapsable: false,
leading: fileInfo['name'].lastIndexOf('.') == -1
? Icon(
FaIcons.doc,
color: appController.downloaderConfig.value.extra.themeMode ==
'dark'
? Colors.white
: Colors.black45,
)
: Icon(
FaIcons.allIcons[findIcon(fileInfo['name'])],
color: appController.downloaderConfig.value.extra.themeMode ==
'dark'
? Colors.white
: Colors.black45,
),
content: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
Expanded(
child: Text(
fileInfo['name'],
overflow: TextOverflow.ellipsis,
// style: context.textTheme.titleSmall,
)),
fileInfo['size'] > 0
? Text(
Util.fmtByte(
fileInfo['size'],
),
// style: context.textTheme.labelMedium,
overflow: TextOverflow.ellipsis,
)
: const SizedBox.shrink(),
]),
));
}
}
return res;
}
List<fluent.TreeViewItem> get items {
List infos = [];
int idNext = 0;
List selectedFileIds = [];
List openedFolders = [];
//make fileInfos list
for (var i = 0; i < files.length; i++) {
//parentId -1 means path root
int parentId = -1;
List folders = path.split(files[i].path);
for (var folder in folders) {
int indexInInfos = infos.lastIndexWhere((fileInfo) =>
fileInfo['name'] == folder && fileInfo['parentId'] == parentId);
if (indexInInfos != -1) {
// folder exists
parentId = infos[indexInInfos]['id'];
// add size
infos[indexInInfos]['size'] += files[i].size;
} else {
// parent not exist
// create one and add index to parent's children
infos.add({
'level': folders.indexOf(folder),
'id': idNext,
'type': 'folder',
'name': folder,
'size': files[i].size,
'parentId': parentId,
'children': [],
});
openedFolders.add(idNext);
if (parentId != -1) {
infos[parentId]['children'].add(idNext);
}
parentId = idNext;
idNext++;
}
}
//add one file, add index to parent
infos.add({
'id': idNext,
'type': 'file',
'fileId': i,
'level': folders.length,
'name': files[i].name,
'size': files[i].size,
'parentId': parentId,
//TODO add unselected logic
'selected': true
});
selectedFileIds.add(i);
if (parentId != -1) {
infos[parentId]['children'].add(idNext);
}
idNext++;
}
parentController.fileInfos.value = infos;
parentController.selectedIndexes.value = selectedFileIds.cast();
parentController.openedFolders.value = openedFolders;
List<fluent.TreeViewItem> treeItems = buildTreeViewItemsR(0, -1);
return treeItems;
}
@override
Widget build(BuildContext context) {
return fluent.FluentTheme(
data: appController.downloaderConfig.value.extra.themeMode == 'system'
? fluent.FluentThemeData(brightness: ui.window.platformBrightness)
: appController.downloaderConfig.value.extra.themeMode == 'light'
? fluent.FluentThemeData(brightness: Brightness.light)
: fluent.FluentThemeData(brightness: Brightness.dark),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(padding: EdgeInsets.only(top: 10)),
Text(
'selectFile'.tr,
// style: TextStyle(color: themeData.hintColor),
),
Expanded(
child: Container(
margin: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey, width: 1),
borderRadius: BorderRadius.circular(5)),
child: fluent.TreeView(
selectionMode: fluent.TreeViewSelectionMode.multiple,
shrinkWrap: false,
// addRepaintBoundaries: false,
usePrototypeItem: true,
// cacheExtent: 20,
// onItemInvoked: (item, reason) async => {},
onSecondaryTap: (item, details) async {
debugPrint(
'onSecondaryTap $item at ${details.globalPosition}');
},
// onSelectionChanged: (selectedItems) async => {},
narrowSpacing: true,
items: items,
// scrollPrimary: true,
))),
],
));
}
}
@@ -0,0 +1,292 @@
import 'package:checkable_treeview/checkable_treeview.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
import '../../api/model/resource.dart';
import '../../icon/gopeed_icons.dart';
import '../../util/util.dart';
import 'file_icon.dart';
import 'sort_icon_button.dart';
const _toggleSwitchIcons = [
Gopeed.file_video,
Gopeed.file_audio,
Gopeed.file_image,
];
const _sizeGapWidth = 72.0;
class FileTreeView extends StatefulWidget {
final List<FileInfo> files;
final List<int> initialValues;
final Function(List<int>) onSelectionChanged;
const FileTreeView(
{Key? key,
required this.files,
required this.initialValues,
required this.onSelectionChanged})
: super(key: key);
@override
State<FileTreeView> createState() => _FileTreeViewState();
}
class _FileTreeViewState extends State<FileTreeView> {
late GlobalKey<TreeViewState<int>> key;
late int totalSize;
int? toggleSwitchIndex;
@override
void initState() {
super.initState();
key = GlobalKey<TreeViewState<int>>();
totalSize = widget.files
.fold(0, (previousValue, element) => previousValue + element.size);
widget.onSelectionChanged(widget.initialValues);
}
@override
Widget build(BuildContext context) {
final selectedFileCount =
key.currentState?.getSelectedValues().where((e) => e != null).length ??
widget.files.length;
final selectedFileSize = calcSelectedSize(null);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: TreeView(
key: key,
nodes: buildTreeNodes(),
showExpandCollapseButton: true,
showSelectAll: true,
onSelectionChanged: (selectedValues) {
setState(() {});
widget.onSelectionChanged(selectedValues
.where((e) => e != null)
.map((e) => e!)
.toList());
},
selectAllTrailing: (context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('name'.tr),
SortIconButton(
onStateChanged: (state) {
switch (state) {
case SortState.asc:
key.currentState?.sort((p0, p1) {
return (p0.label as Text)
.data!
.compareTo((p1.label as Text).data!);
});
break;
case SortState.desc:
key.currentState?.sort((p0, p1) {
return (p1.label as Text)
.data!
.compareTo((p0.label as Text).data!);
});
break;
default:
key.currentState?.sort(null);
break;
}
},
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('size'.tr),
SortIconButton(
onStateChanged: (state) {
switch (state) {
case SortState.asc:
key.currentState?.sort((p0, p1) {
return calcSelectedSize(p0)
.compareTo(calcSelectedSize(p1));
});
break;
case SortState.desc:
key.currentState?.sort((p0, p1) {
return calcSelectedSize(p1)
.compareTo(calcSelectedSize(p0));
});
break;
default:
key.currentState?.sort(null);
break;
}
},
),
],
)
],
);
},
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
flex: 4,
child: InkWell(
onTap: () {},
child: ToggleSwitch(
minHeight: 32,
cornerRadius: 8,
doubleTapDisable: true,
inactiveBgColor: Theme.of(context).dividerColor,
activeBgColor: [Theme.of(context).colorScheme.primary],
initialLabelIndex: toggleSwitchIndex,
icons: _toggleSwitchIcons,
onToggle: (index) {
toggleSwitchIndex = index;
if (index == null) {
key.currentState?.setSelectedValues(List.empty());
return;
}
final iconFileExtArr =
iconConfigMap[_toggleSwitchIcons[index]] ?? [];
final selectedFileIndexes = widget.files
.asMap()
.entries
.where((e) =>
iconFileExtArr.contains(fileExt(e.value.name)))
.map((e) => e.key)
.toList();
key.currentState?.setSelectedValues(selectedFileIndexes);
},
),
),
),
Flexible(
flex: 6,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('fileSelectedCount'.tr),
Text(
selectedFileCount.toString(),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 12),
Text('fileSelectedSize'.tr),
Text(
selectedFileCount > 0 && selectedFileSize == 0
? 'unknown'.tr
: Util.fmtByte(selectedFileSize),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
],
);
}
int calcSelectedSize(TreeNode<int>? node) {
if (key.currentState == null) {
return widget.files
.fold(0, (previousValue, element) => previousValue + element.size);
}
final selectedFileIndexes = node == null
? key.currentState?.getSelectedValues()
: (node.value != null
? [node.value]
: key.currentState?.getChildSelectedValues(node));
if (selectedFileIndexes == null) return 0;
return selectedFileIndexes.where((e) => e != null).map((e) => e!).fold(0,
(previousValue, element) => previousValue + widget.files[element].size);
}
List<TreeNode<int>> buildTreeNodes() {
final List<TreeNode<int>> rootNodes = [];
final Map<String, TreeNode<int>> dirNodes = {};
for (var i = 0; i < widget.files.length; i++) {
final file = widget.files[i];
final parts = file.path.split('/');
String currentPath = '';
TreeNode<int>? parentNode;
// Create or get directory nodes
for (final part in parts) {
if (part.isEmpty) continue;
currentPath += '/$part';
if (!dirNodes.containsKey(currentPath)) {
final node = TreeNode<int>(
label: Text(part),
icon: Icon(
fileIcon(part, isFolder: true),
size: 18,
),
trailing: (context, node) {
final size = calcSelectedSize(node);
return size > 0
? Text(Util.fmtByte(calcSelectedSize(node)),
style: Theme.of(context).textTheme.bodySmall)
: const SizedBox(width: _sizeGapWidth);
},
children: [],
);
dirNodes[currentPath] = node;
if (parentNode == null) {
rootNodes.add(node);
} else {
parentNode.children.add(node);
}
}
parentNode = dirNodes[currentPath];
}
// Create file node using file.name
final fileNode = TreeNode<int>(
label: Text(file.name),
value: i,
icon: Icon(fileIcon(file.name, isFolder: false), size: 18),
trailing: (context, node) {
return file.size > 0
? Text(Util.fmtByte(file.size),
style: Theme.of(context).textTheme.bodySmall)
: const SizedBox(width: _sizeGapWidth);
},
isSelected: widget.initialValues.contains(i),
children: [],
);
// Add file node to parent or root
if (parentNode != null) {
parentNode.children.add(fileNode);
} else {
rootNodes.add(fileNode);
}
}
return rootNodes;
}
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../../icon/gopeed_icons.dart';
enum SortState { none, asc, desc }
class SortIconButton extends StatefulWidget {
final double size;
final Color? color;
final Color? activeColor;
final SortState initialState;
final Function(SortState) onStateChanged;
const SortIconButton({
Key? key,
this.size = 18.0,
this.color,
this.activeColor,
this.initialState = SortState.none,
required this.onStateChanged,
}) : super(key: key);
@override
State<SortIconButton> createState() => _SortIconState();
}
class _SortIconState extends State<SortIconButton> {
late SortState _currentState;
@override
void initState() {
super.initState();
_currentState = widget.initialState;
}
void _toggleState() {
setState(() {
switch (_currentState) {
case SortState.none:
_currentState = SortState.asc;
break;
case SortState.asc:
_currentState = SortState.desc;
break;
case SortState.desc:
_currentState = SortState.none;
break;
}
});
widget.onStateChanged(_currentState);
}
@override
Widget build(BuildContext context) {
return InkWell(
customBorder: const CircleBorder(),
onTap: _toggleState,
child: SizedBox(
width: widget.size,
height: widget.size,
child: _currentState == SortState.none
? Icon(
Gopeed.sort,
size: widget.size,
color: widget.color,
)
: Column(
children: [
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: 0.5,
child: Icon(
Gopeed.sort,
size: widget.size,
color: _currentState == SortState.asc
? (widget.activeColor ??
Theme.of(context).colorScheme.primary)
: widget.color,
),
),
),
ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: 0.5,
child: Icon(
Gopeed.sort,
size: widget.size,
color: _currentState == SortState.desc
? (widget.activeColor ??
Theme.of(context).colorScheme.primary)
: widget.color,
),
),
),
],
),
),
);
}
}
+10 -3
View File
@@ -8,6 +8,7 @@ const enUS = {
'on': 'On', 'on': 'On',
'off': 'Off', 'off': 'Off',
'selectAll': 'Select All', 'selectAll': 'Select All',
'select': 'Select',
'task': 'Tasks', 'task': 'Tasks',
'downloading': 'downloading', 'downloading': 'downloading',
'downloaded': 'downloaded', 'downloaded': 'downloaded',
@@ -20,8 +21,7 @@ const enUS = {
'followSettings': 'Follow Settings', 'followSettings': 'Follow Settings',
'downloadLink': 'Download Link', 'downloadLink': 'Download Link',
'downloadLinkValid': 'Please enter the download link', 'downloadLinkValid': 'Please enter the download link',
'downloadLinkHit': 'downloadLinkHit': 'Please enter the download link, one per line@append',
'Please enter the download link, HTTP/HTTPS/MAGNET supported@append',
'downloadLinkHitDesktop': ', or drag the torrent file here directly', 'downloadLinkHitDesktop': ', or drag the torrent file here directly',
'download': 'Download', 'download': 'Download',
'noFileSelected': 'Please select at least one file to continue.', 'noFileSelected': 'Please select at least one file to continue.',
@@ -65,9 +65,11 @@ const enUS = {
'developer': 'Developer', 'developer': 'Developer',
'logDirectory': 'Log Directory', 'logDirectory': 'Log Directory',
'show': 'Show', 'show': 'Show',
'continue': 'Continue',
'pause': 'Pause',
'startAll': 'Start All', 'startAll': 'Start All',
'pauseAll': 'Pause All', 'pauseAll': 'Pause All',
'deleteTask': 'Delete Task', 'deleteTask': 'Delete @count tasks',
'deleteTaskTip': 'Keep downloaded files', 'deleteTaskTip': 'Keep downloaded files',
'delete': 'Delete', 'delete': 'Delete',
'newVersionTitle': 'Discover new version @version', 'newVersionTitle': 'Discover new version @version',
@@ -108,5 +110,10 @@ const enUS = {
'taskUrl': 'Task URL', 'taskUrl': 'Task URL',
'downloadPath': 'Download Path', 'downloadPath': 'Download Path',
'skipVerifyCert': 'Skip Certificate Verification', 'skipVerifyCert': 'Skip Certificate Verification',
'name': 'Name',
'size': 'Size',
'unknown': 'Unknown',
'fileSelectedCount': 'Files: ',
'fileSelectedSize': 'Size: ',
}, },
}; };
+1 -1
View File
@@ -63,7 +63,7 @@ const esES = {
'show': 'Mostrar', 'show': 'Mostrar',
'startAll': 'Iniciar Todo', 'startAll': 'Iniciar Todo',
'pauseAll': 'Pausar Todo', 'pauseAll': 'Pausar Todo',
'deleteTask': 'Eliminar Tarea', 'deleteTask': 'Eliminar @count tareas',
'deleteTaskTip': 'Mantener archivos descargados', 'deleteTaskTip': 'Mantener archivos descargados',
'delete': 'Eliminar', 'delete': 'Eliminar',
'newVersionTitle': 'Nueva versión @version disponible', 'newVersionTitle': 'Nueva versión @version disponible',
+1 -1
View File
@@ -51,7 +51,7 @@ const faIR = {
'effectAfterRestart': 'Effect after restart', 'effectAfterRestart': 'Effect after restart',
'startAll': 'شروع همه', 'startAll': 'شروع همه',
'pauseAll': 'توقف همه', 'pauseAll': 'توقف همه',
'deleteTask': 'پاک کردن کار', 'deleteTask': 'حذف @count کار',
'deleteTaskTip': 'فایل های دانلود شده را نگه دارد', 'deleteTaskTip': 'فایل های دانلود شده را نگه دارد',
'delete': 'پاک کردن', 'delete': 'پاک کردن',
'newVersionTitle': '@version عنوان: کشف نسخه جدید', 'newVersionTitle': '@version عنوان: کشف نسخه جدید',
+1 -1
View File
@@ -61,7 +61,7 @@ const frFR = {
'show': 'Afficher', 'show': 'Afficher',
'startAll': 'Tout démarrer', 'startAll': 'Tout démarrer',
'pauseAll': 'Tout suspendre', 'pauseAll': 'Tout suspendre',
'deleteTask': 'Supprimer la tâche', 'deleteTask': 'Supprimer @count tâches',
'deleteTaskTip': 'Conserver les fichiers téléchargés', 'deleteTaskTip': 'Conserver les fichiers téléchargés',
'delete': 'Supprimer', 'delete': 'Supprimer',
'newVersionTitle': 'Nouvelle version @version disponible', 'newVersionTitle': 'Nouvelle version @version disponible',
+1 -1
View File
@@ -67,7 +67,7 @@ const idID = {
'show': 'Tampilkan', 'show': 'Tampilkan',
'startAll': 'Mulai Semua', 'startAll': 'Mulai Semua',
'pauseAll': 'Jeda Semua', 'pauseAll': 'Jeda Semua',
'deleteTask': 'Hapus Tugas', 'deleteTask': 'Hapus @count Tugas',
'deleteTaskTip': 'Simpan file yang terunduh', 'deleteTaskTip': 'Simpan file yang terunduh',
'delete': 'Hapus', 'delete': 'Hapus',
'newVersionTitle': 'Temukan versi batu @version', 'newVersionTitle': 'Temukan versi batu @version',
+1 -1
View File
@@ -64,7 +64,7 @@ const itIT = {
'show': 'Mostra', 'show': 'Mostra',
'startAll': 'Avvia tutti', 'startAll': 'Avvia tutti',
'pauseAll': 'Mtti in pausa tutti', 'pauseAll': 'Mtti in pausa tutti',
'deleteTask': 'Elimina attività', 'deleteTask': 'Elimina @count attività',
'deleteTaskTip': 'Conserva i file scaricati', 'deleteTaskTip': 'Conserva i file scaricati',
'delete': 'Elimina', 'delete': 'Elimina',
'newVersionTitle': 'Scopri la nuova versione @version', 'newVersionTitle': 'Scopri la nuova versione @version',
+1 -1
View File
@@ -53,7 +53,7 @@ const jaJP = {
'effectAfterRestart': '再起動後の効果', 'effectAfterRestart': '再起動後の効果',
'startAll': 'すべてを開始', 'startAll': 'すべてを開始',
'pauseAll': 'すべてを一時停止', 'pauseAll': 'すべてを一時停止',
'deleteTask': 'タスクを削除', 'deleteTask': '@count タスクを削除',
'deleteTaskTip': 'ダウンロードしたファイルを保持', 'deleteTaskTip': 'ダウンロードしたファイルを保持',
'delete': '削除', 'delete': '削除',
'newVersionTitle': '新しいバージョン @version を発見する', 'newVersionTitle': '新しいバージョン @version を発見する',
+1 -1
View File
@@ -62,7 +62,7 @@ const plPL = {
'show': 'Pokaż', 'show': 'Pokaż',
'startAll': 'Zacznij wszystkie', 'startAll': 'Zacznij wszystkie',
'pauseAll': 'Zatrzymaj wszystkie', 'pauseAll': 'Zatrzymaj wszystkie',
'deleteTask': 'Usuń zadanie', 'deleteTask': 'Usuń @count zadania',
'deleteTaskTip': 'Zachowaj pobrane pliki', 'deleteTaskTip': 'Zachowaj pobrane pliki',
'delete': 'Usuń', 'delete': 'Usuń',
'newVersionTitle': 'Sprawdź aktualizację @version', 'newVersionTitle': 'Sprawdź aktualizację @version',
+1 -1
View File
@@ -57,7 +57,7 @@ const ruRU = {
'effectAfterRestart': 'Эффект после перезагрузки', 'effectAfterRestart': 'Эффект после перезагрузки',
'startAll': 'Запустить все', 'startAll': 'Запустить все',
'pauseAll': 'Приостановить все', 'pauseAll': 'Приостановить все',
'deleteTask': 'Удалить задачу', 'deleteTask': 'Удалить @count задач',
'deleteTaskTip': 'Сохранить загруженные файлы', 'deleteTaskTip': 'Сохранить загруженные файлы',
'delete': 'Удалить', 'delete': 'Удалить',
'newVersionTitle': 'Обнаружена новая версия @version', 'newVersionTitle': 'Обнаружена новая версия @version',
+1 -1
View File
@@ -63,7 +63,7 @@ const taTA = {
'show': 'காட்டு', 'show': 'காட்டு',
'startAll': 'அனைத்தையும் தொடங்கு', 'startAll': 'அனைத்தையும் தொடங்கு',
'pauseAll': 'அனைத்தையும் நிறுத்திவை', 'pauseAll': 'அனைத்தையும் நிறுத்திவை',
'deleteTask': 'பணியை நீக்கு', 'deleteTask': 'பணிகளை நீக்கு @count',
'deleteTaskTip': 'பதிவிறக்கம் செய்யப்பட்ட கோப்புகளை வைத்திரு', 'deleteTaskTip': 'பதிவிறக்கம் செய்யப்பட்ட கோப்புகளை வைத்திரு',
'delete': 'நீக்கு', 'delete': 'நீக்கு',
'newVersionTitle': 'புதிய பதிப்பைக் கண்டறியவும் @version', 'newVersionTitle': 'புதிய பதிப்பைக் கண்டறியவும் @version',
+1 -1
View File
@@ -65,7 +65,7 @@ const trTR = {
'show': 'Göster', 'show': 'Göster',
'startAll': 'Hepsini başlat', 'startAll': 'Hepsini başlat',
'pauseAll': 'Hepsini durdur', 'pauseAll': 'Hepsini durdur',
'deleteTask': 'Görevleri sil', 'deleteTask': '@count görevi sil',
'deleteTaskTip': 'İndirilen dosyaları sakla', 'deleteTaskTip': 'İndirilen dosyaları sakla',
'delete': 'Sil', 'delete': 'Sil',
'newVersionTitle': 'Yeni sürümü keşfet @version', 'newVersionTitle': 'Yeni sürümü keşfet @version',
+1 -1
View File
@@ -61,7 +61,7 @@ const viVN = {
'effectAfterRestart': 'Hiệu lực sau khi khởi động lại', 'effectAfterRestart': 'Hiệu lực sau khi khởi động lại',
'startAll': 'Bắt đầu tất cả', 'startAll': 'Bắt đầu tất cả',
'pauseAll': 'Tạm dừng tất cả', 'pauseAll': 'Tạm dừng tất cả',
'deleteTask': 'Xóa nhiệm vụ', 'deleteTask': 'Xóa @count nhiệm vụ',
'deleteTaskTip': 'Giữ các tệp đã tải về', 'deleteTaskTip': 'Giữ các tệp đã tải về',
'delete': 'Xóa', 'delete': 'Xóa',
'newVersionTitle': 'Khám phá phiên bản mới @version', 'newVersionTitle': 'Khám phá phiên bản mới @version',
+10 -2
View File
@@ -8,6 +8,7 @@ const zhCN = {
'on': '开启', 'on': '开启',
'off': '关闭', 'off': '关闭',
'selectAll': '全选', 'selectAll': '全选',
'select': '选择',
'task': '任务', 'task': '任务',
'downloading': '下载中', 'downloading': '下载中',
'downloaded': '已完成', 'downloaded': '已完成',
@@ -20,7 +21,7 @@ const zhCN = {
'followSettings': '跟随设置', 'followSettings': '跟随设置',
'downloadLink': '下载链接', 'downloadLink': '下载链接',
'downloadLinkValid': '请输入下载链接', 'downloadLinkValid': '请输入下载链接',
'downloadLinkHit': '请输入下载链接,支持 HTTP/HTTPS/MAGNET@append', 'downloadLinkHit': '请输入下载链接,支持批量下载,每行一个链接@append',
'downloadLinkHitDesktop': ',也可以直接拖拽种子文件到此处', 'downloadLinkHitDesktop': ',也可以直接拖拽种子文件到此处',
'download': '下载', 'download': '下载',
'noFileSelected': '请至少选择一个文件下载', 'noFileSelected': '请至少选择一个文件下载',
@@ -63,9 +64,11 @@ const zhCN = {
'developer': '开发者', 'developer': '开发者',
'logDirectory': '日志目录', 'logDirectory': '日志目录',
'show': '显示', 'show': '显示',
'continue': '继续',
'pause': '暂停',
'startAll': '全部开始', 'startAll': '全部开始',
'pauseAll': '全部暂停', 'pauseAll': '全部暂停',
'deleteTask': '删除任务', 'deleteTask': '删除 @count 个任务',
'deleteTaskTip': '保留已下载的文件', 'deleteTaskTip': '保留已下载的文件',
'delete': '删除', 'delete': '删除',
'newVersionTitle': '发现新版本 @version', 'newVersionTitle': '发现新版本 @version',
@@ -105,5 +108,10 @@ const zhCN = {
'taskUrl': '任务链接', 'taskUrl': '任务链接',
'downloadPath': '下载路径', 'downloadPath': '下载路径',
'skipVerifyCert': '跳过证书验证', 'skipVerifyCert': '跳过证书验证',
'name': '名称',
'size': '大小',
'unknown': '未知',
'fileSelectedCount': '文件数:',
'fileSelectedSize': '大小:',
} }
}; };
+10 -2
View File
@@ -8,6 +8,7 @@ const zhTW = {
'on': '開啟', 'on': '開啟',
'off': '關閉', 'off': '關閉',
'selectAll': '全選', 'selectAll': '全選',
'select': '選擇',
'task': '任務', 'task': '任務',
'downloading': '下載中', 'downloading': '下載中',
'downloaded': '已下載', 'downloaded': '已下載',
@@ -20,7 +21,7 @@ const zhTW = {
'followSettings': '跟隨設定', 'followSettings': '跟隨設定',
'downloadLink': '下載連結', 'downloadLink': '下載連結',
'downloadLinkValid': '請輸入下載連結', 'downloadLinkValid': '請輸入下載連結',
'downloadLinkHit': '請輸入下載連結,支援 HTTP/HTTPS/MAGNET@append', 'downloadLinkHit': '請輸入下載連結,支援批量下載,每行一個連結@append',
'downloadLinkHitDesktop': ',或直接拖曳種子檔到此處', 'downloadLinkHitDesktop': ',或直接拖曳種子檔到此處',
'download': '下載', 'download': '下載',
'noFileSelected': '請至少選擇一個檔案以繼續。', 'noFileSelected': '請至少選擇一個檔案以繼續。',
@@ -63,9 +64,11 @@ const zhTW = {
'developer': '開發者', 'developer': '開發者',
'logDirectory': '日誌目錄', 'logDirectory': '日誌目錄',
'show': '顯示', 'show': '顯示',
'continue': '繼續',
'pause': '暫停',
'startAll': '全部開始', 'startAll': '全部開始',
'pauseAll': '全部暫停', 'pauseAll': '全部暫停',
'deleteTask': '刪除任務', 'deleteTask': '刪除 @count 個任務',
'deleteTaskTip': '保留已下載的檔案', 'deleteTaskTip': '保留已下載的檔案',
'delete': '刪除', 'delete': '刪除',
'newVersionTitle': '發現新版本 @version', 'newVersionTitle': '發現新版本 @version',
@@ -105,5 +108,10 @@ const zhTW = {
'taskUrl': '任務連結', 'taskUrl': '任務連結',
'downloadPath': '下載路徑', 'downloadPath': '下載路徑',
'skipVerifyCert': '跳過憑證驗證', 'skipVerifyCert': '跳過憑證驗證',
'name': '名稱',
'size': '大小',
'unknown': '未知',
'fileSelectedCount': '文件數:',
'fileSelectedSize': '大小:',
} }
}; };
+56
View File
@@ -0,0 +1,56 @@
/// Flutter icons Gopeed
/// Copyright (C) 2024 by original authors @ fluttericon.com, fontello.com
/// This font was generated by FlutterIcon.com, which is derived from Fontello.
///
/// To use this font, place it in your fonts/ directory and include the
/// following in your pubspec.yaml
///
/// flutter:
/// fonts:
/// - family: Gopeed
/// fonts:
/// - asset: fonts/Gopeed.ttf
///
///
/// * Entypo, Copyright (C) 2012 by Daniel Bruce
/// Author: Daniel Bruce
/// License: SIL (http://scripts.sil.org/OFL)
/// Homepage: http://www.entypo.com
/// * Zocial, Copyright (C) 2012 by Sam Collins
/// Author: Sam Collins
/// License: MIT (http://opensource.org/licenses/mit-license.php)
/// Homepage: http://zocial.smcllns.com/
/// * Font Awesome 5, Copyright (C) 2016 by Dave Gandy
/// Author: Dave Gandy
/// License: SIL (https://github.com/FortAwesome/Font-Awesome/blob/master/LICENSE.txt)
/// Homepage: http://fortawesome.github.com/Font-Awesome/
///
import 'package:flutter/widgets.dart';
class Gopeed {
Gopeed._();
static const _kFontFam = 'Gopeed';
static const String? _kFontPkg = null;
static const IconData install = IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData android = IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData cd = IconData(0xe802, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData folder_bt = IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_bt = IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData folder = IconData(0xf07b, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData sort = IconData(0xf0dc, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData html5 = IconData(0xf13b, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file = IconData(0xf15b, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_alt = IconData(0xf15c, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_pdf = IconData(0xf1c1, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_word = IconData(0xf1c2, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_excel = IconData(0xf1c3, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_powerpoint = IconData(0xf1c4, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_image = IconData(0xf1c5, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_archive = IconData(0xf1c6, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_audio = IconData(0xf1c7, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_video = IconData(0xf1c8, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData file_code = IconData(0xf1c9, fontFamily: _kFontFam, fontPackage: _kFontPkg);
static const IconData app_store_ios = IconData(0xf370, fontFamily: _kFontFam, fontPackage: _kFontPkg);
}
Loaded 50 of 60 files, more files were not shown because too many files have changed in this diff. Show more