mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 01:58:50 +05:00
Compare commits
62 Commits
05311129f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 748bf975ca | |||
| 87dc2795ef | |||
| 06bd83278b | |||
|
|
dfebd7f9e6 | ||
|
|
6b59750621 | ||
|
|
02c2abd5fb | ||
|
|
1e5451859f | ||
|
|
93ce51e02a | ||
| c8ee6d75b2 | |||
|
|
1f0cf828da | ||
|
|
fa88fd20c8 | ||
| c9ea5527a8 | |||
|
|
1a610b8d8f | ||
| 499896b3dd | |||
|
|
3e664d726b | ||
|
|
0acf59ddd7 | ||
|
|
94b001e782 | ||
| 7828b378d7 | |||
|
|
23943f5206 | ||
|
|
78c321b0f0 | ||
|
|
9b84492db4 | ||
|
|
8179b39aa4 | ||
| 66032b681c | |||
|
|
016ef05fee | ||
|
|
13e7c0d0b0 | ||
|
|
3e1a9768d8 | ||
|
|
39f311d02e | ||
| 3081510f9e | |||
|
|
4596df1a2e | ||
|
|
86611976a7 | ||
|
|
e70c477238 | ||
|
|
7b8f64842a | ||
|
|
b167c73699 | ||
|
|
23a3068b37 | ||
|
|
fd296d800f | ||
|
|
c30b1b2464 | ||
|
|
13de6a5417 | ||
|
|
7201d2e7dc | ||
|
|
2ba77aee3a | ||
|
|
ca409fabdd | ||
|
|
90113d80b0 | ||
|
|
1e4b2f00ba | ||
|
|
82850b4556 | ||
|
|
a48f947d65 | ||
|
|
545b5e0d68 | ||
| 54a533f267 | |||
|
|
e4e56d76af | ||
|
|
4306a9038a | ||
|
|
275c8122a2 | ||
|
|
2f191dd302 | ||
| 143a5cf8a5 | |||
|
|
18295e1bc4 | ||
|
|
ab91ce7e46 | ||
|
|
5040ee731a | ||
|
|
db192b3c76 | ||
|
|
83842efb68 | ||
|
|
81bbaa62e2 | ||
|
|
1b28c5da45 | ||
| 6a8e226a72 | |||
| f4b497fb3f | |||
| de26fd3fc9 | |||
| 4ea75db105 |
2
.env
2
.env
@@ -1 +1 @@
|
|||||||
API_URL=https://neomovies-api.vercel.app
|
API_URL=https://api.neomovies.ru
|
||||||
|
|||||||
122
.github/workflows/flutter-ci.yml
vendored
122
.github/workflows/flutter-ci.yml
vendored
@@ -1,122 +0,0 @@
|
|||||||
# NeoMovies – GitHub Actions CI/CD for Flutter (Android APK + Linux desktop)
|
|
||||||
# Requires GitHub-hosted Ubuntu runners.
|
|
||||||
|
|
||||||
name: Flutter CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
release:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
env:
|
|
||||||
FLUTTER_VERSION: "3.22.1"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
test:
|
|
||||||
name: Test & Analyze
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Flutter ${{ env.FLUTTER_VERSION }} (beta)
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: beta
|
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
|
||||||
|
|
||||||
- name: Get dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Static analysis
|
|
||||||
run: flutter analyze --no-pub --fatal-infos --fatal-warnings
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: flutter test --coverage
|
|
||||||
|
|
||||||
- name: Upload coverage
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: coverage
|
|
||||||
path: coverage/
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
build_android:
|
|
||||||
name: Build Android APK
|
|
||||||
needs: test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: beta
|
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
|
||||||
|
|
||||||
- name: Get dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Build release APK & AAB
|
|
||||||
run: |
|
|
||||||
flutter build apk --release
|
|
||||||
flutter build appbundle --release
|
|
||||||
|
|
||||||
- name: Upload APK & AAB artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: android-build
|
|
||||||
path: |
|
|
||||||
build/app/outputs/flutter-apk/app-release.apk
|
|
||||||
build/app/outputs/bundle/release/app-release.aab
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
build_linux:
|
|
||||||
name: Build Linux desktop bundle
|
|
||||||
needs: test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: beta
|
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
|
||||||
|
|
||||||
- name: Install Linux build dependencies
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y libjsoncpp-dev libsecret-1-dev clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev
|
|
||||||
|
|
||||||
- name: Enable Linux desktop and get deps
|
|
||||||
run: |
|
|
||||||
flutter config --enable-linux-desktop
|
|
||||||
flutter pub get
|
|
||||||
|
|
||||||
- name: Build Linux release bundle
|
|
||||||
run: flutter build linux --release
|
|
||||||
|
|
||||||
- name: Upload Linux bundle artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: linux-build
|
|
||||||
path: build/linux/x64/release/bundle/
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
release_assets:
|
|
||||||
name: Attach assets to GitHub Release
|
|
||||||
if: github.event_name == 'release'
|
|
||||||
needs: [build_android, build_linux]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Download build artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
android-build/app-release.apk
|
|
||||||
android-build/app-release.aab
|
|
||||||
linux-build/**
|
|
||||||
75
.github/workflows/gitlab-mirror.yml
vendored
Normal file
75
.github/workflows/gitlab-mirror.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Full Mirror to GitLab
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, closed, edited]
|
||||||
|
issues:
|
||||||
|
types: [opened, edited, closed, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror-code:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Git
|
||||||
|
run: |
|
||||||
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
|
||||||
|
- name: Fetch GitLab branch
|
||||||
|
run: |
|
||||||
|
git remote add gitlab https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/foxixus/neomovies_mobile.git
|
||||||
|
git fetch gitlab main || true
|
||||||
|
|
||||||
|
- name: Check for differences with GitLab
|
||||||
|
id: diffcheck
|
||||||
|
run: |
|
||||||
|
if ! git rev-parse gitlab/main >/dev/null 2>&1; then
|
||||||
|
echo "has_diff=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
DIFF=$(git rev-list --left-right --count HEAD...gitlab/main | awk '{print $1}')
|
||||||
|
if [[ "$DIFF" -gt 0 ]]; then
|
||||||
|
echo "has_diff=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "has_diff=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push to GitLab if there are changes
|
||||||
|
if: steps.diffcheck.outputs.has_diff == 'true'
|
||||||
|
run: git push gitlab HEAD:main
|
||||||
|
|
||||||
|
mirror-issues:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'issues'
|
||||||
|
steps:
|
||||||
|
- name: Sync issue to GitLab
|
||||||
|
run: |
|
||||||
|
curl --request POST "https://gitlab.com/api/v4/projects/foxixus%2Fneomovies_mobile/issues" \
|
||||||
|
--header "PRIVATE-TOKEN: ${{ secrets.GITLAB_TOKEN }}" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "{
|
||||||
|
\"title\": \"${{ github.event.issue.title }}\",
|
||||||
|
\"description\": \"${{ github.event.issue.body }}\"
|
||||||
|
}"
|
||||||
|
|
||||||
|
mirror-prs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- name: Sync PR to GitLab MR
|
||||||
|
run: |
|
||||||
|
curl --request POST "https://gitlab.com/api/v4/projects/foxixus%2Fneomovies_mobile/merge_requests" \
|
||||||
|
--header "PRIVATE-TOKEN: ${{ secrets.GITLAB_TOKEN }}" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "{
|
||||||
|
\"title\": \"${{ github.event.pull_request.title }}\",
|
||||||
|
\"source_branch\": \"${{ github.event.pull_request.head.ref }}\",
|
||||||
|
\"target_branch\": \"${{ github.event.pull_request.base.ref }}\",
|
||||||
|
\"description\": \"${{ github.event.pull_request.body }}\"
|
||||||
|
}"
|
||||||
278
.github/workflows/release.yml
vendored
Normal file
278
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-arm64:
|
||||||
|
name: Build APK (ARM64)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.35.5'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build ARM64 APK
|
||||||
|
run: flutter build apk --release --target-platform android-arm64 --split-per-abi
|
||||||
|
|
||||||
|
- name: Upload ARM64 APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: apk-arm64
|
||||||
|
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
build-arm32:
|
||||||
|
name: Build APK (ARM32)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.35.5'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Update version from tag
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: |
|
||||||
|
VERSION_NAME=${GITHUB_REF#refs/tags/v}
|
||||||
|
BUILD_NUMBER=$(echo $VERSION_NAME | sed 's/[^0-9]//g')
|
||||||
|
echo "Updating version to $VERSION_NAME+$BUILD_NUMBER"
|
||||||
|
sed -i "s/^version: .*/version: $VERSION_NAME+$BUILD_NUMBER/" pubspec.yaml
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build ARM32 APK
|
||||||
|
run: flutter build apk --release --target-platform android-arm --split-per-abi
|
||||||
|
|
||||||
|
- name: Upload ARM32 APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: apk-arm32
|
||||||
|
path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
build-x64:
|
||||||
|
name: Build APK (x86_64)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.35.5'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build x86_64 APK
|
||||||
|
run: flutter build apk --release --target-platform android-x64 --split-per-abi
|
||||||
|
|
||||||
|
- name: Upload x86_64 APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: apk-x64
|
||||||
|
path: build/app/outputs/flutter-apk/app-x86_64-release.apk
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Create Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-arm64, build-arm32, build-x64]
|
||||||
|
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download ARM64 APK
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: apk-arm64
|
||||||
|
path: ./apks
|
||||||
|
|
||||||
|
- name: Download ARM32 APK
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: apk-arm32
|
||||||
|
path: ./apks
|
||||||
|
|
||||||
|
- name: Download x86_64 APK
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: apk-x64
|
||||||
|
path: ./apks
|
||||||
|
|
||||||
|
- name: Generate version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
else
|
||||||
|
VERSION="v0.0.${{ github.run_number }}"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
|
- name: Get file sizes
|
||||||
|
id: sizes
|
||||||
|
run: |
|
||||||
|
ARM64_SIZE=$(du -h ./apks/app-arm64-v8a-release.apk | cut -f1)
|
||||||
|
ARM32_SIZE=$(du -h ./apks/app-armeabi-v7a-release.apk | cut -f1)
|
||||||
|
X64_SIZE=$(du -h ./apks/app-x86_64-release.apk | cut -f1)
|
||||||
|
|
||||||
|
echo "arm64_size=$ARM64_SIZE" >> $GITHUB_OUTPUT
|
||||||
|
echo "arm32_size=$ARM32_SIZE" >> $GITHUB_OUTPUT
|
||||||
|
echo "x64_size=$X64_SIZE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create Release Notes
|
||||||
|
id: notes
|
||||||
|
run: |
|
||||||
|
cat << EOF > release_notes.md
|
||||||
|
## NeoMovies Mobile ${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
**Build Info:**
|
||||||
|
- Commit: \`${{ github.sha }}\`
|
||||||
|
- Branch: \`${{ github.ref_name }}\`
|
||||||
|
- Workflow Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||||
|
|
||||||
|
**Downloads:**
|
||||||
|
- **ARM64 (arm64-v8a)**: \`app-arm64-v8a-release.apk\` (${{ steps.sizes.outputs.arm64_size }}) - Recommended for modern devices
|
||||||
|
- **ARM32 (armeabi-v7a)**: \`app-armeabi-v7a-release.apk\` (${{ steps.sizes.outputs.arm32_size }}) - For older devices
|
||||||
|
- **x86_64**: \`app-x86_64-release.apk\` (${{ steps.sizes.outputs.x64_size }}) - For emulators
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
See the [full commit history](${{ github.server_url }}/${{ github.repository }}/compare/${{ github.event.before }}...${{ github.sha }})
|
||||||
|
EOF
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.version }}
|
||||||
|
name: NeoMovies ${{ steps.version.outputs.version }}
|
||||||
|
body_path: release_notes.md
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
make_latest: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
files: |
|
||||||
|
./apks/app-arm64-v8a-release.apk
|
||||||
|
./apks/app-armeabi-v7a-release.apk
|
||||||
|
./apks/app-x86_64-release.apk
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish to Telegram
|
||||||
|
run: |
|
||||||
|
# Prepare Telegram message
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
COMMIT_SHA="${{ github.sha }}"
|
||||||
|
BRANCH="${{ github.ref_name }}"
|
||||||
|
RUN_NUMBER="${{ github.run_number }}"
|
||||||
|
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
|
||||||
|
|
||||||
|
# Create message text
|
||||||
|
MESSAGE="🚀 *NeoMovies Mobile ${VERSION}*
|
||||||
|
|
||||||
|
📋 *Build Info:*
|
||||||
|
• Commit: \`${COMMIT_SHA:0:7}\`
|
||||||
|
• Branch: \`${BRANCH}\`
|
||||||
|
• Workflow Run: [#${RUN_NUMBER}](${REPO_URL}/actions/runs/${{ github.run_id }})
|
||||||
|
|
||||||
|
📦 *Downloads:*
|
||||||
|
• *ARM64 (arm64-v8a)*: ${{ steps.sizes.outputs.arm64_size }} - Recommended for modern devices
|
||||||
|
• *ARM32 (armeabi-v7a)*: ${{ steps.sizes.outputs.arm32_size }} - For older devices
|
||||||
|
• *x86_64*: ${{ steps.sizes.outputs.x64_size }} - For emulators
|
||||||
|
|
||||||
|
🔗 [View Release](${REPO_URL}/releases/tag/${VERSION})"
|
||||||
|
|
||||||
|
# Send message to Telegram
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"chat_id\": \"${{ secrets.TELEGRAM_CHAT_ID }}\",
|
||||||
|
\"text\": \"$MESSAGE\",
|
||||||
|
\"parse_mode\": \"Markdown\",
|
||||||
|
\"disable_web_page_preview\": true
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Send APK files
|
||||||
|
echo "Uploading ARM64 APK to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument" \
|
||||||
|
-F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
-F "document=@./apks/app-arm64-v8a-release.apk" \
|
||||||
|
-F "caption=📱 NeoMovies ${VERSION} - ARM64 (Recommended)"
|
||||||
|
|
||||||
|
echo "Uploading ARM32 APK to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument" \
|
||||||
|
-F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
-F "document=@./apks/app-armeabi-v7a-release.apk" \
|
||||||
|
-F "caption=📱 NeoMovies ${VERSION} - ARM32 (For older devices)"
|
||||||
|
|
||||||
|
echo "Uploading x86_64 APK to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument" \
|
||||||
|
-F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
-F "document=@./apks/app-x86_64-release.apk" \
|
||||||
|
-F "caption=📱 NeoMovies ${VERSION} - x86_64 (For emulators)"
|
||||||
|
|
||||||
|
echo "Telegram notification sent successfully!"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
echo "## Release Created Successfully!" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Release URL:** ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Telegram:** Published to channel" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### APK Files:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- ARM64: ${{ steps.sizes.outputs.arm64_size }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- ARM32: ${{ steps.sizes.outputs.arm32_size }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- x86_64: ${{ steps.sizes.outputs.x64_size }}" >> $GITHUB_STEP_SUMMARY
|
||||||
148
.github/workflows/test.yml
vendored
Normal file
148
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
name: Test and Analyze
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- 'feature/**'
|
||||||
|
- 'torrent-engine-downloads'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
flutter-analyze:
|
||||||
|
name: Flutter Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.19.6'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Run code generation
|
||||||
|
run: |
|
||||||
|
dart run build_runner build --delete-conflicting-outputs || true
|
||||||
|
|
||||||
|
- name: Run Flutter Analyze
|
||||||
|
run: flutter analyze --no-fatal-infos
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: dart format --set-exit-if-changed .
|
||||||
|
|
||||||
|
flutter-test:
|
||||||
|
name: Flutter Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.35.5'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: flutter test --coverage
|
||||||
|
|
||||||
|
- name: Run Integration tests
|
||||||
|
run: flutter test test/integration/ --reporter=expanded
|
||||||
|
env:
|
||||||
|
# Mark that we're running in CI
|
||||||
|
CI: true
|
||||||
|
GITHUB_ACTIONS: true
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v4
|
||||||
|
with:
|
||||||
|
files: ./coverage/lcov.info
|
||||||
|
fail_ci_if_error: false
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
android-lint:
|
||||||
|
name: Android Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.35.5'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Run Android Lint
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew lint
|
||||||
|
|
||||||
|
- name: Upload lint reports
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-lint-reports
|
||||||
|
path: |
|
||||||
|
android/app/build/reports/lint-*.html
|
||||||
|
android/torrentengine/build/reports/lint-*.html
|
||||||
|
retention-days: 7
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
build-debug:
|
||||||
|
name: Build Debug APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/dev'
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.35.5'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build Debug APK
|
||||||
|
run: flutter build apk --debug
|
||||||
|
|
||||||
|
- name: Upload Debug APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: apk-debug
|
||||||
|
path: build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
retention-days: 7
|
||||||
317
.gitlab-ci.yml
317
.gitlab-ci.yml
@@ -4,130 +4,261 @@ stages:
|
|||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
FLUTTER_VERSION: "3.16.0"
|
FLUTTER_VERSION: "stable"
|
||||||
ANDROID_SDK_VERSION: "34"
|
# Optimize for RAM usage
|
||||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
|
FLUTTER_BUILD_FLAGS: "--split-debug-info=./debug-symbols --obfuscate --dart-define=dart.vm.profile=false"
|
||||||
|
PUB_CACHE: "${CI_PROJECT_DIR}/.pub-cache"
|
||||||
|
|
||||||
# Кэш для оптимизации сборки
|
|
||||||
cache:
|
cache:
|
||||||
key: flutter-cache
|
|
||||||
paths:
|
paths:
|
||||||
- .pub-cache/
|
- .pub-cache/
|
||||||
- android/.gradle/
|
|
||||||
- build/
|
|
||||||
|
|
||||||
# Тестирование
|
# Test stage - runs first to catch issues early
|
||||||
test:
|
test:dart:
|
||||||
stage: test
|
stage: test
|
||||||
image: cirrusci/flutter:${FLUTTER_VERSION}
|
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
|
||||||
before_script:
|
script:
|
||||||
- flutter --version
|
- flutter --version
|
||||||
- flutter pub get
|
- flutter pub get
|
||||||
script:
|
- flutter analyze --fatal-warnings
|
||||||
- flutter analyze
|
- flutter test --coverage
|
||||||
- flutter test
|
- flutter build web --release --dart-define=dart.vm.profile=false
|
||||||
artifacts:
|
artifacts:
|
||||||
reports:
|
reports:
|
||||||
junit: test-results.xml
|
coverage_report:
|
||||||
|
coverage_format: cobertura
|
||||||
|
path: coverage/cobertura.xml
|
||||||
paths:
|
paths:
|
||||||
- coverage/
|
- coverage/
|
||||||
expire_in: 1 week
|
- build/web/
|
||||||
|
expire_in: 7 days
|
||||||
|
rules:
|
||||||
|
- if: $CI_MERGE_REQUEST_IID
|
||||||
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
|
||||||
# Сборка Android APK
|
build:apk:arm64:
|
||||||
build_android:
|
|
||||||
stage: build
|
stage: build
|
||||||
image: cirrusci/flutter:${FLUTTER_VERSION}
|
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
|
||||||
before_script:
|
|
||||||
- flutter --version
|
|
||||||
- flutter pub get
|
|
||||||
script:
|
script:
|
||||||
- flutter build apk --release
|
- flutter pub get
|
||||||
- flutter build appbundle --release
|
- mkdir -p debug-symbols
|
||||||
|
- flutter build apk --release --target-platform android-arm64 --split-per-abi ${FLUTTER_BUILD_FLAGS}
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- build/app/outputs/flutter-apk/app-release.apk
|
- build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||||
- build/app/outputs/bundle/release/app-release.aab
|
expire_in: 30 days
|
||||||
expire_in: 1 month
|
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_COMMIT_BRANCH'
|
- if: $CI_COMMIT_TAG
|
||||||
- if: '$CI_COMMIT_TAG'
|
when: always
|
||||||
|
- if: $CI_COMMIT_BRANCH =~ /^dev|main/
|
||||||
|
when: on_success
|
||||||
|
|
||||||
# Сборка Linux приложения
|
build:apk:arm:
|
||||||
build_linux:
|
|
||||||
stage: build
|
stage: build
|
||||||
image: ubuntu:22.04
|
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
|
||||||
before_script:
|
before_script:
|
||||||
- apt-get update -y
|
# Update version from tag if present
|
||||||
- apt-get install -y curl git unzip xz-utils zip libglu1-mesa
|
- |
|
||||||
- apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
- apt-get install -y libblkid-dev liblzma-dev
|
VERSION_NAME="${CI_COMMIT_TAG#v}"
|
||||||
# Установка Flutter
|
BUILD_NUMBER=$(echo $CI_COMMIT_TAG | sed 's/[^0-9]//g')
|
||||||
- git clone https://github.com/flutter/flutter.git -b stable --depth 1
|
echo "Updating version to $VERSION_NAME+$BUILD_NUMBER"
|
||||||
- export PATH="$PATH:`pwd`/flutter/bin"
|
sed -i "s/^version: .*/version: $VERSION_NAME+$BUILD_NUMBER/" pubspec.yaml
|
||||||
- flutter --version
|
fi
|
||||||
- flutter config --enable-linux-desktop
|
|
||||||
- flutter pub get
|
|
||||||
script:
|
script:
|
||||||
- flutter build linux --release
|
- flutter pub get
|
||||||
|
- mkdir -p debug-symbols
|
||||||
|
- flutter build apk --release --target-platform android-arm --split-per-abi ${FLUTTER_BUILD_FLAGS}
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- build/linux/x64/release/bundle/
|
- build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||||
expire_in: 1 month
|
expire_in: 30 days
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_COMMIT_BRANCH'
|
- if: $CI_COMMIT_TAG
|
||||||
- if: '$CI_COMMIT_TAG'
|
when: always
|
||||||
|
- if: $CI_COMMIT_BRANCH =~ /^dev|main/
|
||||||
|
when: on_success
|
||||||
|
|
||||||
# Деплой в Google Play (опционально)
|
build:apk:x64:
|
||||||
deploy_android:
|
stage: build
|
||||||
stage: deploy
|
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
|
||||||
image: ruby:3.0
|
|
||||||
before_script:
|
|
||||||
- gem install fastlane
|
|
||||||
script:
|
script:
|
||||||
- cd android
|
- flutter pub get
|
||||||
- fastlane supply --aab ../build/app/outputs/bundle/release/app-release.aab
|
- mkdir -p debug-symbols
|
||||||
dependencies:
|
- flutter build apk --release --target-platform android-x64 --split-per-abi ${FLUTTER_BUILD_FLAGS}
|
||||||
- build_android
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- build/app/outputs/flutter-apk/app-x86_64-release.apk
|
||||||
|
expire_in: 30 days
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_COMMIT_TAG'
|
- if: $CI_COMMIT_TAG
|
||||||
when: manual
|
when: always
|
||||||
|
- if: $CI_COMMIT_BRANCH =~ /^dev|main/
|
||||||
|
when: on_success
|
||||||
|
|
||||||
# Деплой Linux приложения в GitLab Package Registry
|
deploy:release:
|
||||||
deploy_linux:
|
|
||||||
stage: deploy
|
stage: deploy
|
||||||
image: ubuntu:22.04
|
image: alpine:latest
|
||||||
|
needs:
|
||||||
|
- build:apk:arm64
|
||||||
|
- build:apk:arm
|
||||||
|
- build:apk:x64
|
||||||
before_script:
|
before_script:
|
||||||
- apt-get update -y
|
- apk add --no-cache curl jq coreutils
|
||||||
- apt-get install -y curl zip
|
|
||||||
script:
|
|
||||||
- cd build/linux/x64/release/bundle
|
|
||||||
- zip -r ../../../../../${CI_PROJECT_NAME}-linux-${CI_COMMIT_TAG}.zip .
|
|
||||||
- cd ../../../../../
|
|
||||||
- |
|
|
||||||
curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
|
|
||||||
--upload-file ${CI_PROJECT_NAME}-linux-${CI_COMMIT_TAG}.zip \
|
|
||||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${CI_PROJECT_NAME}/${CI_COMMIT_TAG}/${CI_PROJECT_NAME}-linux-${CI_COMMIT_TAG}.zip"
|
|
||||||
dependencies:
|
|
||||||
- build_linux
|
|
||||||
rules:
|
|
||||||
- if: '$CI_COMMIT_TAG'
|
|
||||||
when: manual
|
|
||||||
|
|
||||||
# Релиз на GitLab
|
|
||||||
release:
|
|
||||||
stage: deploy
|
|
||||||
image: registry.gitlab.com/gitlab-org/release-cli:latest
|
|
||||||
script:
|
script:
|
||||||
- |
|
- |
|
||||||
release-cli create \
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
--name "Release $CI_COMMIT_TAG" \
|
VERSION="$CI_COMMIT_TAG"
|
||||||
--tag-name $CI_COMMIT_TAG \
|
else
|
||||||
--description "Release $CI_COMMIT_TAG" \
|
VERSION="v0.0.${CI_PIPELINE_ID}"
|
||||||
--assets-link "{\"name\":\"Android APK\",\"url\":\"${CI_PROJECT_URL}/-/jobs/artifacts/$CI_COMMIT_TAG/download?job=build_android\"}" \
|
fi
|
||||||
--assets-link "{\"name\":\"Linux App\",\"url\":\"${CI_PROJECT_URL}/-/jobs/artifacts/$CI_COMMIT_TAG/download?job=build_linux\"}"
|
|
||||||
dependencies:
|
echo "Creating GitLab Release: $VERSION"
|
||||||
- build_android
|
echo "Commit: ${CI_COMMIT_SHORT_SHA}"
|
||||||
- build_linux
|
echo "Branch: ${CI_COMMIT_BRANCH}"
|
||||||
|
|
||||||
|
APK_ARM64="build/app/outputs/flutter-apk/app-arm64-v8a-release.apk"
|
||||||
|
APK_ARM32="build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk"
|
||||||
|
APK_X86="build/app/outputs/flutter-apk/app-x86_64-release.apk"
|
||||||
|
|
||||||
|
RELEASE_DESCRIPTION="## NeoMovies Mobile ${VERSION}
|
||||||
|
|
||||||
|
**Build Info:**
|
||||||
|
- Commit: \`${CI_COMMIT_SHORT_SHA}\`
|
||||||
|
- Branch: \`${CI_COMMIT_BRANCH}\`
|
||||||
|
- Pipeline: [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL})
|
||||||
|
|
||||||
|
**Downloads:**"
|
||||||
|
|
||||||
|
FILE_COUNT=0
|
||||||
|
|
||||||
|
if [ -f "$APK_ARM64" ]; then
|
||||||
|
FILE_COUNT=$((FILE_COUNT+1))
|
||||||
|
SIZE_ARM64=$(du -h "$APK_ARM64" | cut -f1)
|
||||||
|
RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- ARM64 (arm64-v8a): \`app-arm64-v8a-release.apk\` (${SIZE_ARM64}) - Recommended for modern devices"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$APK_ARM32" ]; then
|
||||||
|
FILE_COUNT=$((FILE_COUNT+1))
|
||||||
|
SIZE_ARM32=$(du -h "$APK_ARM32" | cut -f1)
|
||||||
|
RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- ARM32 (armeabi-v7a): \`app-armeabi-v7a-release.apk\` (${SIZE_ARM32}) - For older devices"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$APK_X86" ]; then
|
||||||
|
FILE_COUNT=$((FILE_COUNT+1))
|
||||||
|
SIZE_X86=$(du -h "$APK_X86" | cut -f1)
|
||||||
|
RELEASE_DESCRIPTION="${RELEASE_DESCRIPTION}\n- x86_64: \`app-x86_64-release.apk\` (${SIZE_X86}) - For emulators"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $FILE_COUNT -eq 0 ]; then
|
||||||
|
echo "No release artifacts found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found $FILE_COUNT artifact(s) to release"
|
||||||
|
|
||||||
|
RELEASE_DATA=$(jq -n \
|
||||||
|
--arg name "NeoMovies ${VERSION}" \
|
||||||
|
--arg tag "${VERSION}" \
|
||||||
|
--arg desc "$RELEASE_DESCRIPTION" \
|
||||||
|
--arg ref "${CI_COMMIT_SHA}" \
|
||||||
|
'{name: $name, tag_name: $tag, description: $desc, ref: $ref}')
|
||||||
|
|
||||||
|
echo "Creating release via GitLab API..."
|
||||||
|
|
||||||
|
curl --fail-with-body -s -X POST \
|
||||||
|
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases" \
|
||||||
|
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "$RELEASE_DATA" || \
|
||||||
|
curl -s -X PUT \
|
||||||
|
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}" \
|
||||||
|
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "$RELEASE_DATA"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Uploading APK files to Package Registry..."
|
||||||
|
|
||||||
|
if [ -f "$APK_ARM64" ]; then
|
||||||
|
echo "Uploading app-arm64-v8a-release.apk..."
|
||||||
|
curl --fail -s --request PUT \
|
||||||
|
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
|
||||||
|
--upload-file "$APK_ARM64" \
|
||||||
|
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-arm64-v8a-release.apk"
|
||||||
|
|
||||||
|
LINK_DATA=$(jq -n \
|
||||||
|
--arg name "app-arm64-v8a-release.apk" \
|
||||||
|
--arg url "${CI_PROJECT_URL}/-/package_files/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-arm64-v8a-release.apk" \
|
||||||
|
--arg type "package" \
|
||||||
|
'{name: $name, url: $url, link_type: $type}')
|
||||||
|
|
||||||
|
curl -s --request POST \
|
||||||
|
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "$LINK_DATA" \
|
||||||
|
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}/assets/links"
|
||||||
|
|
||||||
|
echo "ARM64 APK uploaded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$APK_ARM32" ]; then
|
||||||
|
echo "Uploading app-armeabi-v7a-release.apk..."
|
||||||
|
curl --fail -s --request PUT \
|
||||||
|
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
|
||||||
|
--upload-file "$APK_ARM32" \
|
||||||
|
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-armeabi-v7a-release.apk"
|
||||||
|
|
||||||
|
LINK_DATA=$(jq -n \
|
||||||
|
--arg name "app-armeabi-v7a-release.apk" \
|
||||||
|
--arg url "${CI_PROJECT_URL}/-/package_files/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-armeabi-v7a-release.apk" \
|
||||||
|
--arg type "package" \
|
||||||
|
'{name: $name, url: $url, link_type: $type}')
|
||||||
|
|
||||||
|
curl -s --request POST \
|
||||||
|
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "$LINK_DATA" \
|
||||||
|
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}/assets/links"
|
||||||
|
|
||||||
|
echo "ARM32 APK uploaded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$APK_X86" ]; then
|
||||||
|
echo "Uploading app-x86_64-release.apk..."
|
||||||
|
curl --fail -s --request PUT \
|
||||||
|
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
|
||||||
|
--upload-file "$APK_X86" \
|
||||||
|
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-x86_64-release.apk"
|
||||||
|
|
||||||
|
LINK_DATA=$(jq -n \
|
||||||
|
--arg name "app-x86_64-release.apk" \
|
||||||
|
--arg url "${CI_PROJECT_URL}/-/package_files/${CI_PROJECT_ID}/packages/generic/neomovies/${VERSION}/app-x86_64-release.apk" \
|
||||||
|
--arg type "package" \
|
||||||
|
'{name: $name, url: $url, link_type: $type}')
|
||||||
|
|
||||||
|
curl -s --request POST \
|
||||||
|
--header "PRIVATE-TOKEN: ${CI_JOB_TOKEN}" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "$LINK_DATA" \
|
||||||
|
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases/${VERSION}/assets/links"
|
||||||
|
|
||||||
|
echo "x86_64 APK uploaded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================================================"
|
||||||
|
echo "Release created successfully!"
|
||||||
|
echo "View release: ${CI_PROJECT_URL}/-/releases/${VERSION}"
|
||||||
|
echo "Pipeline artifacts: ${CI_JOB_URL}/artifacts/browse"
|
||||||
|
echo "================================================"
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- build/app/outputs/flutter-apk/*.apk
|
||||||
|
expire_in: 90 days
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_COMMIT_TAG'
|
- if: $CI_COMMIT_TAG
|
||||||
when: manual
|
when: always
|
||||||
|
- if: $CI_COMMIT_BRANCH =~ /^dev|main/
|
||||||
|
when: on_success
|
||||||
44
README.md
44
README.md
@@ -2,25 +2,7 @@
|
|||||||
|
|
||||||
Мобильное приложение для просмотра фильмов и сериалов, созданное на Flutter.
|
Мобильное приложение для просмотра фильмов и сериалов, созданное на Flutter.
|
||||||
|
|
||||||
## Возможности
|
[](https://github.com/Neo-Open-Source/neomovies-mobile/releases/latest)
|
||||||
|
|
||||||
- 📱 Кроссплатформенное приложение (Android/iOS(пока не реализовано))
|
|
||||||
- 🎥 Просмотр фильмов и сериалов через WebView
|
|
||||||
- 🌙 Поддержка динамической темы
|
|
||||||
- 💾 Локальное кэширование данных
|
|
||||||
- 🔒 Безопасное хранение данных
|
|
||||||
- 🚀 Быстрая загрузка контента
|
|
||||||
- 🎨 Современный Material Design интерфейс
|
|
||||||
|
|
||||||
## Технологии
|
|
||||||
|
|
||||||
- **Flutter** - основной фреймворк
|
|
||||||
- **Provider** - управление состоянием
|
|
||||||
- **Hive** - локальная база данных
|
|
||||||
- **HTTP** - сетевые запросы
|
|
||||||
- **WebView** - воспроизведение видео
|
|
||||||
- **Cached Network Image** - кэширование изображений
|
|
||||||
- **Google Fonts** - красивые шрифты
|
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -37,7 +19,7 @@ flutter pub get
|
|||||||
|
|
||||||
3. Создайте файл `.env` в корне проекта:
|
3. Создайте файл `.env` в корне проекта:
|
||||||
```
|
```
|
||||||
API_URL=your_api_url_here
|
API_URL=api.neomovies.ru
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Запустите приложение:
|
4. Запустите приложение:
|
||||||
@@ -52,11 +34,6 @@ flutter run
|
|||||||
flutter build apk --release
|
flutter build apk --release
|
||||||
```
|
```
|
||||||
|
|
||||||
### iOS
|
|
||||||
```bash
|
|
||||||
flutter build ios --release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -75,20 +52,15 @@ lib/
|
|||||||
- **Flutter SDK**: 3.8.1+
|
- **Flutter SDK**: 3.8.1+
|
||||||
- **Dart**: 3.8.1+
|
- **Dart**: 3.8.1+
|
||||||
- **Android**: API 21+ (Android 5.0+)
|
- **Android**: API 21+ (Android 5.0+)
|
||||||
- **iOS**: iOS 11.0+
|
|
||||||
|
|
||||||
## Участие в разработке
|
|
||||||
|
|
||||||
1. Форкните репозиторий
|
|
||||||
2. Создайте ветку для новой функции (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Внесите изменения и закоммитьте (`git commit -m 'Add amazing feature'`)
|
|
||||||
4. Отправьте изменения в ветку (`git push origin feature/amazing-feature`)
|
|
||||||
5. Создайте Pull Request
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
Этот проект лицензирован под Apache 2.0 License - подробности в файле [LICENSE](LICENSE).
|
Apache 2.0 License - [LICENSE](LICENSE).
|
||||||
|
|
||||||
## Контакты
|
## Контакты
|
||||||
|
|
||||||
Если у вас есть вопросы или предложения, создайте issue в этом репозитории.
|
neo.movies.mail@gmail.com
|
||||||
|
|
||||||
|
## Благодарность
|
||||||
|
|
||||||
|
Огромная благодарность создателям проекта [LAMPAC](https://github.com/immisterio/Lampac)
|
||||||
3
android/.gitignore
vendored
3
android/.gitignore
vendored
@@ -1,8 +1,5 @@
|
|||||||
gradle-wrapper.jar
|
|
||||||
/.gradle
|
/.gradle
|
||||||
/captures/
|
/captures/
|
||||||
/gradlew
|
|
||||||
/gradlew.bat
|
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|||||||
@@ -6,22 +6,22 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.neomovies_mobile"
|
namespace = "com.neo.neomovies_mobile"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = "27.0.12077973"
|
// ndkVersion = "27.0.12077973" // Commented out to avoid license issues
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.example.neomovies_mobile"
|
applicationId = "com.neo.neomovies_mobile"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -42,3 +42,18 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// TorrentEngine library module
|
||||||
|
implementation(project(":torrentengine"))
|
||||||
|
|
||||||
|
// Kotlin Coroutines
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
|
||||||
|
|
||||||
|
// Gson для JSON сериализации
|
||||||
|
implementation("com.google.code.gson:gson:2.11.0")
|
||||||
|
|
||||||
|
// AndroidX libraries
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Разрешения для работы с торрентами -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<!-- Queries for url_launcher -->
|
<!-- Queries for url_launcher -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package com.example.neomovies_mobile
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package com.neo.neomovies_mobile
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.neomovies.torrentengine.TorrentEngine
|
||||||
|
import com.neomovies.torrentengine.models.FilePriority
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MainActivity"
|
||||||
|
private const val TORRENT_CHANNEL = "com.neo.neomovies_mobile/torrent"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
private val gson = Gson()
|
||||||
|
private lateinit var torrentEngine: TorrentEngine
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
// Initialize TorrentEngine
|
||||||
|
torrentEngine = TorrentEngine.getInstance(applicationContext)
|
||||||
|
torrentEngine.startStatsUpdater()
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TORRENT_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"addTorrent" -> {
|
||||||
|
val magnetUri = call.argument<String>("magnetUri")
|
||||||
|
val savePath = call.argument<String>("savePath")
|
||||||
|
if (magnetUri != null && savePath != null) {
|
||||||
|
addTorrent(magnetUri, savePath, result)
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_ARGUMENT", "magnetUri and savePath are required", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"getTorrents" -> getTorrents(result)
|
||||||
|
"getTorrent" -> {
|
||||||
|
val infoHash = call.argument<String>("infoHash")
|
||||||
|
if (infoHash != null) getTorrent(infoHash, result)
|
||||||
|
else result.error("INVALID_ARGUMENT", "infoHash is required", null)
|
||||||
|
}
|
||||||
|
"pauseTorrent" -> {
|
||||||
|
val infoHash = call.argument<String>("infoHash")
|
||||||
|
if (infoHash != null) pauseTorrent(infoHash, result)
|
||||||
|
else result.error("INVALID_ARGUMENT", "infoHash is required", null)
|
||||||
|
}
|
||||||
|
"resumeTorrent" -> {
|
||||||
|
val infoHash = call.argument<String>("infoHash")
|
||||||
|
if (infoHash != null) resumeTorrent(infoHash, result)
|
||||||
|
else result.error("INVALID_ARGUMENT", "infoHash is required", null)
|
||||||
|
}
|
||||||
|
"removeTorrent" -> {
|
||||||
|
val infoHash = call.argument<String>("infoHash")
|
||||||
|
val deleteFiles = call.argument<Boolean>("deleteFiles") ?: false
|
||||||
|
if (infoHash != null) removeTorrent(infoHash, deleteFiles, result)
|
||||||
|
else result.error("INVALID_ARGUMENT", "infoHash is required", null)
|
||||||
|
}
|
||||||
|
"setFilePriority" -> {
|
||||||
|
val infoHash = call.argument<String>("infoHash")
|
||||||
|
val fileIndex = call.argument<Int>("fileIndex")
|
||||||
|
val priority = call.argument<Int>("priority")
|
||||||
|
if (infoHash != null && fileIndex != null && priority != null) {
|
||||||
|
setFilePriority(infoHash, fileIndex, priority, result)
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_ARGUMENT", "infoHash, fileIndex, and priority are required", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addTorrent(magnetUri: String, savePath: String, result: MethodChannel.Result) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val infoHash = withContext(Dispatchers.IO) {
|
||||||
|
torrentEngine.addTorrent(magnetUri, savePath)
|
||||||
|
}
|
||||||
|
result.success(infoHash)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to add torrent", e)
|
||||||
|
result.error("ADD_TORRENT_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTorrents(result: MethodChannel.Result) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val torrents = withContext(Dispatchers.IO) {
|
||||||
|
torrentEngine.getAllTorrents()
|
||||||
|
}
|
||||||
|
val torrentsJson = torrents.map { torrent ->
|
||||||
|
mapOf(
|
||||||
|
"infoHash" to torrent.infoHash,
|
||||||
|
"name" to torrent.name,
|
||||||
|
"magnetUri" to torrent.magnetUri,
|
||||||
|
"totalSize" to torrent.totalSize,
|
||||||
|
"downloadedSize" to torrent.downloadedSize,
|
||||||
|
"uploadedSize" to torrent.uploadedSize,
|
||||||
|
"downloadSpeed" to torrent.downloadSpeed,
|
||||||
|
"uploadSpeed" to torrent.uploadSpeed,
|
||||||
|
"progress" to torrent.progress,
|
||||||
|
"state" to torrent.state.name,
|
||||||
|
"numPeers" to torrent.numPeers,
|
||||||
|
"numSeeds" to torrent.numSeeds,
|
||||||
|
"savePath" to torrent.savePath,
|
||||||
|
"files" to torrent.files.map { file ->
|
||||||
|
mapOf(
|
||||||
|
"index" to file.index,
|
||||||
|
"path" to file.path,
|
||||||
|
"size" to file.size,
|
||||||
|
"downloaded" to file.downloaded,
|
||||||
|
"priority" to file.priority.value,
|
||||||
|
"progress" to file.progress
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"addedDate" to torrent.addedDate,
|
||||||
|
"error" to torrent.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.success(gson.toJson(torrentsJson))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to get torrents", e)
|
||||||
|
result.error("GET_TORRENTS_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTorrent(infoHash: String, result: MethodChannel.Result) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val torrent = withContext(Dispatchers.IO) {
|
||||||
|
torrentEngine.getTorrent(infoHash)
|
||||||
|
}
|
||||||
|
if (torrent != null) {
|
||||||
|
val torrentJson = mapOf(
|
||||||
|
"infoHash" to torrent.infoHash,
|
||||||
|
"name" to torrent.name,
|
||||||
|
"magnetUri" to torrent.magnetUri,
|
||||||
|
"totalSize" to torrent.totalSize,
|
||||||
|
"downloadedSize" to torrent.downloadedSize,
|
||||||
|
"uploadedSize" to torrent.uploadedSize,
|
||||||
|
"downloadSpeed" to torrent.downloadSpeed,
|
||||||
|
"uploadSpeed" to torrent.uploadSpeed,
|
||||||
|
"progress" to torrent.progress,
|
||||||
|
"state" to torrent.state.name,
|
||||||
|
"numPeers" to torrent.numPeers,
|
||||||
|
"numSeeds" to torrent.numSeeds,
|
||||||
|
"savePath" to torrent.savePath,
|
||||||
|
"files" to torrent.files.map { file ->
|
||||||
|
mapOf(
|
||||||
|
"index" to file.index,
|
||||||
|
"path" to file.path,
|
||||||
|
"size" to file.size,
|
||||||
|
"downloaded" to file.downloaded,
|
||||||
|
"priority" to file.priority.value,
|
||||||
|
"progress" to file.progress
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"addedDate" to torrent.addedDate,
|
||||||
|
"error" to torrent.error
|
||||||
|
)
|
||||||
|
result.success(gson.toJson(torrentJson))
|
||||||
|
} else {
|
||||||
|
result.error("NOT_FOUND", "Torrent not found", null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to get torrent", e)
|
||||||
|
result.error("GET_TORRENT_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pauseTorrent(infoHash: String, result: MethodChannel.Result) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
torrentEngine.pauseTorrent(infoHash)
|
||||||
|
}
|
||||||
|
result.success(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to pause torrent", e)
|
||||||
|
result.error("PAUSE_TORRENT_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resumeTorrent(infoHash: String, result: MethodChannel.Result) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
torrentEngine.resumeTorrent(infoHash)
|
||||||
|
}
|
||||||
|
result.success(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to resume torrent", e)
|
||||||
|
result.error("RESUME_TORRENT_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeTorrent(infoHash: String, deleteFiles: Boolean, result: MethodChannel.Result) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
torrentEngine.removeTorrent(infoHash, deleteFiles)
|
||||||
|
}
|
||||||
|
result.success(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to remove torrent", e)
|
||||||
|
result.error("REMOVE_TORRENT_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setFilePriority(infoHash: String, fileIndex: Int, priorityValue: Int, result: MethodChannel.Result) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val priority = FilePriority.fromValue(priorityValue)
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
torrentEngine.setFilePriority(infoHash, fileIndex, priority)
|
||||||
|
}
|
||||||
|
result.success(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to set file priority", e)
|
||||||
|
result.error("SET_PRIORITY_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
coroutineScope.cancel()
|
||||||
|
torrentEngine.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,20 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
# Gradle JVM settings - optimized for limited RAM
|
||||||
|
org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.daemon=true
|
||||||
|
org.gradle.parallel=false
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.configureondemand=true
|
||||||
|
|
||||||
|
# Android settings
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
android.enableR8.fullMode=false
|
||||||
|
|
||||||
|
# Kotlin settings
|
||||||
|
kotlin.daemon.jvmargs=-Xmx1G -XX:MaxMetaspaceSize=512m
|
||||||
|
kotlin.incremental=true
|
||||||
|
kotlin.incremental.usePreciseJavaTracking=true
|
||||||
|
|
||||||
|
# Build optimization
|
||||||
|
# android.enableBuildCache=true # Deprecated in AGP 7.0+, use org.gradle.caching instead
|
||||||
|
org.gradle.vfs.watch=false
|
||||||
|
|||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Executable file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Executable file
Binary file not shown.
160
android/gradlew
vendored
Executable file
160
android/gradlew
vendored
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS=""
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn ( ) {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die ( ) {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin, switch paths to Windows format before running java
|
||||||
|
if $cygwin ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
(0) set -- ;;
|
||||||
|
(1) set -- "$args0" ;;
|
||||||
|
(2) set -- "$args0" "$args1" ;;
|
||||||
|
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||||
|
function splitJvmOpts() {
|
||||||
|
JVM_OPTS=("$@")
|
||||||
|
}
|
||||||
|
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||||
|
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||||
90
android/gradlew.bat
vendored
Executable file
90
android/gradlew.bat
vendored
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:init
|
||||||
|
@rem Get command-line arguments, handling Windowz variants
|
||||||
|
|
||||||
|
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||||
|
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||||
|
|
||||||
|
:win9xME_args
|
||||||
|
@rem Slurp the command line arguments.
|
||||||
|
set CMD_LINE_ARGS=
|
||||||
|
set _SKIP=2
|
||||||
|
|
||||||
|
:win9xME_args_slurp
|
||||||
|
if "x%~1" == "x" goto execute
|
||||||
|
|
||||||
|
set CMD_LINE_ARGS=%*
|
||||||
|
goto execute
|
||||||
|
|
||||||
|
:4NT_args
|
||||||
|
@rem Get arguments from the 4NT Shell from JP Software
|
||||||
|
set CMD_LINE_ARGS=%$
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
33
android/settings.gradle
Normal file
33
android/settings.gradle
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Legacy settings.gradle file for CI compatibility
|
||||||
|
// Main configuration is in settings.gradle.kts
|
||||||
|
|
||||||
|
pluginManagement {
|
||||||
|
def flutterSdkPath = {
|
||||||
|
def properties = new Properties()
|
||||||
|
file("local.properties").withInputStream { properties.load(it) }
|
||||||
|
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||||
|
return flutterSdkPath
|
||||||
|
}()
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
|
id "com.android.application" version "8.7.3" apply false
|
||||||
|
id "com.android.library" version "8.7.3" apply false
|
||||||
|
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include ":app"
|
||||||
|
project(":app").projectDir = file("app")
|
||||||
|
|
||||||
|
include ":torrentengine"
|
||||||
|
project(":torrentengine").projectDir = file("torrentengine")
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
pluginManagement {
|
pluginManagement {
|
||||||
val flutterSdkPath = run {
|
val flutterSdkPath = run {
|
||||||
val properties = java.util.Properties()
|
val properties = java.util.Properties()
|
||||||
file("local.properties").inputStream().use { properties.load(it) }
|
val localPropertiesFile = file("local.properties")
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localPropertiesFile.inputStream().use { properties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get from local.properties first, then from environment variable
|
||||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
?: System.getenv("FLUTTER_ROOT")
|
||||||
|
?: System.getenv("FLUTTER_SDK")
|
||||||
|
?: "/opt/flutter" // Default path in CI
|
||||||
|
|
||||||
flutterSdkPath
|
flutterSdkPath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +27,9 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.7.3" apply false
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("com.android.library") version "8.7.3" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":torrentengine")
|
||||||
|
|||||||
201
android/torrentengine/LICENSE
Normal file
201
android/torrentengine/LICENSE
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2025 NeoMovies
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
206
android/torrentengine/README.md
Normal file
206
android/torrentengine/README.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# TorrentEngine Library
|
||||||
|
|
||||||
|
Либа для моего клиента и других независимых проектов где нужен простой торрент движок.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Добавьте модуль в `settings.gradle.kts`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
include(":torrentengine")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Добавьте зависимость в `app/build.gradle.kts`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":torrentengine"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Добавьте permissions в `AndroidManifest.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Инициализация
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val torrentEngine = TorrentEngine.getInstance(context)
|
||||||
|
torrentEngine.startStatsUpdater() // Запустить обновление статистики
|
||||||
|
```
|
||||||
|
|
||||||
|
### Добавление торрента
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val magnetUri = "magnet:?xt=urn:btih:..."
|
||||||
|
val savePath = "${context.getExternalFilesDir(null)}/downloads"
|
||||||
|
|
||||||
|
val infoHash = torrentEngine.addTorrent(magnetUri, savePath)
|
||||||
|
Log.d("Torrent", "Added: $infoHash")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Torrent", "Failed to add", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение списка торрентов (реактивно)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
lifecycleScope.launch {
|
||||||
|
torrentEngine.getAllTorrentsFlow().collect { torrents ->
|
||||||
|
torrents.forEach { torrent ->
|
||||||
|
println("${torrent.name}: ${torrent.progress * 100}%")
|
||||||
|
println("Speed: ${torrent.downloadSpeed} B/s")
|
||||||
|
println("Peers: ${torrent.numPeers}, Seeds: ${torrent.numSeeds}")
|
||||||
|
println("ETA: ${torrent.getFormattedEta()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Управление файлами в раздаче
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
lifecycleScope.launch {
|
||||||
|
// Получить информацию о торренте
|
||||||
|
val torrent = torrentEngine.getTorrent(infoHash)
|
||||||
|
|
||||||
|
torrent?.files?.forEachIndexed { index, file ->
|
||||||
|
println("File $index: ${file.path} (${file.size} bytes)")
|
||||||
|
|
||||||
|
// Выбрать только видео файлы
|
||||||
|
if (file.isVideo()) {
|
||||||
|
torrentEngine.setFilePriority(infoHash, index, FilePriority.HIGH)
|
||||||
|
} else {
|
||||||
|
torrentEngine.setFilePriority(infoHash, index, FilePriority.DONT_DOWNLOAD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пауза/Возобновление/Удаление
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
lifecycleScope.launch {
|
||||||
|
// Поставить на паузу
|
||||||
|
torrentEngine.pauseTorrent(infoHash)
|
||||||
|
|
||||||
|
// Возобновить
|
||||||
|
torrentEngine.resumeTorrent(infoHash)
|
||||||
|
|
||||||
|
// Удалить (с файлами или без)
|
||||||
|
torrentEngine.removeTorrent(infoHash, deleteFiles = true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Множественное изменение приоритетов
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val priorities = mapOf(
|
||||||
|
0 to FilePriority.MAXIMUM, // Первый файл - максимальный приоритет
|
||||||
|
1 to FilePriority.HIGH, // Второй - высокий
|
||||||
|
2 to FilePriority.DONT_DOWNLOAD // Третий - не загружать
|
||||||
|
)
|
||||||
|
|
||||||
|
torrentEngine.setFilePriorities(infoHash, priorities)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Модели данных
|
||||||
|
|
||||||
|
### TorrentInfo
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class TorrentInfo(
|
||||||
|
val infoHash: String,
|
||||||
|
val magnetUri: String,
|
||||||
|
val name: String,
|
||||||
|
val totalSize: Long,
|
||||||
|
val downloadedSize: Long,
|
||||||
|
val uploadedSize: Long,
|
||||||
|
val downloadSpeed: Int,
|
||||||
|
val uploadSpeed: Int,
|
||||||
|
val progress: Float,
|
||||||
|
val state: TorrentState,
|
||||||
|
val numPeers: Int,
|
||||||
|
val numSeeds: Int,
|
||||||
|
val savePath: String,
|
||||||
|
val files: List<TorrentFile>,
|
||||||
|
val addedDate: Long,
|
||||||
|
val finishedDate: Long?,
|
||||||
|
val error: String?
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### TorrentState
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
enum class TorrentState {
|
||||||
|
STOPPED,
|
||||||
|
QUEUED,
|
||||||
|
METADATA_DOWNLOADING,
|
||||||
|
CHECKING,
|
||||||
|
DOWNLOADING,
|
||||||
|
SEEDING,
|
||||||
|
FINISHED,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### FilePriority
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
enum class FilePriority(val value: Int) {
|
||||||
|
DONT_DOWNLOAD(0), // Не загружать
|
||||||
|
LOW(1), // Низкий приоритет
|
||||||
|
NORMAL(4), // Обычный (по умолчанию)
|
||||||
|
HIGH(6), // Высокий
|
||||||
|
MAXIMUM(7) // Максимальный (загружать первым)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Foreground Service
|
||||||
|
|
||||||
|
Сервис автоматически запускается при добавлении торрента и показывает постоянное уведомление с:
|
||||||
|
- Количеством активных торрентов
|
||||||
|
- Общей скоростью загрузки/отдачи
|
||||||
|
- Списком загружающихся файлов с прогрессом
|
||||||
|
- Кнопками управления (Pause All)
|
||||||
|
|
||||||
|
Уведомление **нельзя закрыть** пока есть активные торренты.
|
||||||
|
|
||||||
|
## Персистентность
|
||||||
|
|
||||||
|
Все торренты сохраняются в Room database и автоматически восстанавливаются при перезапуске приложения.
|
||||||
|
|
||||||
|
### Проверка видео файлов
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val videoFiles = torrent.files.filter { it.isVideo() }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение share ratio
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val ratio = torrent.getShareRatio()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Подсчет выбранных файлов
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val selectedCount = torrent.getSelectedFilesCount()
|
||||||
|
val selectedSize = torrent.getSelectedSize()
|
||||||
|
```
|
||||||
|
|
||||||
|
[Apache License 2.0](LICENSE).
|
||||||
|
|
||||||
|
Made with <3 by Erno/Foxix
|
||||||
82
android/torrentengine/build.gradle.kts
Normal file
82
android/torrentengine/build.gradle.kts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
kotlin("kapt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.neomovies.torrentengine"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 21
|
||||||
|
targetSdk = 34
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
// KAPT configuration for Kotlin 2.1.0 compatibility
|
||||||
|
kapt {
|
||||||
|
correctErrorTypes = true
|
||||||
|
useBuildCache = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Core Android dependencies
|
||||||
|
implementation("androidx.core:core-ktx:1.15.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
|
||||||
|
// Coroutines for async operations
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
|
||||||
|
|
||||||
|
// Lifecycle components
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
||||||
|
|
||||||
|
// Room database for torrent state persistence - updated for Kotlin 2.1.0
|
||||||
|
implementation("androidx.room:room-runtime:2.7.0-alpha09")
|
||||||
|
implementation("androidx.room:room-ktx:2.7.0-alpha09")
|
||||||
|
kapt("androidx.room:room-compiler:2.7.0-alpha09")
|
||||||
|
|
||||||
|
// WorkManager for background tasks
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||||
|
|
||||||
|
// Gson for JSON parsing
|
||||||
|
implementation("com.google.code.gson:gson:2.11.0")
|
||||||
|
|
||||||
|
// LibTorrent4j - Java bindings for libtorrent
|
||||||
|
// Using main package which includes native libraries
|
||||||
|
implementation("org.libtorrent4j:libtorrent4j:2.1.0-28")
|
||||||
|
implementation("org.libtorrent4j:libtorrent4j-android-arm64:2.1.0-28")
|
||||||
|
implementation("org.libtorrent4j:libtorrent4j-android-arm:2.1.0-28")
|
||||||
|
implementation("org.libtorrent4j:libtorrent4j-android-x86:2.1.0-28")
|
||||||
|
implementation("org.libtorrent4j:libtorrent4j-android-x86_64:2.1.0-28")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
|
}
|
||||||
12
android/torrentengine/consumer-rules.pro
Normal file
12
android/torrentengine/consumer-rules.pro
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Consumer ProGuard rules for torrentengine library
|
||||||
|
|
||||||
|
# Keep LibTorrent4j
|
||||||
|
-keep class org.libtorrent4j.** { *; }
|
||||||
|
|
||||||
|
# Keep public API
|
||||||
|
-keep public class com.neomovies.torrentengine.TorrentEngine {
|
||||||
|
public *;
|
||||||
|
}
|
||||||
|
|
||||||
|
-keep class com.neomovies.torrentengine.models.** { *; }
|
||||||
|
-keep class com.neomovies.torrentengine.service.TorrentService { *; }
|
||||||
27
android/torrentengine/proguard-rules.pro
vendored
Normal file
27
android/torrentengine/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# Keep LibTorrent4j classes
|
||||||
|
-keep class org.libtorrent4j.** { *; }
|
||||||
|
-keepclassmembers class org.libtorrent4j.** { *; }
|
||||||
|
|
||||||
|
# Keep TorrentEngine public API
|
||||||
|
-keep public class com.neomovies.torrentengine.TorrentEngine {
|
||||||
|
public *;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep models
|
||||||
|
-keep class com.neomovies.torrentengine.models.** { *; }
|
||||||
|
|
||||||
|
# Keep Room database classes
|
||||||
|
-keep class * extends androidx.room.RoomDatabase
|
||||||
|
-keep @androidx.room.Entity class *
|
||||||
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# Gson
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-dontwarn sun.misc.**
|
||||||
|
-keep class com.google.gson.** { *; }
|
||||||
|
-keep class * implements com.google.gson.TypeAdapter
|
||||||
|
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||||
|
-keep class * implements com.google.gson.JsonSerializer
|
||||||
|
-keep class * implements com.google.gson.JsonDeserializer
|
||||||
39
android/torrentengine/src/main/AndroidManifest.xml
Normal file
39
android/torrentengine/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Permissions for torrent engine -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
|
android:minSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<!-- Torrent Foreground Service -->
|
||||||
|
<service
|
||||||
|
android:name=".service.TorrentService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<!-- Work Manager for background tasks -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
android:value="androidx.startup" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
package com.neomovies.torrentengine
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.neomovies.torrentengine.database.TorrentDao
|
||||||
|
import com.neomovies.torrentengine.database.TorrentDatabase
|
||||||
|
import com.neomovies.torrentengine.models.*
|
||||||
|
import com.neomovies.torrentengine.service.TorrentService
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import org.libtorrent4j.*
|
||||||
|
import org.libtorrent4j.alerts.*
|
||||||
|
import org.libtorrent4j.TorrentInfo as LibTorrentInfo
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main TorrentEngine class - the core of the torrent library
|
||||||
|
* This is the main API that applications should use.
|
||||||
|
*/
|
||||||
|
class TorrentEngine private constructor(private val context: Context) {
|
||||||
|
private val TAG = "TorrentEngine"
|
||||||
|
|
||||||
|
// LibTorrent session
|
||||||
|
private var session: SessionManager? = null
|
||||||
|
private var isSessionStarted = false
|
||||||
|
|
||||||
|
// Database
|
||||||
|
private val database: TorrentDatabase = TorrentDatabase.getDatabase(context)
|
||||||
|
private val torrentDao: TorrentDao = database.torrentDao()
|
||||||
|
|
||||||
|
// Coroutine scope
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
// Active torrent handles
|
||||||
|
private val torrentHandles = mutableMapOf<String, TorrentHandle>()
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
private val settingsPack = SettingsPack().apply {
|
||||||
|
// Enable DHT for magnet links
|
||||||
|
setEnableDht(true)
|
||||||
|
// Enable Local Service Discovery
|
||||||
|
setEnableLsd(true)
|
||||||
|
// User agent
|
||||||
|
setString(org.libtorrent4j.swig.settings_pack.string_types.user_agent.swigValue(), "NeoMovies/1.0 libtorrent4j/2.1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sessionParams = SessionParams(settingsPack)
|
||||||
|
|
||||||
|
init {
|
||||||
|
startSession()
|
||||||
|
restoreTorrents()
|
||||||
|
startAlertListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start LibTorrent session
|
||||||
|
*/
|
||||||
|
private fun startSession() {
|
||||||
|
try {
|
||||||
|
session = SessionManager()
|
||||||
|
session?.start(sessionParams)
|
||||||
|
isSessionStarted = true
|
||||||
|
Log.d(TAG, "LibTorrent session started")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start session", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore torrents from database on startup
|
||||||
|
*/
|
||||||
|
private fun restoreTorrents() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val torrents = torrentDao.getAllTorrents()
|
||||||
|
Log.d(TAG, "Restoring ${torrents.size} torrents from database")
|
||||||
|
|
||||||
|
torrents.forEach { torrent ->
|
||||||
|
if (torrent.state in arrayOf(TorrentState.DOWNLOADING, TorrentState.SEEDING)) {
|
||||||
|
// Resume active torrents
|
||||||
|
addTorrentInternal(torrent.magnetUri, torrent.savePath, torrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to restore torrents", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start alert listener for torrent events
|
||||||
|
*/
|
||||||
|
private fun startAlertListener() {
|
||||||
|
session?.addListener(object : AlertListener {
|
||||||
|
override fun types(): IntArray {
|
||||||
|
return intArrayOf(
|
||||||
|
AlertType.METADATA_RECEIVED.swig(),
|
||||||
|
AlertType.TORRENT_FINISHED.swig(),
|
||||||
|
AlertType.TORRENT_ERROR.swig(),
|
||||||
|
AlertType.STATE_CHANGED.swig(),
|
||||||
|
AlertType.TORRENT_CHECKED.swig()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun alert(alert: Alert<*>) {
|
||||||
|
handleAlert(alert)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle LibTorrent alerts
|
||||||
|
*/
|
||||||
|
private fun handleAlert(alert: Alert<*>) {
|
||||||
|
when (alert.type()) {
|
||||||
|
AlertType.METADATA_RECEIVED -> handleMetadataReceived(alert as MetadataReceivedAlert)
|
||||||
|
AlertType.TORRENT_FINISHED -> handleTorrentFinished(alert as TorrentFinishedAlert)
|
||||||
|
AlertType.TORRENT_ERROR -> handleTorrentError(alert as TorrentErrorAlert)
|
||||||
|
AlertType.STATE_CHANGED -> handleStateChanged(alert as StateChangedAlert)
|
||||||
|
AlertType.TORRENT_CHECKED -> handleTorrentChecked(alert as TorrentCheckedAlert)
|
||||||
|
else -> { /* Ignore other alerts */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle metadata received (from magnet link)
|
||||||
|
*/
|
||||||
|
private fun handleMetadataReceived(alert: MetadataReceivedAlert) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val handle = alert.handle()
|
||||||
|
val infoHash = handle.infoHash().toHex()
|
||||||
|
|
||||||
|
Log.d(TAG, "Metadata received for $infoHash")
|
||||||
|
|
||||||
|
// Extract file information
|
||||||
|
val torrentInfo = handle.torrentFile()
|
||||||
|
val files = mutableListOf<TorrentFile>()
|
||||||
|
|
||||||
|
for (i in 0 until torrentInfo.numFiles()) {
|
||||||
|
val fileStorage = torrentInfo.files()
|
||||||
|
files.add(
|
||||||
|
TorrentFile(
|
||||||
|
index = i,
|
||||||
|
path = fileStorage.filePath(i),
|
||||||
|
size = fileStorage.fileSize(i),
|
||||||
|
priority = FilePriority.NORMAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
val existingTorrent = torrentDao.getTorrent(infoHash)
|
||||||
|
existingTorrent?.let {
|
||||||
|
torrentDao.updateTorrent(
|
||||||
|
it.copy(
|
||||||
|
name = torrentInfo.name(),
|
||||||
|
totalSize = torrentInfo.totalSize(),
|
||||||
|
files = files,
|
||||||
|
state = TorrentState.DOWNLOADING
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
torrentHandles[infoHash] = handle
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error handling metadata", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle torrent finished
|
||||||
|
*/
|
||||||
|
private fun handleTorrentFinished(alert: TorrentFinishedAlert) {
|
||||||
|
scope.launch {
|
||||||
|
val handle = alert.handle()
|
||||||
|
val infoHash = handle.infoHash().toHex()
|
||||||
|
Log.d(TAG, "Torrent finished: $infoHash")
|
||||||
|
|
||||||
|
torrentDao.updateTorrentState(infoHash, TorrentState.FINISHED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle torrent error
|
||||||
|
*/
|
||||||
|
private fun handleTorrentError(alert: TorrentErrorAlert) {
|
||||||
|
scope.launch {
|
||||||
|
val handle = alert.handle()
|
||||||
|
val infoHash = handle.infoHash().toHex()
|
||||||
|
// message is a property in Kotlin
|
||||||
|
val error = alert.error().message
|
||||||
|
|
||||||
|
Log.e(TAG, "Torrent error: $infoHash - $error")
|
||||||
|
torrentDao.setTorrentError(infoHash, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle state changed
|
||||||
|
*/
|
||||||
|
private fun handleStateChanged(alert: StateChangedAlert) {
|
||||||
|
scope.launch {
|
||||||
|
val handle = alert.handle()
|
||||||
|
val infoHash = handle.infoHash().toHex()
|
||||||
|
val status = handle.status()
|
||||||
|
val state = when (status.state()) {
|
||||||
|
TorrentStatus.State.CHECKING_FILES -> TorrentState.CHECKING
|
||||||
|
TorrentStatus.State.DOWNLOADING_METADATA -> TorrentState.METADATA_DOWNLOADING
|
||||||
|
TorrentStatus.State.DOWNLOADING -> TorrentState.DOWNLOADING
|
||||||
|
TorrentStatus.State.FINISHED, TorrentStatus.State.SEEDING -> TorrentState.SEEDING
|
||||||
|
else -> TorrentState.STOPPED
|
||||||
|
}
|
||||||
|
|
||||||
|
torrentDao.updateTorrentState(infoHash, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle torrent checked
|
||||||
|
*/
|
||||||
|
private fun handleTorrentChecked(alert: TorrentCheckedAlert) {
|
||||||
|
scope.launch {
|
||||||
|
val handle = alert.handle()
|
||||||
|
val infoHash = handle.infoHash().toHex()
|
||||||
|
Log.d(TAG, "Torrent checked: $infoHash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add torrent from magnet URI
|
||||||
|
*
|
||||||
|
* @param magnetUri Magnet link
|
||||||
|
* @param savePath Directory to save files
|
||||||
|
* @return Info hash of the torrent
|
||||||
|
*/
|
||||||
|
suspend fun addTorrent(magnetUri: String, savePath: String): String {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
addTorrentInternal(magnetUri, savePath, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to add torrent
|
||||||
|
*/
|
||||||
|
private suspend fun addTorrentInternal(
|
||||||
|
magnetUri: String,
|
||||||
|
savePath: String,
|
||||||
|
existingTorrent: TorrentInfo?
|
||||||
|
): String {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// Parse magnet URI using new API
|
||||||
|
val params = AddTorrentParams.parseMagnetUri(magnetUri)
|
||||||
|
|
||||||
|
// Get info hash from parsed params - best is a property
|
||||||
|
val infoHash = params.infoHashes.best.toHex()
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
val existing = existingTorrent ?: torrentDao.getTorrent(infoHash)
|
||||||
|
if (existing != null && torrentHandles.containsKey(infoHash)) {
|
||||||
|
Log.d(TAG, "Torrent already exists: $infoHash")
|
||||||
|
return@withContext infoHash
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set save path and apply to params
|
||||||
|
val saveDir = File(savePath)
|
||||||
|
if (!saveDir.exists()) {
|
||||||
|
saveDir.mkdirs()
|
||||||
|
}
|
||||||
|
params.swig().setSave_path(saveDir.absolutePath)
|
||||||
|
|
||||||
|
// Add to session using async API
|
||||||
|
// Handle will be received asynchronously via ADD_TORRENT alert
|
||||||
|
session?.swig()?.async_add_torrent(params.swig()) ?: throw Exception("Session not initialized")
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
val torrentInfo = TorrentInfo(
|
||||||
|
infoHash = infoHash,
|
||||||
|
magnetUri = magnetUri,
|
||||||
|
name = existingTorrent?.name ?: "Loading...",
|
||||||
|
savePath = saveDir.absolutePath,
|
||||||
|
state = TorrentState.METADATA_DOWNLOADING
|
||||||
|
)
|
||||||
|
torrentDao.insertTorrent(torrentInfo)
|
||||||
|
|
||||||
|
// Start foreground service
|
||||||
|
startService()
|
||||||
|
|
||||||
|
Log.d(TAG, "Torrent added: $infoHash")
|
||||||
|
infoHash
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to add torrent", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume torrent
|
||||||
|
*/
|
||||||
|
suspend fun resumeTorrent(infoHash: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
torrentHandles[infoHash]?.resume()
|
||||||
|
torrentDao.updateTorrentState(infoHash, TorrentState.DOWNLOADING)
|
||||||
|
startService()
|
||||||
|
Log.d(TAG, "Torrent resumed: $infoHash")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to resume torrent", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause torrent
|
||||||
|
*/
|
||||||
|
suspend fun pauseTorrent(infoHash: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
torrentHandles[infoHash]?.pause()
|
||||||
|
torrentDao.updateTorrentState(infoHash, TorrentState.STOPPED)
|
||||||
|
Log.d(TAG, "Torrent paused: $infoHash")
|
||||||
|
|
||||||
|
// Stop service if no active torrents
|
||||||
|
val activeTorrents = torrentDao.getActiveTorrents()
|
||||||
|
if (activeTorrents.isEmpty()) {
|
||||||
|
stopService()
|
||||||
|
}
|
||||||
|
Unit // Explicitly return Unit
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to pause torrent", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove torrent
|
||||||
|
*
|
||||||
|
* @param infoHash Torrent info hash
|
||||||
|
* @param deleteFiles Whether to delete downloaded files
|
||||||
|
*/
|
||||||
|
suspend fun removeTorrent(infoHash: String, deleteFiles: Boolean = false) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val handle = torrentHandles[infoHash]
|
||||||
|
if (handle != null) {
|
||||||
|
session?.remove(handle)
|
||||||
|
torrentHandles.remove(infoHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteFiles) {
|
||||||
|
val torrent = torrentDao.getTorrent(infoHash)
|
||||||
|
torrent?.let {
|
||||||
|
val dir = File(it.savePath)
|
||||||
|
if (dir.exists()) {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
torrentDao.deleteTorrentByHash(infoHash)
|
||||||
|
Log.d(TAG, "Torrent removed: $infoHash")
|
||||||
|
|
||||||
|
// Stop service if no active torrents
|
||||||
|
val activeTorrents = torrentDao.getActiveTorrents()
|
||||||
|
if (activeTorrents.isEmpty()) {
|
||||||
|
stopService()
|
||||||
|
}
|
||||||
|
Unit // Explicitly return Unit
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to remove torrent", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set file priority in torrent
|
||||||
|
* This allows selecting/deselecting files even after torrent is started
|
||||||
|
*
|
||||||
|
* @param infoHash Torrent info hash
|
||||||
|
* @param fileIndex File index
|
||||||
|
* @param priority File priority
|
||||||
|
*/
|
||||||
|
suspend fun setFilePriority(infoHash: String, fileIndex: Int, priority: FilePriority) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val handle = torrentHandles[infoHash] ?: return@withContext
|
||||||
|
// Convert FilePriority to LibTorrent Priority
|
||||||
|
val libPriority = when (priority) {
|
||||||
|
FilePriority.DONT_DOWNLOAD -> Priority.IGNORE
|
||||||
|
FilePriority.LOW -> Priority.LOW
|
||||||
|
FilePriority.NORMAL -> Priority.DEFAULT
|
||||||
|
FilePriority.HIGH -> Priority.TOP_PRIORITY
|
||||||
|
else -> Priority.DEFAULT // Default
|
||||||
|
}
|
||||||
|
handle.filePriority(fileIndex, libPriority)
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
val torrent = torrentDao.getTorrent(infoHash) ?: return@withContext
|
||||||
|
val updatedFiles = torrent.files.mapIndexed { index, file ->
|
||||||
|
if (index == fileIndex) file.copy(priority = priority) else file
|
||||||
|
}
|
||||||
|
torrentDao.updateTorrent(torrent.copy(files = updatedFiles))
|
||||||
|
|
||||||
|
Log.d(TAG, "File priority updated: $infoHash, file $fileIndex, priority $priority")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to set file priority", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set multiple file priorities at once
|
||||||
|
*/
|
||||||
|
suspend fun setFilePriorities(infoHash: String, priorities: Map<Int, FilePriority>) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val handle = torrentHandles[infoHash] ?: return@withContext
|
||||||
|
|
||||||
|
priorities.forEach { (fileIndex, priority) ->
|
||||||
|
val libPriority = when (priority) {
|
||||||
|
FilePriority.DONT_DOWNLOAD -> Priority.IGNORE
|
||||||
|
FilePriority.LOW -> Priority.LOW
|
||||||
|
FilePriority.NORMAL -> Priority.DEFAULT
|
||||||
|
FilePriority.HIGH -> Priority.TOP_PRIORITY
|
||||||
|
else -> Priority.DEFAULT // Default
|
||||||
|
}
|
||||||
|
handle.filePriority(fileIndex, libPriority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
val torrent = torrentDao.getTorrent(infoHash) ?: return@withContext
|
||||||
|
val updatedFiles = torrent.files.mapIndexed { index, file ->
|
||||||
|
priorities[index]?.let { file.copy(priority = it) } ?: file
|
||||||
|
}
|
||||||
|
torrentDao.updateTorrent(torrent.copy(files = updatedFiles))
|
||||||
|
|
||||||
|
Log.d(TAG, "Multiple file priorities updated: $infoHash")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to set file priorities", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get torrent info
|
||||||
|
*/
|
||||||
|
suspend fun getTorrent(infoHash: String): TorrentInfo? {
|
||||||
|
return torrentDao.getTorrent(infoHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all torrents
|
||||||
|
*/
|
||||||
|
suspend fun getAllTorrents(): List<TorrentInfo> {
|
||||||
|
return torrentDao.getAllTorrents()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get torrents as Flow (reactive updates)
|
||||||
|
*/
|
||||||
|
fun getAllTorrentsFlow(): Flow<List<TorrentInfo>> {
|
||||||
|
return torrentDao.getAllTorrentsFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update torrent statistics
|
||||||
|
*/
|
||||||
|
private suspend fun updateTorrentStats() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
torrentHandles.forEach { (infoHash, handle) ->
|
||||||
|
try {
|
||||||
|
val status = handle.status()
|
||||||
|
|
||||||
|
torrentDao.updateTorrentProgress(
|
||||||
|
infoHash,
|
||||||
|
status.progress(),
|
||||||
|
status.totalDone()
|
||||||
|
)
|
||||||
|
|
||||||
|
torrentDao.updateTorrentSpeeds(
|
||||||
|
infoHash,
|
||||||
|
status.downloadRate(),
|
||||||
|
status.uploadRate()
|
||||||
|
)
|
||||||
|
|
||||||
|
torrentDao.updateTorrentPeers(
|
||||||
|
infoHash,
|
||||||
|
status.numPeers(),
|
||||||
|
status.numSeeds()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error updating torrent stats for $infoHash", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic stats update
|
||||||
|
*/
|
||||||
|
fun startStatsUpdater() {
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
updateTorrentStats()
|
||||||
|
delay(1000) // Update every second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start foreground service
|
||||||
|
*/
|
||||||
|
private fun startService() {
|
||||||
|
try {
|
||||||
|
val intent = Intent(context, TorrentService::class.java)
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start service", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop foreground service
|
||||||
|
*/
|
||||||
|
private fun stopService() {
|
||||||
|
try {
|
||||||
|
val intent = Intent(context, TorrentService::class.java)
|
||||||
|
context.stopService(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to stop service", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown engine
|
||||||
|
*/
|
||||||
|
fun shutdown() {
|
||||||
|
scope.cancel()
|
||||||
|
session?.stop()
|
||||||
|
isSessionStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: TorrentEngine? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TorrentEngine singleton instance
|
||||||
|
*/
|
||||||
|
fun getInstance(context: Context): TorrentEngine {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = TorrentEngine(context.applicationContext)
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.neomovies.torrentengine.database
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import com.neomovies.torrentengine.models.TorrentFile
|
||||||
|
import com.neomovies.torrentengine.models.TorrentState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type converters for Room database
|
||||||
|
*/
|
||||||
|
class Converters {
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromTorrentState(value: TorrentState): String = value.name
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toTorrentState(value: String): TorrentState = TorrentState.valueOf(value)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromTorrentFileList(value: List<TorrentFile>): String = gson.toJson(value)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toTorrentFileList(value: String): List<TorrentFile> {
|
||||||
|
val listType = object : TypeToken<List<TorrentFile>>() {}.type
|
||||||
|
return gson.fromJson(value, listType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromStringList(value: List<String>): String = gson.toJson(value)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toStringList(value: String): List<String> {
|
||||||
|
val listType = object : TypeToken<List<String>>() {}.type
|
||||||
|
return gson.fromJson(value, listType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.neomovies.torrentengine.database
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.neomovies.torrentengine.models.TorrentInfo
|
||||||
|
import com.neomovies.torrentengine.models.TorrentState
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Access Object for torrent operations
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface TorrentDao {
|
||||||
|
/**
|
||||||
|
* Get all torrents as Flow (reactive updates)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM torrents ORDER BY addedDate DESC")
|
||||||
|
fun getAllTorrentsFlow(): Flow<List<TorrentInfo>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all torrents (one-time fetch)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM torrents ORDER BY addedDate DESC")
|
||||||
|
suspend fun getAllTorrents(): List<TorrentInfo>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get torrent by info hash
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM torrents WHERE infoHash = :infoHash")
|
||||||
|
suspend fun getTorrent(infoHash: String): TorrentInfo?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get torrent by info hash as Flow
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM torrents WHERE infoHash = :infoHash")
|
||||||
|
fun getTorrentFlow(infoHash: String): Flow<TorrentInfo?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get torrents by state
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM torrents WHERE state = :state ORDER BY addedDate DESC")
|
||||||
|
suspend fun getTorrentsByState(state: TorrentState): List<TorrentInfo>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active torrents (downloading or seeding)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM torrents WHERE state IN ('DOWNLOADING', 'SEEDING', 'METADATA_DOWNLOADING') ORDER BY addedDate DESC")
|
||||||
|
suspend fun getActiveTorrents(): List<TorrentInfo>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active torrents as Flow
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM torrents WHERE state IN ('DOWNLOADING', 'SEEDING', 'METADATA_DOWNLOADING') ORDER BY addedDate DESC")
|
||||||
|
fun getActiveTorrentsFlow(): Flow<List<TorrentInfo>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update torrent
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertTorrent(torrent: TorrentInfo)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update multiple torrents
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertTorrents(torrents: List<TorrentInfo>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update torrent
|
||||||
|
*/
|
||||||
|
@Update
|
||||||
|
suspend fun updateTorrent(torrent: TorrentInfo)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete torrent
|
||||||
|
*/
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteTorrent(torrent: TorrentInfo)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete torrent by info hash
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM torrents WHERE infoHash = :infoHash")
|
||||||
|
suspend fun deleteTorrentByHash(infoHash: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all torrents
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM torrents")
|
||||||
|
suspend fun deleteAllTorrents()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total torrents count
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(*) FROM torrents")
|
||||||
|
suspend fun getTorrentsCount(): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update torrent state
|
||||||
|
*/
|
||||||
|
@Query("UPDATE torrents SET state = :state WHERE infoHash = :infoHash")
|
||||||
|
suspend fun updateTorrentState(infoHash: String, state: TorrentState)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update torrent progress
|
||||||
|
*/
|
||||||
|
@Query("UPDATE torrents SET progress = :progress, downloadedSize = :downloadedSize WHERE infoHash = :infoHash")
|
||||||
|
suspend fun updateTorrentProgress(infoHash: String, progress: Float, downloadedSize: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update torrent speeds
|
||||||
|
*/
|
||||||
|
@Query("UPDATE torrents SET downloadSpeed = :downloadSpeed, uploadSpeed = :uploadSpeed WHERE infoHash = :infoHash")
|
||||||
|
suspend fun updateTorrentSpeeds(infoHash: String, downloadSpeed: Int, uploadSpeed: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update torrent peers/seeds
|
||||||
|
*/
|
||||||
|
@Query("UPDATE torrents SET numPeers = :numPeers, numSeeds = :numSeeds WHERE infoHash = :infoHash")
|
||||||
|
suspend fun updateTorrentPeers(infoHash: String, numPeers: Int, numSeeds: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set torrent error
|
||||||
|
*/
|
||||||
|
@Query("UPDATE torrents SET error = :error, state = 'ERROR' WHERE infoHash = :infoHash")
|
||||||
|
suspend fun setTorrentError(infoHash: String, error: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear torrent error
|
||||||
|
*/
|
||||||
|
@Query("UPDATE torrents SET error = NULL WHERE infoHash = :infoHash")
|
||||||
|
suspend fun clearTorrentError(infoHash: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.neomovies.torrentengine.database
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import com.neomovies.torrentengine.models.TorrentInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room database for torrent persistence
|
||||||
|
*/
|
||||||
|
@Database(
|
||||||
|
entities = [TorrentInfo::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
abstract class TorrentDatabase : RoomDatabase() {
|
||||||
|
abstract fun torrentDao(): TorrentDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: TorrentDatabase? = null
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): TorrentDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
TorrentDatabase::class.java,
|
||||||
|
"torrent_database"
|
||||||
|
)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package com.neomovies.torrentengine.models
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import com.neomovies.torrentengine.database.Converters
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Torrent information model
|
||||||
|
* Represents a torrent download with all its metadata
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "torrents")
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
data class TorrentInfo(
|
||||||
|
@PrimaryKey
|
||||||
|
val infoHash: String,
|
||||||
|
val magnetUri: String,
|
||||||
|
val name: String,
|
||||||
|
val totalSize: Long = 0,
|
||||||
|
val downloadedSize: Long = 0,
|
||||||
|
val uploadedSize: Long = 0,
|
||||||
|
val downloadSpeed: Int = 0,
|
||||||
|
val uploadSpeed: Int = 0,
|
||||||
|
val progress: Float = 0f,
|
||||||
|
val state: TorrentState = TorrentState.STOPPED,
|
||||||
|
val numPeers: Int = 0,
|
||||||
|
val numSeeds: Int = 0,
|
||||||
|
val savePath: String,
|
||||||
|
val files: List<TorrentFile> = emptyList(),
|
||||||
|
val addedDate: Long = System.currentTimeMillis(),
|
||||||
|
val finishedDate: Long? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val sequentialDownload: Boolean = false,
|
||||||
|
val isPrivate: Boolean = false,
|
||||||
|
val creator: String? = null,
|
||||||
|
val comment: String? = null,
|
||||||
|
val trackers: List<String> = emptyList()
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Calculate ETA (Estimated Time of Arrival) in seconds
|
||||||
|
*/
|
||||||
|
fun getEta(): Long {
|
||||||
|
if (downloadSpeed == 0) return Long.MAX_VALUE
|
||||||
|
val remainingBytes = totalSize - downloadedSize
|
||||||
|
return remainingBytes / downloadSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted ETA string
|
||||||
|
*/
|
||||||
|
fun getFormattedEta(): String {
|
||||||
|
val eta = getEta()
|
||||||
|
if (eta == Long.MAX_VALUE) return "∞"
|
||||||
|
|
||||||
|
val hours = eta / 3600
|
||||||
|
val minutes = (eta % 3600) / 60
|
||||||
|
val seconds = eta % 60
|
||||||
|
|
||||||
|
return when {
|
||||||
|
hours > 0 -> String.format("%dh %02dm", hours, minutes)
|
||||||
|
minutes > 0 -> String.format("%dm %02ds", minutes, seconds)
|
||||||
|
else -> String.format("%ds", seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get share ratio
|
||||||
|
*/
|
||||||
|
fun getShareRatio(): Float {
|
||||||
|
if (downloadedSize == 0L) return 0f
|
||||||
|
return uploadedSize.toFloat() / downloadedSize.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if torrent is active (downloading/seeding)
|
||||||
|
*/
|
||||||
|
fun isActive(): Boolean = state in arrayOf(
|
||||||
|
TorrentState.DOWNLOADING,
|
||||||
|
TorrentState.SEEDING,
|
||||||
|
TorrentState.METADATA_DOWNLOADING
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if torrent has error
|
||||||
|
*/
|
||||||
|
fun hasError(): Boolean = error != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get selected files count
|
||||||
|
*/
|
||||||
|
fun getSelectedFilesCount(): Int = files.count { it.priority > FilePriority.DONT_DOWNLOAD }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total selected size
|
||||||
|
*/
|
||||||
|
fun getSelectedSize(): Long = files
|
||||||
|
.filter { it.priority > FilePriority.DONT_DOWNLOAD }
|
||||||
|
.sumOf { it.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Torrent state enumeration
|
||||||
|
*/
|
||||||
|
enum class TorrentState {
|
||||||
|
/**
|
||||||
|
* Torrent is stopped/paused
|
||||||
|
*/
|
||||||
|
STOPPED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Torrent is queued for download
|
||||||
|
*/
|
||||||
|
QUEUED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloading metadata from magnet link
|
||||||
|
*/
|
||||||
|
METADATA_DOWNLOADING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checking files on disk
|
||||||
|
*/
|
||||||
|
CHECKING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actively downloading
|
||||||
|
*/
|
||||||
|
DOWNLOADING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download finished, now seeding
|
||||||
|
*/
|
||||||
|
SEEDING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finished downloading and seeding
|
||||||
|
*/
|
||||||
|
FINISHED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error occurred
|
||||||
|
*/
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File information within torrent
|
||||||
|
*/
|
||||||
|
data class TorrentFile(
|
||||||
|
val index: Int,
|
||||||
|
val path: String,
|
||||||
|
val size: Long,
|
||||||
|
val downloaded: Long = 0,
|
||||||
|
val priority: FilePriority = FilePriority.NORMAL,
|
||||||
|
val progress: Float = 0f
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Get file name from path
|
||||||
|
*/
|
||||||
|
fun getName(): String = path.substringAfterLast('/')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file extension
|
||||||
|
*/
|
||||||
|
fun getExtension(): String = path.substringAfterLast('.', "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is video
|
||||||
|
*/
|
||||||
|
fun isVideo(): Boolean = getExtension().lowercase() in VIDEO_EXTENSIONS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is selected for download
|
||||||
|
*/
|
||||||
|
fun isSelected(): Boolean = priority > FilePriority.DONT_DOWNLOAD
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val VIDEO_EXTENSIONS = setOf(
|
||||||
|
"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg", "3gp"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File download priority
|
||||||
|
*/
|
||||||
|
enum class FilePriority(val value: Int) {
|
||||||
|
/**
|
||||||
|
* Don't download this file
|
||||||
|
*/
|
||||||
|
DONT_DOWNLOAD(0),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low priority
|
||||||
|
*/
|
||||||
|
LOW(1),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normal priority (default)
|
||||||
|
*/
|
||||||
|
NORMAL(4),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High priority
|
||||||
|
*/
|
||||||
|
HIGH(6),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum priority (download first)
|
||||||
|
*/
|
||||||
|
MAXIMUM(7);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromValue(value: Int): FilePriority = values().firstOrNull { it.value == value } ?: NORMAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Torrent statistics for UI
|
||||||
|
*/
|
||||||
|
data class TorrentStats(
|
||||||
|
val totalTorrents: Int = 0,
|
||||||
|
val activeTorrents: Int = 0,
|
||||||
|
val downloadingTorrents: Int = 0,
|
||||||
|
val seedingTorrents: Int = 0,
|
||||||
|
val pausedTorrents: Int = 0,
|
||||||
|
val totalDownloadSpeed: Long = 0,
|
||||||
|
val totalUploadSpeed: Long = 0,
|
||||||
|
val totalDownloaded: Long = 0,
|
||||||
|
val totalUploaded: Long = 0
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Get formatted download speed
|
||||||
|
*/
|
||||||
|
fun getFormattedDownloadSpeed(): String = formatSpeed(totalDownloadSpeed)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted upload speed
|
||||||
|
*/
|
||||||
|
fun getFormattedUploadSpeed(): String = formatSpeed(totalUploadSpeed)
|
||||||
|
|
||||||
|
private fun formatSpeed(speed: Long): String {
|
||||||
|
return when {
|
||||||
|
speed >= 1024 * 1024 -> String.format("%.1f MB/s", speed / (1024.0 * 1024.0))
|
||||||
|
speed >= 1024 -> String.format("%.1f KB/s", speed / 1024.0)
|
||||||
|
else -> "$speed B/s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.neomovies.torrentengine.service
|
||||||
|
|
||||||
|
import android.app.*
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.neomovies.torrentengine.TorrentEngine
|
||||||
|
import com.neomovies.torrentengine.models.TorrentState
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service for torrent downloads
|
||||||
|
* This service shows a persistent notification that cannot be dismissed while torrents are active
|
||||||
|
*/
|
||||||
|
class TorrentService : Service() {
|
||||||
|
private val TAG = "TorrentService"
|
||||||
|
|
||||||
|
private lateinit var torrentEngine: TorrentEngine
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
|
||||||
|
private val NOTIFICATION_ID = 1001
|
||||||
|
private val CHANNEL_ID = "torrent_service_channel"
|
||||||
|
private val CHANNEL_NAME = "Torrent Downloads"
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
torrentEngine = TorrentEngine.getInstance(applicationContext)
|
||||||
|
torrentEngine.startStatsUpdater()
|
||||||
|
|
||||||
|
createNotificationChannel()
|
||||||
|
startForeground(NOTIFICATION_ID, createNotification())
|
||||||
|
|
||||||
|
// Start observing torrents for notification updates
|
||||||
|
observeTorrents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
// Service will restart if killed by system
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
|
// This service doesn't support binding
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create notification channel for Android 8.0+
|
||||||
|
*/
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Shows download progress for torrents"
|
||||||
|
setShowBadge(false)
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe torrents and update notification
|
||||||
|
*/
|
||||||
|
private fun observeTorrents() {
|
||||||
|
scope.launch {
|
||||||
|
torrentEngine.getAllTorrentsFlow().collect { torrents ->
|
||||||
|
val activeTorrents = torrents.filter { it.isActive() }
|
||||||
|
|
||||||
|
if (activeTorrents.isEmpty()) {
|
||||||
|
// Stop service if no active torrents
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
// Update notification
|
||||||
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, createNotification(activeTorrents.size, torrents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update notification
|
||||||
|
*/
|
||||||
|
private fun createNotification(activeTorrentsCount: Int = 0, allTorrents: List<com.neomovies.torrentengine.models.TorrentInfo> = emptyList()): Notification {
|
||||||
|
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
|
.setOngoing(true) // Cannot be dismissed
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
|
||||||
|
if (activeTorrentsCount == 0) {
|
||||||
|
// Initial notification
|
||||||
|
builder.setContentTitle("Torrent Service")
|
||||||
|
.setContentText("Ready to download")
|
||||||
|
} else {
|
||||||
|
// Calculate total stats
|
||||||
|
val downloadingTorrents = allTorrents.filter { it.state == TorrentState.DOWNLOADING }
|
||||||
|
val totalDownloadSpeed = allTorrents.sumOf { it.downloadSpeed.toLong() }
|
||||||
|
val totalUploadSpeed = allTorrents.sumOf { it.uploadSpeed.toLong() }
|
||||||
|
|
||||||
|
val speedText = buildString {
|
||||||
|
if (totalDownloadSpeed > 0) {
|
||||||
|
append("↓ ${formatSpeed(totalDownloadSpeed)}")
|
||||||
|
}
|
||||||
|
if (totalUploadSpeed > 0) {
|
||||||
|
if (isNotEmpty()) append(" ")
|
||||||
|
append("↑ ${formatSpeed(totalUploadSpeed)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.setContentTitle("$activeTorrentsCount active torrent(s)")
|
||||||
|
.setContentText(speedText)
|
||||||
|
|
||||||
|
// Add big text style with details
|
||||||
|
val bigText = buildString {
|
||||||
|
if (downloadingTorrents.isNotEmpty()) {
|
||||||
|
appendLine("Downloading:")
|
||||||
|
downloadingTorrents.take(3).forEach { torrent ->
|
||||||
|
appendLine("• ${torrent.name}")
|
||||||
|
appendLine(" ${String.format("%.1f%%", torrent.progress * 100)} - ↓ ${formatSpeed(torrent.downloadSpeed.toLong())}")
|
||||||
|
}
|
||||||
|
if (downloadingTorrents.size > 3) {
|
||||||
|
appendLine("... and ${downloadingTorrents.size - 3} more")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
|
||||||
|
|
||||||
|
// Add action buttons
|
||||||
|
addNotificationActions(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add action buttons to notification
|
||||||
|
*/
|
||||||
|
private fun addNotificationActions(builder: NotificationCompat.Builder) {
|
||||||
|
// Pause all button
|
||||||
|
val pauseAllIntent = Intent(this, TorrentService::class.java).apply {
|
||||||
|
action = ACTION_PAUSE_ALL
|
||||||
|
}
|
||||||
|
val pauseAllPendingIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
1,
|
||||||
|
pauseAllIntent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
builder.addAction(
|
||||||
|
android.R.drawable.ic_media_pause,
|
||||||
|
"Pause All",
|
||||||
|
pauseAllPendingIntent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format speed for display
|
||||||
|
*/
|
||||||
|
private fun formatSpeed(bytesPerSecond: Long): String {
|
||||||
|
return when {
|
||||||
|
bytesPerSecond >= 1024 * 1024 -> String.format("%.1f MB/s", bytesPerSecond / (1024.0 * 1024.0))
|
||||||
|
bytesPerSecond >= 1024 -> String.format("%.1f KB/s", bytesPerSecond / 1024.0)
|
||||||
|
else -> "$bytesPerSecond B/s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_PAUSE_ALL = "com.neomovies.torrentengine.PAUSE_ALL"
|
||||||
|
const val ACTION_RESUME_ALL = "com.neomovies.torrentengine.RESUME_ALL"
|
||||||
|
const val ACTION_STOP_SERVICE = "com.neomovies.torrentengine.STOP_SERVICE"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,332 +1,142 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:neomovies_mobile/data/models/auth_response.dart';
|
|
||||||
import 'package:neomovies_mobile/data/models/favorite.dart';
|
|
||||||
import 'package:neomovies_mobile/data/models/movie.dart';
|
import 'package:neomovies_mobile/data/models/movie.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/favorite.dart';
|
||||||
import 'package:neomovies_mobile/data/models/reaction.dart';
|
import 'package:neomovies_mobile/data/models/reaction.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/auth_response.dart';
|
||||||
import 'package:neomovies_mobile/data/models/user.dart';
|
import 'package:neomovies_mobile/data/models/user.dart';
|
||||||
|
import 'package:neomovies_mobile/data/api/neomovies_api_client.dart'; // новый клиент
|
||||||
|
import 'package:neomovies_mobile/data/exceptions/auth_exceptions.dart';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
final http.Client _client;
|
final NeoMoviesApiClient _neoClient;
|
||||||
final String _baseUrl = dotenv.env['API_URL']!;
|
|
||||||
|
|
||||||
ApiClient(this._client);
|
ApiClient(http.Client client)
|
||||||
|
: _neoClient = NeoMoviesApiClient(client);
|
||||||
|
|
||||||
Future<List<Movie>> getPopularMovies({int page = 1}) async {
|
// ---- Movies ----
|
||||||
return _fetchMovies('/movies/popular', page: page);
|
Future<List<Movie>> getPopularMovies({int page = 1}) {
|
||||||
|
return _neoClient.getPopularMovies(page: page);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Movie>> getTopRatedMovies({int page = 1}) async {
|
Future<List<Movie>> getTopRatedMovies({int page = 1}) {
|
||||||
return _fetchMovies('/movies/top-rated', page: page);
|
return _neoClient.getTopRatedMovies(page: page);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Movie>> getUpcomingMovies({int page = 1}) async {
|
Future<List<Movie>> getUpcomingMovies({int page = 1}) {
|
||||||
return _fetchMovies('/movies/upcoming', page: page);
|
return _neoClient.getUpcomingMovies(page: page);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Movie> getMovieById(String id) async {
|
Future<Movie> getMovieById(String id) {
|
||||||
return _fetchMovieDetail('/movies/$id');
|
return _neoClient.getMovieById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Movie> getTvById(String id) async {
|
Future<Movie> getTvById(String id) {
|
||||||
return _fetchMovieDetail('/tv/$id');
|
return _neoClient.getTvShowById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение IMDB ID для фильмов
|
// ---- Search ----
|
||||||
Future<String?> getMovieImdbId(int movieId) async {
|
Future<List<Movie>> searchMovies(String query, {int page = 1}) {
|
||||||
|
return _neoClient.search(query, page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Favorites ----
|
||||||
|
Future<List<Favorite>> getFavorites() {
|
||||||
|
return _neoClient.getFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addFavorite(
|
||||||
|
String mediaId,
|
||||||
|
String mediaType,
|
||||||
|
String title,
|
||||||
|
String posterPath,
|
||||||
|
) {
|
||||||
|
return _neoClient.addFavorite(
|
||||||
|
mediaId: mediaId,
|
||||||
|
mediaType: mediaType,
|
||||||
|
title: title,
|
||||||
|
posterPath: posterPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeFavorite(String mediaId, {String mediaType = 'movie'}) {
|
||||||
|
return _neoClient.removeFavorite(mediaId, mediaType: mediaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> checkIsFavorite(String mediaId, {String mediaType = 'movie'}) {
|
||||||
|
return _neoClient.checkIsFavorite(mediaId, mediaType: mediaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reactions ----
|
||||||
|
Future<Map<String, int>> getReactionCounts(
|
||||||
|
String mediaType, String mediaId) {
|
||||||
|
return _neoClient.getReactionCounts(
|
||||||
|
mediaType: mediaType,
|
||||||
|
mediaId: mediaId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setReaction(
|
||||||
|
String mediaType, String mediaId, String reactionType) {
|
||||||
|
return _neoClient.setReaction(
|
||||||
|
mediaType: mediaType,
|
||||||
|
mediaId: mediaId,
|
||||||
|
reactionType: reactionType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<UserReaction>> getMyReactions() {
|
||||||
|
return _neoClient.getMyReactions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get single user reaction for specific media
|
||||||
|
Future<UserReaction?> getMyReaction(String mediaType, String mediaId) async {
|
||||||
|
final reactions = await _neoClient.getMyReactions();
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse('$_baseUrl/movies/$movieId/external-ids');
|
return reactions.firstWhere(
|
||||||
final response = await _client.get(uri).timeout(const Duration(seconds: 30));
|
(r) => r.mediaType == mediaType && r.mediaId == mediaId,
|
||||||
|
);
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final data = json.decode(response.body);
|
|
||||||
return data['imdb_id'] as String?;
|
|
||||||
} else {
|
|
||||||
print('Failed to get movie IMDB ID: ${response.statusCode}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting movie IMDB ID: $e');
|
return null; // No reaction found
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение IMDB ID для сериалов
|
// ---- External IDs (IMDb) ----
|
||||||
Future<String?> getTvImdbId(int showId) async {
|
Future<String?> getImdbId(String mediaId, String mediaType) async {
|
||||||
try {
|
return _neoClient.getExternalIds(mediaId, mediaType);
|
||||||
final uri = Uri.parse('$_baseUrl/tv/$showId/external-ids');
|
|
||||||
final response = await _client.get(uri).timeout(const Duration(seconds: 30));
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final data = json.decode(response.body);
|
|
||||||
return data['imdb_id'] as String?;
|
|
||||||
} else {
|
|
||||||
print('Failed to get TV IMDB ID: ${response.statusCode}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting TV IMDB ID: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Универсальный метод получения IMDB ID
|
// ---- Auth ----
|
||||||
Future<String?> getImdbId(int mediaId, String mediaType) async {
|
Future<void> register(String name, String email, String password) {
|
||||||
if (mediaType == 'tv') {
|
return _neoClient.register(
|
||||||
return getTvImdbId(mediaId);
|
name: name,
|
||||||
} else {
|
email: email,
|
||||||
return getMovieImdbId(mediaId);
|
password: password,
|
||||||
}
|
).then((_) {}); // старый код ничего не возвращал
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Movie>> searchMovies(String query, {int page = 1}) async {
|
|
||||||
final moviesUri = Uri.parse('$_baseUrl/movies/search?query=${Uri.encodeQueryComponent(query)}&page=$page');
|
|
||||||
final tvUri = Uri.parse('$_baseUrl/tv/search?query=${Uri.encodeQueryComponent(query)}&page=$page');
|
|
||||||
|
|
||||||
final responses = await Future.wait([
|
|
||||||
_client.get(moviesUri),
|
|
||||||
_client.get(tvUri),
|
|
||||||
]);
|
|
||||||
|
|
||||||
List<Movie> combined = [];
|
|
||||||
|
|
||||||
for (final response in responses) {
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final decoded = json.decode(response.body);
|
|
||||||
List<dynamic> listData;
|
|
||||||
if (decoded is List) {
|
|
||||||
listData = decoded;
|
|
||||||
} else if (decoded is Map && decoded['results'] is List) {
|
|
||||||
listData = decoded['results'];
|
|
||||||
} else {
|
|
||||||
listData = [];
|
|
||||||
}
|
|
||||||
combined.addAll(listData.map((json) => Movie.fromJson(json)));
|
|
||||||
} else {
|
|
||||||
// ignore non-200 but log maybe
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (combined.isEmpty) {
|
|
||||||
throw Exception('Failed to search movies/tv');
|
|
||||||
}
|
|
||||||
return combined;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Movie> _fetchMovieDetail(String path) async {
|
|
||||||
final uri = Uri.parse('$_baseUrl$path');
|
|
||||||
final response = await _client.get(uri);
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final data = json.decode(response.body);
|
|
||||||
return Movie.fromJson(data);
|
|
||||||
} else {
|
|
||||||
throw Exception('Failed to load media details: ${response.statusCode}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favorites
|
|
||||||
Future<List<Favorite>> getFavorites() async {
|
|
||||||
final response = await _client.get(Uri.parse('$_baseUrl/favorites'));
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final List<dynamic> data = json.decode(response.body);
|
|
||||||
return data.map((json) => Favorite.fromJson(json)).toList();
|
|
||||||
} else {
|
|
||||||
throw Exception('Failed to fetch favorites');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addFavorite(String mediaId, String mediaType, String title, String posterPath) async {
|
|
||||||
final response = await _client.post(
|
|
||||||
Uri.parse('$_baseUrl/favorites/$mediaId?mediaType=$mediaType'),
|
|
||||||
body: json.encode({
|
|
||||||
'title': title,
|
|
||||||
'posterPath': posterPath,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode != 201 && response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to add favorite');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> removeFavorite(String mediaId) async {
|
|
||||||
final response = await _client.delete(
|
|
||||||
Uri.parse('$_baseUrl/favorites/$mediaId'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to remove favorite');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reactions
|
|
||||||
Future<Map<String, int>> getReactionCounts(String mediaType, String mediaId) async {
|
|
||||||
final response = await _client.get(
|
|
||||||
Uri.parse('$_baseUrl/reactions/$mediaType/$mediaId/counts'),
|
|
||||||
);
|
|
||||||
|
|
||||||
print('REACTION COUNTS RESPONSE (${response.statusCode}): ${response.body}');
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final decoded = json.decode(response.body);
|
|
||||||
print('PARSED: $decoded');
|
|
||||||
|
|
||||||
if (decoded is Map) {
|
|
||||||
final mapSrc = decoded.containsKey('data') && decoded['data'] is Map
|
|
||||||
? decoded['data'] as Map<String, dynamic>
|
|
||||||
: decoded;
|
|
||||||
|
|
||||||
print('MAPPING: $mapSrc');
|
|
||||||
return mapSrc.map((k, v) {
|
|
||||||
int count;
|
|
||||||
if (v is num) {
|
|
||||||
count = v.toInt();
|
|
||||||
} else if (v is String) {
|
|
||||||
count = int.tryParse(v) ?? 0;
|
|
||||||
} else {
|
|
||||||
count = 0;
|
|
||||||
}
|
|
||||||
return MapEntry(k, count);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (decoded is List) {
|
|
||||||
// list of {type,count}
|
|
||||||
Map<String, int> res = {};
|
|
||||||
for (var item in decoded) {
|
|
||||||
if (item is Map && item['type'] != null) {
|
|
||||||
res[item['type'].toString()] = (item['count'] as num?)?.toInt() ?? 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
} else {
|
|
||||||
throw Exception('Failed to fetch reactions counts');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<UserReaction> getMyReaction(String mediaType, String mediaId) async {
|
|
||||||
final response = await _client.get(
|
|
||||||
Uri.parse('$_baseUrl/reactions/$mediaType/$mediaId/my-reaction'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final decoded = json.decode(response.body);
|
|
||||||
if (decoded == null || (decoded is String && decoded.isEmpty)) {
|
|
||||||
return UserReaction(reactionType: null);
|
|
||||||
}
|
|
||||||
return UserReaction.fromJson(decoded as Map<String, dynamic>);
|
|
||||||
} else if (response.statusCode == 404) {
|
|
||||||
return UserReaction(reactionType: 'none'); // No reaction found
|
|
||||||
} else {
|
|
||||||
throw Exception('Failed to fetch user reaction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setReaction(String mediaType, String mediaId, String reactionType) async {
|
|
||||||
final response = await _client.post(
|
|
||||||
Uri.parse('$_baseUrl/reactions'),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: json.encode({'mediaId': '${mediaType}_${mediaId}', 'type': reactionType}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode != 201 && response.statusCode != 200 && response.statusCode != 204) {
|
|
||||||
throw Exception('Failed to set reaction: ${response.statusCode} ${response.body}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Auth Methods ---
|
|
||||||
|
|
||||||
Future<void> register(String name, String email, String password) async {
|
|
||||||
final uri = Uri.parse('$_baseUrl/auth/register');
|
|
||||||
final response = await _client.post(
|
|
||||||
uri,
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: json.encode({'name': name, 'email': email, 'password': password}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
|
||||||
final decoded = json.decode(response.body) as Map<String, dynamic>;
|
|
||||||
if (decoded['success'] == true || decoded.containsKey('token')) {
|
|
||||||
// registration succeeded; nothing further to return
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw Exception('Failed to register: ${decoded['message'] ?? 'Unknown error'}');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw Exception('Failed to register: ${response.statusCode} ${response.body}');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AuthResponse> login(String email, String password) async {
|
Future<AuthResponse> login(String email, String password) async {
|
||||||
final uri = Uri.parse('$_baseUrl/auth/login');
|
try {
|
||||||
final response = await _client.post(
|
return await _neoClient.login(email: email, password: password);
|
||||||
uri,
|
} catch (e) {
|
||||||
headers: {'Content-Type': 'application/json'},
|
final errorMessage = e.toString();
|
||||||
body: json.encode({'email': email, 'password': password}),
|
if (errorMessage.contains('Account not activated') ||
|
||||||
);
|
errorMessage.contains('not verified') ||
|
||||||
|
errorMessage.contains('Please verify your email')) {
|
||||||
if (response.statusCode == 200) {
|
throw UnverifiedAccountException(email, message: errorMessage);
|
||||||
return AuthResponse.fromJson(json.decode(response.body));
|
|
||||||
} else {
|
|
||||||
throw Exception('Failed to login: ${response.body}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> verify(String email, String code) async {
|
|
||||||
final uri = Uri.parse('$_baseUrl/auth/verify');
|
|
||||||
final response = await _client.post(
|
|
||||||
uri,
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: json.encode({'email': email, 'code': code}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to verify code: ${response.body}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> resendCode(String email) async {
|
|
||||||
final uri = Uri.parse('$_baseUrl/auth/resend-code');
|
|
||||||
final response = await _client.post(
|
|
||||||
uri,
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: json.encode({'email': email}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to resend code: ${response.body}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteAccount() async {
|
|
||||||
final uri = Uri.parse('$_baseUrl/auth/profile');
|
|
||||||
final response = await _client.delete(uri);
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to delete account: ${response.body}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Movie Methods ---
|
|
||||||
|
|
||||||
Future<List<Movie>> _fetchMovies(String endpoint, {int page = 1}) async {
|
|
||||||
final uri = Uri.parse('$_baseUrl$endpoint').replace(queryParameters: {
|
|
||||||
'page': page.toString(),
|
|
||||||
});
|
|
||||||
final response = await _client.get(uri);
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final List<dynamic> data = json.decode(response.body)['results'];
|
|
||||||
if (data == null) {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
return data.map((json) => Movie.fromJson(json)).toList();
|
rethrow;
|
||||||
} else {
|
|
||||||
throw Exception('Failed to load movies from $endpoint');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Future<AuthResponse> verify(String email, String code) {
|
||||||
|
return _neoClient.verifyEmail(email: email, code: code);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resendCode(String email) {
|
||||||
|
return _neoClient.resendVerificationCode(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAccount() {
|
||||||
|
return _neoClient.deleteAccount();
|
||||||
|
}
|
||||||
|
}
|
||||||
552
lib/data/api/neomovies_api_client.dart
Normal file
552
lib/data/api/neomovies_api_client.dart
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:neomovies_mobile/data/models/auth_response.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/favorite.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/movie.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/reaction.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/user.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/torrent.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/torrent/torrent_item.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/player/player_response.dart';
|
||||||
|
|
||||||
|
/// New API client for neomovies-api (Go-based backend)
|
||||||
|
/// This client provides improved performance and new features:
|
||||||
|
/// - Email verification flow
|
||||||
|
/// - Google OAuth support
|
||||||
|
/// - Torrent search via RedAPI
|
||||||
|
/// - Multiple player support (Alloha, Lumex, Vibix)
|
||||||
|
/// - Enhanced reactions system
|
||||||
|
class NeoMoviesApiClient {
|
||||||
|
final http.Client _client;
|
||||||
|
final String _baseUrl;
|
||||||
|
final String _apiVersion = 'v1';
|
||||||
|
|
||||||
|
NeoMoviesApiClient(this._client, {String? baseUrl})
|
||||||
|
: _baseUrl = baseUrl ?? dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
|
||||||
|
|
||||||
|
String get apiUrl => '$_baseUrl/api/$_apiVersion';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Authentication Endpoints
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Register a new user (sends verification code to email)
|
||||||
|
/// Returns: {"success": true, "message": "Verification code sent"}
|
||||||
|
Future<Map<String, dynamic>> register({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String name,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$apiUrl/auth/register');
|
||||||
|
final response = await _client.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'name': name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
|
return json.decode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Registration failed: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify email with code sent during registration
|
||||||
|
/// Returns: AuthResponse with JWT token and user info
|
||||||
|
Future<AuthResponse> verifyEmail({
|
||||||
|
required String email,
|
||||||
|
required String code,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$apiUrl/auth/verify');
|
||||||
|
final response = await _client.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({
|
||||||
|
'email': email,
|
||||||
|
'code': code,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return AuthResponse.fromJson(json.decode(response.body));
|
||||||
|
} else {
|
||||||
|
throw Exception('Verification failed: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resend verification code to email
|
||||||
|
Future<void> resendVerificationCode(String email) async {
|
||||||
|
final uri = Uri.parse('$apiUrl/auth/resend-code');
|
||||||
|
final response = await _client.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({'email': email}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to resend code: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login with email and password
|
||||||
|
Future<AuthResponse> login({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$apiUrl/auth/login');
|
||||||
|
final response = await _client.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return AuthResponse.fromJson(json.decode(response.body));
|
||||||
|
} else {
|
||||||
|
throw Exception('Login failed: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Google OAuth login URL
|
||||||
|
/// User should be redirected to this URL in a WebView
|
||||||
|
String getGoogleOAuthUrl() {
|
||||||
|
return '$apiUrl/auth/google/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh authentication token
|
||||||
|
Future<AuthResponse> refreshToken(String refreshToken) async {
|
||||||
|
final uri = Uri.parse('$apiUrl/auth/refresh');
|
||||||
|
final response = await _client.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({'refreshToken': refreshToken}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return AuthResponse.fromJson(json.decode(response.body));
|
||||||
|
} else {
|
||||||
|
throw Exception('Token refresh failed: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current user profile
|
||||||
|
Future<User> getProfile() async {
|
||||||
|
final uri = Uri.parse('$apiUrl/auth/profile');
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return User.fromJson(json.decode(response.body));
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to get profile: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete user account
|
||||||
|
Future<void> deleteAccount() async {
|
||||||
|
final uri = Uri.parse('$apiUrl/auth/profile');
|
||||||
|
final response = await _client.delete(uri);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to delete account: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Movies Endpoints
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Get popular movies
|
||||||
|
Future<List<Movie>> getPopularMovies({int page = 1}) async {
|
||||||
|
return _fetchMovies('/movies/popular', page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get top rated movies
|
||||||
|
Future<List<Movie>> getTopRatedMovies({int page = 1}) async {
|
||||||
|
return _fetchMovies('/movies/top-rated', page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get upcoming movies
|
||||||
|
Future<List<Movie>> getUpcomingMovies({int page = 1}) async {
|
||||||
|
return _fetchMovies('/movies/upcoming', page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get now playing movies
|
||||||
|
Future<List<Movie>> getNowPlayingMovies({int page = 1}) async {
|
||||||
|
return _fetchMovies('/movies/now-playing', page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get movie by ID
|
||||||
|
Future<Movie> getMovieById(String id) async {
|
||||||
|
final uri = Uri.parse('$apiUrl/movies/$id');
|
||||||
|
print('Fetching movie from: $uri');
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
print('Response status: ${response.statusCode}');
|
||||||
|
print('Response body: ${response.body.substring(0, response.body.length > 500 ? 500 : response.body.length)}...');
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final apiResponse = json.decode(response.body);
|
||||||
|
print('Decoded API response type: ${apiResponse.runtimeType}');
|
||||||
|
print('API response keys: ${apiResponse is Map ? apiResponse.keys.toList() : 'Not a map'}');
|
||||||
|
|
||||||
|
// API returns: {"success": true, "data": {...}}
|
||||||
|
final movieData = (apiResponse is Map && apiResponse['data'] != null)
|
||||||
|
? apiResponse['data']
|
||||||
|
: apiResponse;
|
||||||
|
|
||||||
|
print('Movie data keys: ${movieData is Map ? movieData.keys.toList() : 'Not a map'}');
|
||||||
|
print('Movie data: $movieData');
|
||||||
|
|
||||||
|
return Movie.fromJson(movieData);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load movie: ${response.statusCode} - ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get movie recommendations
|
||||||
|
Future<List<Movie>> getMovieRecommendations(String movieId, {int page = 1}) async {
|
||||||
|
return _fetchMovies('/movies/$movieId/recommendations', page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search movies
|
||||||
|
Future<List<Movie>> searchMovies(String query, {int page = 1}) async {
|
||||||
|
return _fetchMovies('/movies/search', page: page, query: query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TV Shows Endpoints
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Get popular TV shows
|
||||||
|
Future<List<Movie>> getPopularTvShows({int page = 1}) async {
|
||||||
|
return _fetchMovies('/tv/popular', page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get top rated TV shows
|
||||||
|
Future<List<Movie>> getTopRatedTvShows({int page = 1}) async {
|
||||||
|
return _fetchMovies('/tv/top-rated', page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get TV show by ID
|
||||||
|
Future<Movie> getTvShowById(String id) async {
|
||||||
|
final uri = Uri.parse('$apiUrl/tv/$id');
|
||||||
|
print('Fetching TV show from: $uri');
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
print('Response status: ${response.statusCode}');
|
||||||
|
print('Response body: ${response.body.substring(0, response.body.length > 500 ? 500 : response.body.length)}...');
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final apiResponse = json.decode(response.body);
|
||||||
|
print('Decoded API response type: ${apiResponse.runtimeType}');
|
||||||
|
print('API response keys: ${apiResponse is Map ? apiResponse.keys.toList() : 'Not a map'}');
|
||||||
|
|
||||||
|
// API returns: {"success": true, "data": {...}}
|
||||||
|
final tvData = (apiResponse is Map && apiResponse['data'] != null)
|
||||||
|
? apiResponse['data']
|
||||||
|
: apiResponse;
|
||||||
|
|
||||||
|
print('TV data keys: ${tvData is Map ? tvData.keys.toList() : 'Not a map'}');
|
||||||
|
print('TV data: $tvData');
|
||||||
|
|
||||||
|
return Movie.fromJson(tvData);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load TV show: ${response.statusCode} - ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get TV show recommendations
|
||||||
|
Future<List<Movie>> getTvShowRecommendations(String tvId, {int page = 1}) async {
|
||||||
|
return _fetchMovies('/tv/$tvId/recommendations', page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search TV shows
|
||||||
|
Future<List<Movie>> searchTvShows(String query, {int page = 1}) async {
|
||||||
|
return _fetchMovies('/tv/search', page: page, query: query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// External IDs (IMDb, TVDB, etc.)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Get external IDs (IMDb, TVDB) for a movie or TV show
|
||||||
|
Future<String?> getExternalIds(String mediaId, String mediaType) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse('$apiUrl/${mediaType}s/$mediaId/external-ids');
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final apiResponse = json.decode(response.body);
|
||||||
|
final data = (apiResponse is Map && apiResponse['data'] != null)
|
||||||
|
? apiResponse['data']
|
||||||
|
: apiResponse;
|
||||||
|
return data['imdb_id'] as String?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error getting external IDs: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Unified Search
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Search both movies and TV shows
|
||||||
|
Future<List<Movie>> search(String query, {int page = 1}) async {
|
||||||
|
final results = await Future.wait([
|
||||||
|
searchMovies(query, page: page),
|
||||||
|
searchTvShows(query, page: page),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Combine and return
|
||||||
|
return [...results[0], ...results[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Favorites Endpoints
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Get user's favorite movies/shows
|
||||||
|
Future<List<Favorite>> getFavorites() async {
|
||||||
|
final uri = Uri.parse('$apiUrl/favorites');
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final apiResponse = json.decode(response.body);
|
||||||
|
// API returns: {"success": true, "data": [...]}
|
||||||
|
final List<dynamic> data = (apiResponse is Map && apiResponse['data'] != null)
|
||||||
|
? (apiResponse['data'] is List ? apiResponse['data'] : [])
|
||||||
|
: (apiResponse is List ? apiResponse : []);
|
||||||
|
return data.map((json) => Favorite.fromJson(json)).toList();
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to fetch favorites: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add movie/show to favorites
|
||||||
|
/// Backend automatically fetches title and poster_path from TMDB
|
||||||
|
Future<void> addFavorite({
|
||||||
|
required String mediaId,
|
||||||
|
required String mediaType,
|
||||||
|
required String title,
|
||||||
|
required String posterPath,
|
||||||
|
}) async {
|
||||||
|
// Backend route: POST /favorites/{id}?type={mediaType}
|
||||||
|
final uri = Uri.parse('$apiUrl/favorites/$mediaId')
|
||||||
|
.replace(queryParameters: {'type': mediaType});
|
||||||
|
final response = await _client.post(uri);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
throw Exception('Failed to add favorite: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove movie/show from favorites
|
||||||
|
Future<void> removeFavorite(String mediaId, {String mediaType = 'movie'}) async {
|
||||||
|
// Backend route: DELETE /favorites/{id}?type={mediaType}
|
||||||
|
final uri = Uri.parse('$apiUrl/favorites/$mediaId')
|
||||||
|
.replace(queryParameters: {'type': mediaType});
|
||||||
|
final response = await _client.delete(uri);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception('Failed to remove favorite: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if media is in favorites
|
||||||
|
Future<bool> checkIsFavorite(String mediaId, {String mediaType = 'movie'}) async {
|
||||||
|
// Backend route: GET /favorites/{id}/check?type={mediaType}
|
||||||
|
final uri = Uri.parse('$apiUrl/favorites/$mediaId/check')
|
||||||
|
.replace(queryParameters: {'type': mediaType});
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final apiResponse = json.decode(response.body);
|
||||||
|
// API returns: {"success": true, "data": {"isFavorite": true}}
|
||||||
|
if (apiResponse is Map && apiResponse['data'] != null) {
|
||||||
|
final data = apiResponse['data'];
|
||||||
|
return data['isFavorite'] ?? false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to check favorite status: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Reactions Endpoints (NEW!)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Get reaction counts for a movie/show
|
||||||
|
Future<Map<String, int>> getReactionCounts({
|
||||||
|
required String mediaType,
|
||||||
|
required String mediaId,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$apiUrl/reactions/$mediaType/$mediaId/counts');
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
return Map<String, int>.from(data);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to get reactions: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update user's reaction
|
||||||
|
Future<void> setReaction({
|
||||||
|
required String mediaType,
|
||||||
|
required String mediaId,
|
||||||
|
required String reactionType, // 'like' or 'dislike'
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$apiUrl/reactions/$mediaType/$mediaId');
|
||||||
|
final response = await _client.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: json.encode({'type': reactionType}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
throw Exception('Failed to set reaction: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user's own reactions
|
||||||
|
Future<List<UserReaction>> getMyReactions() async {
|
||||||
|
final uri = Uri.parse('$apiUrl/reactions/my');
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final apiResponse = json.decode(response.body);
|
||||||
|
// API returns: {"success": true, "data": [...]}
|
||||||
|
final List<dynamic> data = (apiResponse is Map && apiResponse['data'] != null)
|
||||||
|
? (apiResponse['data'] is List ? apiResponse['data'] : [])
|
||||||
|
: (apiResponse is List ? apiResponse : []);
|
||||||
|
return data.map((json) => UserReaction.fromJson(json)).toList();
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to get my reactions: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Torrent Search Endpoints (NEW!)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Search torrents for a movie/show via RedAPI
|
||||||
|
/// @param imdbId - IMDb ID (e.g., "tt1234567")
|
||||||
|
/// @param type - "movie" or "series"
|
||||||
|
/// @param quality - "1080p", "720p", "480p", etc.
|
||||||
|
Future<List<TorrentItem>> searchTorrents({
|
||||||
|
required String imdbId,
|
||||||
|
required String type,
|
||||||
|
String? quality,
|
||||||
|
String? season,
|
||||||
|
String? episode,
|
||||||
|
}) async {
|
||||||
|
final queryParams = {
|
||||||
|
'type': type,
|
||||||
|
if (quality != null) 'quality': quality,
|
||||||
|
if (season != null) 'season': season,
|
||||||
|
if (episode != null) 'episode': episode,
|
||||||
|
};
|
||||||
|
|
||||||
|
final uri = Uri.parse('$apiUrl/torrents/search/$imdbId')
|
||||||
|
.replace(queryParameters: queryParams);
|
||||||
|
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List<dynamic> data = json.decode(response.body);
|
||||||
|
return data.map((json) => TorrentItem.fromJson(json)).toList();
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to search torrents: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Players Endpoints (NEW!)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Get Alloha player embed URL
|
||||||
|
Future<PlayerResponse> getAllohaPlayer(String imdbId) async {
|
||||||
|
return _getPlayer('/players/alloha/$imdbId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Lumex player embed URL
|
||||||
|
Future<PlayerResponse> getLumexPlayer(String imdbId) async {
|
||||||
|
return _getPlayer('/players/lumex/$imdbId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Vibix player embed URL
|
||||||
|
Future<PlayerResponse> getVibixPlayer(String imdbId) async {
|
||||||
|
return _getPlayer('/players/vibix/$imdbId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Private Helper Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Generic method to fetch movies/TV shows
|
||||||
|
Future<List<Movie>> _fetchMovies(
|
||||||
|
String endpoint, {
|
||||||
|
int page = 1,
|
||||||
|
String? query,
|
||||||
|
}) async {
|
||||||
|
final queryParams = {
|
||||||
|
'page': page.toString(),
|
||||||
|
if (query != null && query.isNotEmpty) 'query': query,
|
||||||
|
};
|
||||||
|
|
||||||
|
final uri = Uri.parse('$apiUrl$endpoint').replace(queryParameters: queryParams);
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final decoded = json.decode(response.body);
|
||||||
|
|
||||||
|
// API returns: {"success": true, "data": {"page": 1, "results": [...], ...}}
|
||||||
|
List<dynamic> results;
|
||||||
|
if (decoded is Map && decoded['success'] == true && decoded['data'] != null) {
|
||||||
|
final data = decoded['data'];
|
||||||
|
if (data is Map && data['results'] != null) {
|
||||||
|
results = data['results'];
|
||||||
|
} else if (data is List) {
|
||||||
|
results = data;
|
||||||
|
} else {
|
||||||
|
throw Exception('Unexpected data format in API response');
|
||||||
|
}
|
||||||
|
} else if (decoded is List) {
|
||||||
|
results = decoded;
|
||||||
|
} else if (decoded is Map && decoded['results'] != null) {
|
||||||
|
results = decoded['results'];
|
||||||
|
} else {
|
||||||
|
throw Exception('Unexpected response format');
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.map((json) => Movie.fromJson(json)).toList();
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load from $endpoint: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic method to fetch player info
|
||||||
|
Future<PlayerResponse> _getPlayer(String endpoint) async {
|
||||||
|
final uri = Uri.parse('$apiUrl$endpoint');
|
||||||
|
final response = await _client.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return PlayerResponse.fromJson(json.decode(response.body));
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to get player: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,13 @@ class AuthResponse {
|
|||||||
AuthResponse({required this.token, required this.user, required this.verified});
|
AuthResponse({required this.token, required this.user, required this.verified});
|
||||||
|
|
||||||
factory AuthResponse.fromJson(Map<String, dynamic> json) {
|
factory AuthResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
// Handle wrapped response with "data" field
|
||||||
|
final data = json['data'] ?? json;
|
||||||
|
|
||||||
return AuthResponse(
|
return AuthResponse(
|
||||||
token: json['token'] as String,
|
token: data['token'] as String,
|
||||||
user: User.fromJson(json['user'] as Map<String, dynamic>),
|
user: User.fromJson(data['user'] as Map<String, dynamic>),
|
||||||
verified: (json['verified'] as bool?) ?? (json['user']?['verified'] as bool? ?? true),
|
verified: (data['verified'] as bool?) ?? (data['user']?['verified'] as bool? ?? true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
|
||||||
class Favorite {
|
class Favorite {
|
||||||
final int id;
|
final String id; // MongoDB ObjectID as string
|
||||||
final String mediaId;
|
final String mediaId;
|
||||||
final String mediaType;
|
final String mediaType; // "movie" or "tv"
|
||||||
final String title;
|
final String title;
|
||||||
final String posterPath;
|
final String posterPath;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
Favorite({
|
Favorite({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -13,24 +14,29 @@ class Favorite {
|
|||||||
required this.mediaType,
|
required this.mediaType,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.posterPath,
|
required this.posterPath,
|
||||||
|
this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Favorite.fromJson(Map<String, dynamic> json) {
|
factory Favorite.fromJson(Map<String, dynamic> json) {
|
||||||
return Favorite(
|
return Favorite(
|
||||||
id: json['id'] as int? ?? 0,
|
id: json['id'] as String? ?? '',
|
||||||
mediaId: json['mediaId'] as String? ?? '',
|
mediaId: json['mediaId'] as String? ?? '',
|
||||||
mediaType: json['mediaType'] as String? ?? '',
|
mediaType: json['mediaType'] as String? ?? 'movie',
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
posterPath: json['posterPath'] as String? ?? '',
|
posterPath: json['posterPath'] as String? ?? '',
|
||||||
|
createdAt: json['createdAt'] != null
|
||||||
|
? DateTime.tryParse(json['createdAt'] as String)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get fullPosterUrl {
|
String get fullPosterUrl {
|
||||||
final baseUrl = dotenv.env['API_URL']!;
|
|
||||||
if (posterPath.isEmpty) {
|
if (posterPath.isEmpty) {
|
||||||
return '$baseUrl/images/w500/placeholder.jpg';
|
return 'https://via.placeholder.com/500x750.png?text=No+Poster';
|
||||||
}
|
}
|
||||||
|
// TMDB CDN base URL
|
||||||
|
const tmdbBaseUrl = 'https://image.tmdb.org/t/p';
|
||||||
final cleanPath = posterPath.startsWith('/') ? posterPath.substring(1) : posterPath;
|
final cleanPath = posterPath.startsWith('/') ? posterPath.substring(1) : posterPath;
|
||||||
return '$baseUrl/images/w500/$cleanPath';
|
return '$tmdbBaseUrl/w500/$cleanPath';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
part 'movie.g.dart';
|
part 'movie.g.dart';
|
||||||
|
|
||||||
@HiveType(typeId: 0)
|
@HiveType(typeId: 0)
|
||||||
|
@JsonSerializable()
|
||||||
class Movie extends HiveObject {
|
class Movie extends HiveObject {
|
||||||
@HiveField(0)
|
@HiveField(0)
|
||||||
final String id;
|
final String id;
|
||||||
@@ -14,6 +16,8 @@ class Movie extends HiveObject {
|
|||||||
@HiveField(2)
|
@HiveField(2)
|
||||||
final String? posterPath;
|
final String? posterPath;
|
||||||
|
|
||||||
|
final String? backdropPath;
|
||||||
|
|
||||||
@HiveField(3)
|
@HiveField(3)
|
||||||
final String? overview;
|
final String? overview;
|
||||||
|
|
||||||
@@ -49,6 +53,7 @@ class Movie extends HiveObject {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.posterPath,
|
this.posterPath,
|
||||||
|
this.backdropPath,
|
||||||
this.overview,
|
this.overview,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
this.genres,
|
this.genres,
|
||||||
@@ -62,39 +67,104 @@ class Movie extends HiveObject {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory Movie.fromJson(Map<String, dynamic> json) {
|
factory Movie.fromJson(Map<String, dynamic> json) {
|
||||||
return Movie(
|
try {
|
||||||
id: (json['id'] as num).toString(), // Ensure id is a string
|
print('Parsing Movie from JSON: ${json.keys.toList()}');
|
||||||
title: (json['title'] ?? json['name'] ?? '') as String,
|
|
||||||
posterPath: json['poster_path'] as String?,
|
// Parse genres safely - API returns: [{"id": 18, "name": "Drama"}]
|
||||||
overview: json['overview'] as String?,
|
List<String> genresList = [];
|
||||||
releaseDate: json['release_date'] != null && json['release_date'].isNotEmpty
|
if (json['genres'] != null && json['genres'] is List) {
|
||||||
? DateTime.tryParse(json['release_date'] as String)
|
genresList = (json['genres'] as List)
|
||||||
: json['first_air_date'] != null && json['first_air_date'].isNotEmpty
|
.map((g) {
|
||||||
? DateTime.tryParse(json['first_air_date'] as String)
|
if (g is Map && g.containsKey('name')) {
|
||||||
: null,
|
return g['name'] as String? ?? '';
|
||||||
genres: List<String>.from(json['genres']?.map((g) => g['name']) ?? []),
|
}
|
||||||
voteAverage: (json['vote_average'] as num?)?.toDouble() ?? 0.0,
|
return '';
|
||||||
popularity: (json['popularity'] as num?)?.toDouble() ?? 0.0,
|
})
|
||||||
runtime: json['runtime'] is num
|
.where((name) => name.isNotEmpty)
|
||||||
? (json['runtime'] as num).toInt()
|
.toList();
|
||||||
: (json['episode_run_time'] is List && (json['episode_run_time'] as List).isNotEmpty)
|
print('Parsed genres: $genresList');
|
||||||
? ((json['episode_run_time'] as List).first as num).toInt()
|
}
|
||||||
: null,
|
|
||||||
seasonsCount: json['number_of_seasons'] as int?,
|
// Parse dates safely
|
||||||
episodesCount: json['number_of_episodes'] as int?,
|
DateTime? parsedDate;
|
||||||
tagline: json['tagline'] as String?,
|
final releaseDate = json['release_date'];
|
||||||
mediaType: (json['media_type'] ?? (json['title'] != null ? 'movie' : 'tv')) as String,
|
final firstAirDate = json['first_air_date'];
|
||||||
);
|
|
||||||
|
if (releaseDate != null && releaseDate.toString().isNotEmpty && releaseDate.toString() != 'null') {
|
||||||
|
parsedDate = DateTime.tryParse(releaseDate.toString());
|
||||||
|
} else if (firstAirDate != null && firstAirDate.toString().isNotEmpty && firstAirDate.toString() != 'null') {
|
||||||
|
parsedDate = DateTime.tryParse(firstAirDate.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse runtime (movie) or episode_run_time (TV)
|
||||||
|
int? runtimeValue;
|
||||||
|
if (json['runtime'] != null && json['runtime'] is num && (json['runtime'] as num) > 0) {
|
||||||
|
runtimeValue = (json['runtime'] as num).toInt();
|
||||||
|
} else if (json['episode_run_time'] != null && json['episode_run_time'] is List) {
|
||||||
|
final episodeRunTime = json['episode_run_time'] as List;
|
||||||
|
if (episodeRunTime.isNotEmpty && episodeRunTime.first is num) {
|
||||||
|
runtimeValue = (episodeRunTime.first as num).toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine media type
|
||||||
|
String mediaTypeValue = 'movie';
|
||||||
|
if (json.containsKey('media_type') && json['media_type'] != null) {
|
||||||
|
mediaTypeValue = json['media_type'] as String;
|
||||||
|
} else if (json.containsKey('name') || json.containsKey('first_air_date')) {
|
||||||
|
mediaTypeValue = 'tv';
|
||||||
|
}
|
||||||
|
|
||||||
|
final movie = Movie(
|
||||||
|
id: (json['id'] as num).toString(),
|
||||||
|
title: (json['title'] ?? json['name'] ?? 'Untitled') as String,
|
||||||
|
posterPath: json['poster_path'] as String?,
|
||||||
|
backdropPath: json['backdrop_path'] as String?,
|
||||||
|
overview: json['overview'] as String?,
|
||||||
|
releaseDate: parsedDate,
|
||||||
|
genres: genresList,
|
||||||
|
voteAverage: (json['vote_average'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
popularity: (json['popularity'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
runtime: runtimeValue,
|
||||||
|
seasonsCount: json['number_of_seasons'] as int?,
|
||||||
|
episodesCount: json['number_of_episodes'] as int?,
|
||||||
|
tagline: json['tagline'] as String?,
|
||||||
|
mediaType: mediaTypeValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
print('Successfully parsed movie: ${movie.title}');
|
||||||
|
return movie;
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
print('❌ Error parsing Movie from JSON: $e');
|
||||||
|
print('Stack trace: $stackTrace');
|
||||||
|
print('JSON data: $json');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$MovieToJson(this);
|
||||||
|
|
||||||
String get fullPosterUrl {
|
String get fullPosterUrl {
|
||||||
final baseUrl = dotenv.env['API_URL']!;
|
|
||||||
if (posterPath == null || posterPath!.isEmpty) {
|
if (posterPath == null || posterPath!.isEmpty) {
|
||||||
// Use the placeholder from our own backend
|
// Use API placeholder
|
||||||
return '$baseUrl/images/w500/placeholder.jpg';
|
final apiUrl = dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
|
||||||
|
return '$apiUrl/api/v1/images/w500/placeholder.jpg';
|
||||||
}
|
}
|
||||||
// Null check is already performed above, so we can use `!`
|
// Use NeoMovies API images endpoint instead of TMDB directly
|
||||||
|
final apiUrl = dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
|
||||||
final cleanPath = posterPath!.startsWith('/') ? posterPath!.substring(1) : posterPath!;
|
final cleanPath = posterPath!.startsWith('/') ? posterPath!.substring(1) : posterPath!;
|
||||||
return '$baseUrl/images/w500/$cleanPath';
|
return '$apiUrl/api/v1/images/w500/$cleanPath';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get fullBackdropUrl {
|
||||||
|
if (backdropPath == null || backdropPath!.isEmpty) {
|
||||||
|
// Use API placeholder
|
||||||
|
final apiUrl = dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
|
||||||
|
return '$apiUrl/api/v1/images/w780/placeholder.jpg';
|
||||||
|
}
|
||||||
|
// Use NeoMovies API images endpoint instead of TMDB directly
|
||||||
|
final apiUrl = dotenv.env['API_URL'] ?? 'https://api.neomovies.ru';
|
||||||
|
final cleanPath = backdropPath!.startsWith('/') ? backdropPath!.substring(1) : backdropPath!;
|
||||||
|
return '$apiUrl/api/v1/images/w780/$cleanPath';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ class MovieAdapter extends TypeAdapter<Movie> {
|
|||||||
releaseDate: fields[4] as DateTime?,
|
releaseDate: fields[4] as DateTime?,
|
||||||
genres: (fields[5] as List?)?.cast<String>(),
|
genres: (fields[5] as List?)?.cast<String>(),
|
||||||
voteAverage: fields[6] as double?,
|
voteAverage: fields[6] as double?,
|
||||||
|
popularity: fields[9] as double,
|
||||||
|
runtime: fields[7] as int?,
|
||||||
|
seasonsCount: fields[10] as int?,
|
||||||
|
episodesCount: fields[11] as int?,
|
||||||
|
tagline: fields[8] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void write(BinaryWriter writer, Movie obj) {
|
void write(BinaryWriter writer, Movie obj) {
|
||||||
writer
|
writer
|
||||||
..writeByte(7)
|
..writeByte(12)
|
||||||
..writeByte(0)
|
..writeByte(0)
|
||||||
..write(obj.id)
|
..write(obj.id)
|
||||||
..writeByte(1)
|
..writeByte(1)
|
||||||
@@ -44,7 +49,17 @@ class MovieAdapter extends TypeAdapter<Movie> {
|
|||||||
..writeByte(5)
|
..writeByte(5)
|
||||||
..write(obj.genres)
|
..write(obj.genres)
|
||||||
..writeByte(6)
|
..writeByte(6)
|
||||||
..write(obj.voteAverage);
|
..write(obj.voteAverage)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.popularity)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.runtime)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.seasonsCount)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.episodesCount)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.tagline);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -57,3 +72,44 @@ class MovieAdapter extends TypeAdapter<Movie> {
|
|||||||
runtimeType == other.runtimeType &&
|
runtimeType == other.runtimeType &&
|
||||||
typeId == other.typeId;
|
typeId == other.typeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
Movie _$MovieFromJson(Map<String, dynamic> json) => Movie(
|
||||||
|
id: json['id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
posterPath: json['posterPath'] as String?,
|
||||||
|
backdropPath: json['backdropPath'] as String?,
|
||||||
|
overview: json['overview'] as String?,
|
||||||
|
releaseDate: json['releaseDate'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['releaseDate'] as String),
|
||||||
|
genres:
|
||||||
|
(json['genres'] as List<dynamic>?)?.map((e) => e as String).toList(),
|
||||||
|
voteAverage: (json['voteAverage'] as num?)?.toDouble(),
|
||||||
|
popularity: (json['popularity'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
runtime: (json['runtime'] as num?)?.toInt(),
|
||||||
|
seasonsCount: (json['seasonsCount'] as num?)?.toInt(),
|
||||||
|
episodesCount: (json['episodesCount'] as num?)?.toInt(),
|
||||||
|
tagline: json['tagline'] as String?,
|
||||||
|
mediaType: json['mediaType'] as String? ?? 'movie',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$MovieToJson(Movie instance) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'title': instance.title,
|
||||||
|
'posterPath': instance.posterPath,
|
||||||
|
'backdropPath': instance.backdropPath,
|
||||||
|
'overview': instance.overview,
|
||||||
|
'releaseDate': instance.releaseDate?.toIso8601String(),
|
||||||
|
'genres': instance.genres,
|
||||||
|
'voteAverage': instance.voteAverage,
|
||||||
|
'popularity': instance.popularity,
|
||||||
|
'runtime': instance.runtime,
|
||||||
|
'seasonsCount': instance.seasonsCount,
|
||||||
|
'episodesCount': instance.episodesCount,
|
||||||
|
'tagline': instance.tagline,
|
||||||
|
'mediaType': instance.mediaType,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
part 'movie_preview.g.dart';
|
part 'movie_preview.g.dart';
|
||||||
|
|
||||||
@HiveType(typeId: 1) // Use a new typeId to avoid conflicts with Movie
|
@HiveType(typeId: 1) // Use a new typeId to avoid conflicts with Movie
|
||||||
|
@JsonSerializable()
|
||||||
class MoviePreview extends HiveObject {
|
class MoviePreview extends HiveObject {
|
||||||
@HiveField(0)
|
@HiveField(0)
|
||||||
final String id;
|
final String id;
|
||||||
@@ -18,4 +20,7 @@ class MoviePreview extends HiveObject {
|
|||||||
required this.title,
|
required this.title,
|
||||||
this.posterPath,
|
this.posterPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
factory MoviePreview.fromJson(Map<String, dynamic> json) => _$MoviePreviewFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$MoviePreviewToJson(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,3 +45,20 @@ class MoviePreviewAdapter extends TypeAdapter<MoviePreview> {
|
|||||||
runtimeType == other.runtimeType &&
|
runtimeType == other.runtimeType &&
|
||||||
typeId == other.typeId;
|
typeId == other.typeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
MoviePreview _$MoviePreviewFromJson(Map<String, dynamic> json) => MoviePreview(
|
||||||
|
id: json['id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
posterPath: json['posterPath'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$MoviePreviewToJson(MoviePreview instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'title': instance.title,
|
||||||
|
'posterPath': instance.posterPath,
|
||||||
|
};
|
||||||
|
|||||||
34
lib/data/models/player/audio_track.dart
Normal file
34
lib/data/models/player/audio_track.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
class AudioTrack {
|
||||||
|
final String name;
|
||||||
|
final String language;
|
||||||
|
final String url;
|
||||||
|
final bool isDefault;
|
||||||
|
|
||||||
|
AudioTrack({
|
||||||
|
required this.name,
|
||||||
|
required this.language,
|
||||||
|
required this.url,
|
||||||
|
this.isDefault = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AudioTrack.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AudioTrack(
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
language: json['language'] ?? '',
|
||||||
|
url: json['url'] ?? '',
|
||||||
|
isDefault: json['isDefault'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'language': language,
|
||||||
|
'url': url,
|
||||||
|
'isDefault': isDefault,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => name;
|
||||||
|
}
|
||||||
23
lib/data/models/player/player_response.dart
Normal file
23
lib/data/models/player/player_response.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'player_response.g.dart';
|
||||||
|
|
||||||
|
/// Response from player endpoints
|
||||||
|
/// Contains embed URL for different player services
|
||||||
|
@JsonSerializable()
|
||||||
|
class PlayerResponse {
|
||||||
|
final String? embedUrl;
|
||||||
|
final String? playerType; // 'alloha', 'lumex', 'vibix'
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
PlayerResponse({
|
||||||
|
this.embedUrl,
|
||||||
|
this.playerType,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PlayerResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PlayerResponseFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$PlayerResponseToJson(this);
|
||||||
|
}
|
||||||
21
lib/data/models/player/player_response.g.dart
Normal file
21
lib/data/models/player/player_response.g.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'player_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
PlayerResponse _$PlayerResponseFromJson(Map<String, dynamic> json) =>
|
||||||
|
PlayerResponse(
|
||||||
|
embedUrl: json['embedUrl'] as String?,
|
||||||
|
playerType: json['playerType'] as String?,
|
||||||
|
error: json['error'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$PlayerResponseToJson(PlayerResponse instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'embedUrl': instance.embedUrl,
|
||||||
|
'playerType': instance.playerType,
|
||||||
|
'error': instance.error,
|
||||||
|
};
|
||||||
73
lib/data/models/player/player_settings.dart
Normal file
73
lib/data/models/player/player_settings.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:neomovies_mobile/data/models/player/video_quality.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/player/audio_track.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/player/subtitle.dart';
|
||||||
|
|
||||||
|
class PlayerSettings {
|
||||||
|
final VideoQuality? selectedQuality;
|
||||||
|
final AudioTrack? selectedAudioTrack;
|
||||||
|
final Subtitle? selectedSubtitle;
|
||||||
|
final double volume;
|
||||||
|
final double playbackSpeed;
|
||||||
|
final bool autoPlay;
|
||||||
|
final bool muted;
|
||||||
|
|
||||||
|
PlayerSettings({
|
||||||
|
this.selectedQuality,
|
||||||
|
this.selectedAudioTrack,
|
||||||
|
this.selectedSubtitle,
|
||||||
|
this.volume = 1.0,
|
||||||
|
this.playbackSpeed = 1.0,
|
||||||
|
this.autoPlay = true,
|
||||||
|
this.muted = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
PlayerSettings copyWith({
|
||||||
|
VideoQuality? selectedQuality,
|
||||||
|
AudioTrack? selectedAudioTrack,
|
||||||
|
Subtitle? selectedSubtitle,
|
||||||
|
double? volume,
|
||||||
|
double? playbackSpeed,
|
||||||
|
bool? autoPlay,
|
||||||
|
bool? muted,
|
||||||
|
}) {
|
||||||
|
return PlayerSettings(
|
||||||
|
selectedQuality: selectedQuality ?? this.selectedQuality,
|
||||||
|
selectedAudioTrack: selectedAudioTrack ?? this.selectedAudioTrack,
|
||||||
|
selectedSubtitle: selectedSubtitle ?? this.selectedSubtitle,
|
||||||
|
volume: volume ?? this.volume,
|
||||||
|
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
|
||||||
|
autoPlay: autoPlay ?? this.autoPlay,
|
||||||
|
muted: muted ?? this.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PlayerSettings.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PlayerSettings(
|
||||||
|
selectedQuality: json['selectedQuality'] != null
|
||||||
|
? VideoQuality.fromJson(json['selectedQuality'])
|
||||||
|
: null,
|
||||||
|
selectedAudioTrack: json['selectedAudioTrack'] != null
|
||||||
|
? AudioTrack.fromJson(json['selectedAudioTrack'])
|
||||||
|
: null,
|
||||||
|
selectedSubtitle: json['selectedSubtitle'] != null
|
||||||
|
? Subtitle.fromJson(json['selectedSubtitle'])
|
||||||
|
: null,
|
||||||
|
volume: json['volume']?.toDouble() ?? 1.0,
|
||||||
|
playbackSpeed: json['playbackSpeed']?.toDouble() ?? 1.0,
|
||||||
|
autoPlay: json['autoPlay'] ?? true,
|
||||||
|
muted: json['muted'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'selectedQuality': selectedQuality?.toJson(),
|
||||||
|
'selectedAudioTrack': selectedAudioTrack?.toJson(),
|
||||||
|
'selectedSubtitle': selectedSubtitle?.toJson(),
|
||||||
|
'volume': volume,
|
||||||
|
'playbackSpeed': playbackSpeed,
|
||||||
|
'autoPlay': autoPlay,
|
||||||
|
'muted': muted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
34
lib/data/models/player/subtitle.dart
Normal file
34
lib/data/models/player/subtitle.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
class Subtitle {
|
||||||
|
final String name;
|
||||||
|
final String language;
|
||||||
|
final String url;
|
||||||
|
final bool isDefault;
|
||||||
|
|
||||||
|
Subtitle({
|
||||||
|
required this.name,
|
||||||
|
required this.language,
|
||||||
|
required this.url,
|
||||||
|
this.isDefault = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Subtitle.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Subtitle(
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
language: json['language'] ?? '',
|
||||||
|
url: json['url'] ?? '',
|
||||||
|
isDefault: json['isDefault'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'language': language,
|
||||||
|
'url': url,
|
||||||
|
'isDefault': isDefault,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => name;
|
||||||
|
}
|
||||||
38
lib/data/models/player/video_quality.dart
Normal file
38
lib/data/models/player/video_quality.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
class VideoQuality {
|
||||||
|
final String quality;
|
||||||
|
final String url;
|
||||||
|
final int bandwidth;
|
||||||
|
final int width;
|
||||||
|
final int height;
|
||||||
|
|
||||||
|
VideoQuality({
|
||||||
|
required this.quality,
|
||||||
|
required this.url,
|
||||||
|
required this.bandwidth,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory VideoQuality.fromJson(Map<String, dynamic> json) {
|
||||||
|
return VideoQuality(
|
||||||
|
quality: json['quality'] ?? '',
|
||||||
|
url: json['url'] ?? '',
|
||||||
|
bandwidth: json['bandwidth'] ?? 0,
|
||||||
|
width: json['width'] ?? 0,
|
||||||
|
height: json['height'] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'quality': quality,
|
||||||
|
'url': url,
|
||||||
|
'bandwidth': bandwidth,
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => quality;
|
||||||
|
}
|
||||||
@@ -14,12 +14,20 @@ class Reaction {
|
|||||||
|
|
||||||
class UserReaction {
|
class UserReaction {
|
||||||
final String? reactionType;
|
final String? reactionType;
|
||||||
|
final String? mediaType;
|
||||||
|
final String? mediaId;
|
||||||
|
|
||||||
UserReaction({this.reactionType});
|
UserReaction({
|
||||||
|
this.reactionType,
|
||||||
|
this.mediaType,
|
||||||
|
this.mediaId,
|
||||||
|
});
|
||||||
|
|
||||||
factory UserReaction.fromJson(Map<String, dynamic> json) {
|
factory UserReaction.fromJson(Map<String, dynamic> json) {
|
||||||
return UserReaction(
|
return UserReaction(
|
||||||
reactionType: json['type'] as String?,
|
reactionType: json['type'] as String?,
|
||||||
|
mediaType: json['mediaType'] as String?,
|
||||||
|
mediaId: json['mediaId'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
lib/data/models/torrent.dart
Normal file
20
lib/data/models/torrent.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'torrent.freezed.dart';
|
||||||
|
part 'torrent.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class Torrent with _$Torrent {
|
||||||
|
const factory Torrent({
|
||||||
|
required String magnet,
|
||||||
|
String? title,
|
||||||
|
String? name,
|
||||||
|
String? quality,
|
||||||
|
int? seeders,
|
||||||
|
int? size, // размер в байтах
|
||||||
|
}) = _Torrent;
|
||||||
|
|
||||||
|
factory Torrent.fromJson(Map<String, dynamic> json) => _$TorrentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
252
lib/data/models/torrent.freezed.dart
Normal file
252
lib/data/models/torrent.freezed.dart
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'torrent.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||||
|
|
||||||
|
Torrent _$TorrentFromJson(Map<String, dynamic> json) {
|
||||||
|
return _Torrent.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$Torrent {
|
||||||
|
String get magnet => throw _privateConstructorUsedError;
|
||||||
|
String? get title => throw _privateConstructorUsedError;
|
||||||
|
String? get name => throw _privateConstructorUsedError;
|
||||||
|
String? get quality => throw _privateConstructorUsedError;
|
||||||
|
int? get seeders => throw _privateConstructorUsedError;
|
||||||
|
int? get size => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
$TorrentCopyWith<Torrent> get copyWith => throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $TorrentCopyWith<$Res> {
|
||||||
|
factory $TorrentCopyWith(Torrent value, $Res Function(Torrent) then) =
|
||||||
|
_$TorrentCopyWithImpl<$Res, Torrent>;
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{String magnet,
|
||||||
|
String? title,
|
||||||
|
String? name,
|
||||||
|
String? quality,
|
||||||
|
int? seeders,
|
||||||
|
int? size});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$TorrentCopyWithImpl<$Res, $Val extends Torrent>
|
||||||
|
implements $TorrentCopyWith<$Res> {
|
||||||
|
_$TorrentCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? magnet = null,
|
||||||
|
Object? title = freezed,
|
||||||
|
Object? name = freezed,
|
||||||
|
Object? quality = freezed,
|
||||||
|
Object? seeders = freezed,
|
||||||
|
Object? size = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_value.copyWith(
|
||||||
|
magnet: null == magnet
|
||||||
|
? _value.magnet
|
||||||
|
: magnet // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
title: freezed == title
|
||||||
|
? _value.title
|
||||||
|
: title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
name: freezed == name
|
||||||
|
? _value.name
|
||||||
|
: name // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
quality: freezed == quality
|
||||||
|
? _value.quality
|
||||||
|
: quality // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
seeders: freezed == seeders
|
||||||
|
? _value.seeders
|
||||||
|
: seeders // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
size: freezed == size
|
||||||
|
? _value.size
|
||||||
|
: size // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
) as $Val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$TorrentImplCopyWith<$Res> implements $TorrentCopyWith<$Res> {
|
||||||
|
factory _$$TorrentImplCopyWith(
|
||||||
|
_$TorrentImpl value, $Res Function(_$TorrentImpl) then) =
|
||||||
|
__$$TorrentImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{String magnet,
|
||||||
|
String? title,
|
||||||
|
String? name,
|
||||||
|
String? quality,
|
||||||
|
int? seeders,
|
||||||
|
int? size});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$TorrentImplCopyWithImpl<$Res>
|
||||||
|
extends _$TorrentCopyWithImpl<$Res, _$TorrentImpl>
|
||||||
|
implements _$$TorrentImplCopyWith<$Res> {
|
||||||
|
__$$TorrentImplCopyWithImpl(
|
||||||
|
_$TorrentImpl _value, $Res Function(_$TorrentImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? magnet = null,
|
||||||
|
Object? title = freezed,
|
||||||
|
Object? name = freezed,
|
||||||
|
Object? quality = freezed,
|
||||||
|
Object? seeders = freezed,
|
||||||
|
Object? size = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_$TorrentImpl(
|
||||||
|
magnet: null == magnet
|
||||||
|
? _value.magnet
|
||||||
|
: magnet // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
title: freezed == title
|
||||||
|
? _value.title
|
||||||
|
: title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
name: freezed == name
|
||||||
|
? _value.name
|
||||||
|
: name // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
quality: freezed == quality
|
||||||
|
? _value.quality
|
||||||
|
: quality // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
seeders: freezed == seeders
|
||||||
|
? _value.seeders
|
||||||
|
: seeders // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
size: freezed == size
|
||||||
|
? _value.size
|
||||||
|
: size // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
class _$TorrentImpl implements _Torrent {
|
||||||
|
const _$TorrentImpl(
|
||||||
|
{required this.magnet,
|
||||||
|
this.title,
|
||||||
|
this.name,
|
||||||
|
this.quality,
|
||||||
|
this.seeders,
|
||||||
|
this.size});
|
||||||
|
|
||||||
|
factory _$TorrentImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$$TorrentImplFromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String magnet;
|
||||||
|
@override
|
||||||
|
final String? title;
|
||||||
|
@override
|
||||||
|
final String? name;
|
||||||
|
@override
|
||||||
|
final String? quality;
|
||||||
|
@override
|
||||||
|
final int? seeders;
|
||||||
|
@override
|
||||||
|
final int? size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Torrent(magnet: $magnet, title: $title, name: $name, quality: $quality, seeders: $seeders, size: $size)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$TorrentImpl &&
|
||||||
|
(identical(other.magnet, magnet) || other.magnet == magnet) &&
|
||||||
|
(identical(other.title, title) || other.title == title) &&
|
||||||
|
(identical(other.name, name) || other.name == name) &&
|
||||||
|
(identical(other.quality, quality) || other.quality == quality) &&
|
||||||
|
(identical(other.seeders, seeders) || other.seeders == seeders) &&
|
||||||
|
(identical(other.size, size) || other.size == size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(runtimeType, magnet, title, name, quality, seeders, size);
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$TorrentImplCopyWith<_$TorrentImpl> get copyWith =>
|
||||||
|
__$$TorrentImplCopyWithImpl<_$TorrentImpl>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$$TorrentImplToJson(
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Torrent implements Torrent {
|
||||||
|
const factory _Torrent(
|
||||||
|
{required final String magnet,
|
||||||
|
final String? title,
|
||||||
|
final String? name,
|
||||||
|
final String? quality,
|
||||||
|
final int? seeders,
|
||||||
|
final int? size}) = _$TorrentImpl;
|
||||||
|
|
||||||
|
factory _Torrent.fromJson(Map<String, dynamic> json) = _$TorrentImpl.fromJson;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get magnet;
|
||||||
|
@override
|
||||||
|
String? get title;
|
||||||
|
@override
|
||||||
|
String? get name;
|
||||||
|
@override
|
||||||
|
String? get quality;
|
||||||
|
@override
|
||||||
|
int? get seeders;
|
||||||
|
@override
|
||||||
|
int? get size;
|
||||||
|
@override
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$TorrentImplCopyWith<_$TorrentImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
27
lib/data/models/torrent.g.dart
Normal file
27
lib/data/models/torrent.g.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'torrent.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_$TorrentImpl _$$TorrentImplFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$TorrentImpl(
|
||||||
|
magnet: json['magnet'] as String,
|
||||||
|
title: json['title'] as String?,
|
||||||
|
name: json['name'] as String?,
|
||||||
|
quality: json['quality'] as String?,
|
||||||
|
seeders: (json['seeders'] as num?)?.toInt(),
|
||||||
|
size: (json['size'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$TorrentImplToJson(_$TorrentImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'magnet': instance.magnet,
|
||||||
|
'title': instance.title,
|
||||||
|
'name': instance.name,
|
||||||
|
'quality': instance.quality,
|
||||||
|
'seeders': instance.seeders,
|
||||||
|
'size': instance.size,
|
||||||
|
};
|
||||||
29
lib/data/models/torrent/torrent_item.dart
Normal file
29
lib/data/models/torrent/torrent_item.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'torrent_item.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class TorrentItem {
|
||||||
|
final String? title;
|
||||||
|
final String? magnetUrl;
|
||||||
|
final String? quality;
|
||||||
|
final int? seeders;
|
||||||
|
final int? leechers;
|
||||||
|
final String? size;
|
||||||
|
final String? source;
|
||||||
|
|
||||||
|
TorrentItem({
|
||||||
|
this.title,
|
||||||
|
this.magnetUrl,
|
||||||
|
this.quality,
|
||||||
|
this.seeders,
|
||||||
|
this.leechers,
|
||||||
|
this.size,
|
||||||
|
this.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TorrentItem.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$TorrentItemFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$TorrentItemToJson(this);
|
||||||
|
}
|
||||||
28
lib/data/models/torrent/torrent_item.g.dart
Normal file
28
lib/data/models/torrent/torrent_item.g.dart
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'torrent_item.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
TorrentItem _$TorrentItemFromJson(Map<String, dynamic> json) => TorrentItem(
|
||||||
|
title: json['title'] as String?,
|
||||||
|
magnetUrl: json['magnetUrl'] as String?,
|
||||||
|
quality: json['quality'] as String?,
|
||||||
|
seeders: (json['seeders'] as num?)?.toInt(),
|
||||||
|
leechers: (json['leechers'] as num?)?.toInt(),
|
||||||
|
size: json['size'] as String?,
|
||||||
|
source: json['source'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$TorrentItemToJson(TorrentItem instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'title': instance.title,
|
||||||
|
'magnetUrl': instance.magnetUrl,
|
||||||
|
'quality': instance.quality,
|
||||||
|
'seeders': instance.seeders,
|
||||||
|
'leechers': instance.leechers,
|
||||||
|
'size': instance.size,
|
||||||
|
'source': instance.source,
|
||||||
|
};
|
||||||
180
lib/data/models/torrent_info.dart
Normal file
180
lib/data/models/torrent_info.dart
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/// File priority enum matching Android implementation
|
||||||
|
enum FilePriority {
|
||||||
|
DONT_DOWNLOAD(0),
|
||||||
|
NORMAL(4),
|
||||||
|
HIGH(7);
|
||||||
|
|
||||||
|
const FilePriority(this.value);
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
static FilePriority fromValue(int value) {
|
||||||
|
return FilePriority.values.firstWhere(
|
||||||
|
(priority) => priority.value == value,
|
||||||
|
orElse: () => FilePriority.NORMAL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator >(FilePriority other) => value > other.value;
|
||||||
|
bool operator <(FilePriority other) => value < other.value;
|
||||||
|
bool operator >=(FilePriority other) => value >= other.value;
|
||||||
|
bool operator <=(FilePriority other) => value <= other.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Torrent file information matching Android TorrentFileInfo
|
||||||
|
class TorrentFileInfo {
|
||||||
|
final String path;
|
||||||
|
final int size;
|
||||||
|
final FilePriority priority;
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
TorrentFileInfo({
|
||||||
|
required this.path,
|
||||||
|
required this.size,
|
||||||
|
required this.priority,
|
||||||
|
this.progress = 0.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TorrentFileInfo.fromAndroidJson(Map<String, dynamic> json) {
|
||||||
|
return TorrentFileInfo(
|
||||||
|
path: json['path'] as String,
|
||||||
|
size: json['size'] as int,
|
||||||
|
priority: FilePriority.fromValue(json['priority'] as int),
|
||||||
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'path': path,
|
||||||
|
'size': size,
|
||||||
|
'priority': priority.value,
|
||||||
|
'progress': progress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main torrent information class matching Android TorrentInfo
|
||||||
|
class TorrentInfo {
|
||||||
|
final String infoHash;
|
||||||
|
final String name;
|
||||||
|
final int totalSize;
|
||||||
|
final double progress;
|
||||||
|
final int downloadSpeed;
|
||||||
|
final int uploadSpeed;
|
||||||
|
final int numSeeds;
|
||||||
|
final int numPeers;
|
||||||
|
final String state;
|
||||||
|
final String savePath;
|
||||||
|
final List<TorrentFileInfo> files;
|
||||||
|
final int pieceLength;
|
||||||
|
final int numPieces;
|
||||||
|
final DateTime? addedTime;
|
||||||
|
|
||||||
|
TorrentInfo({
|
||||||
|
required this.infoHash,
|
||||||
|
required this.name,
|
||||||
|
required this.totalSize,
|
||||||
|
required this.progress,
|
||||||
|
required this.downloadSpeed,
|
||||||
|
required this.uploadSpeed,
|
||||||
|
required this.numSeeds,
|
||||||
|
required this.numPeers,
|
||||||
|
required this.state,
|
||||||
|
required this.savePath,
|
||||||
|
required this.files,
|
||||||
|
this.pieceLength = 0,
|
||||||
|
this.numPieces = 0,
|
||||||
|
this.addedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TorrentInfo.fromAndroidJson(Map<String, dynamic> json) {
|
||||||
|
final filesJson = json['files'] as List<dynamic>? ?? [];
|
||||||
|
final files = filesJson
|
||||||
|
.map((fileJson) => TorrentFileInfo.fromAndroidJson(fileJson as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return TorrentInfo(
|
||||||
|
infoHash: json['infoHash'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
totalSize: json['totalSize'] as int,
|
||||||
|
progress: (json['progress'] as num).toDouble(),
|
||||||
|
downloadSpeed: json['downloadSpeed'] as int,
|
||||||
|
uploadSpeed: json['uploadSpeed'] as int,
|
||||||
|
numSeeds: json['numSeeds'] as int,
|
||||||
|
numPeers: json['numPeers'] as int,
|
||||||
|
state: json['state'] as String,
|
||||||
|
savePath: json['savePath'] as String,
|
||||||
|
files: files,
|
||||||
|
pieceLength: json['pieceLength'] as int? ?? 0,
|
||||||
|
numPieces: json['numPieces'] as int? ?? 0,
|
||||||
|
addedTime: json['addedTime'] != null
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(json['addedTime'] as int)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'infoHash': infoHash,
|
||||||
|
'name': name,
|
||||||
|
'totalSize': totalSize,
|
||||||
|
'progress': progress,
|
||||||
|
'downloadSpeed': downloadSpeed,
|
||||||
|
'uploadSpeed': uploadSpeed,
|
||||||
|
'numSeeds': numSeeds,
|
||||||
|
'numPeers': numPeers,
|
||||||
|
'state': state,
|
||||||
|
'savePath': savePath,
|
||||||
|
'files': files.map((file) => file.toJson()).toList(),
|
||||||
|
'pieceLength': pieceLength,
|
||||||
|
'numPieces': numPieces,
|
||||||
|
'addedTime': addedTime?.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get video files only
|
||||||
|
List<TorrentFileInfo> get videoFiles {
|
||||||
|
final videoExtensions = {'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v'};
|
||||||
|
return files.where((file) {
|
||||||
|
final extension = file.path.toLowerCase().split('.').last;
|
||||||
|
return videoExtensions.contains('.$extension');
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the largest video file (usually the main movie file)
|
||||||
|
TorrentFileInfo? get mainVideoFile {
|
||||||
|
final videos = videoFiles;
|
||||||
|
if (videos.isEmpty) return null;
|
||||||
|
|
||||||
|
videos.sort((a, b) => b.size.compareTo(a.size));
|
||||||
|
return videos.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if torrent is completed
|
||||||
|
bool get isCompleted => progress >= 1.0;
|
||||||
|
|
||||||
|
/// Check if torrent is downloading
|
||||||
|
bool get isDownloading => state == 'DOWNLOADING';
|
||||||
|
|
||||||
|
/// Check if torrent is seeding
|
||||||
|
bool get isSeeding => state == 'SEEDING';
|
||||||
|
|
||||||
|
/// Check if torrent is paused
|
||||||
|
bool get isPaused => state == 'PAUSED';
|
||||||
|
|
||||||
|
/// Get formatted download speed
|
||||||
|
String get formattedDownloadSpeed => _formatBytes(downloadSpeed);
|
||||||
|
|
||||||
|
/// Get formatted upload speed
|
||||||
|
String get formattedUploadSpeed => _formatBytes(uploadSpeed);
|
||||||
|
|
||||||
|
/// Get formatted total size
|
||||||
|
String get formattedTotalSize => _formatBytes(totalSize);
|
||||||
|
|
||||||
|
static String _formatBytes(int bytes) {
|
||||||
|
if (bytes < 1024) return '${bytes}B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB';
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,30 @@ class User {
|
|||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String email;
|
final String email;
|
||||||
|
final bool verified;
|
||||||
|
|
||||||
User({required this.id, required this.name, required this.email});
|
User({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.email,
|
||||||
|
this.verified = true,
|
||||||
|
});
|
||||||
|
|
||||||
factory User.fromJson(Map<String, dynamic> json) {
|
factory User.fromJson(Map<String, dynamic> json) {
|
||||||
return User(
|
return User(
|
||||||
id: json['_id'] as String? ?? '',
|
id: (json['_id'] ?? json['id'] ?? '') as String,
|
||||||
name: json['name'] as String? ?? '',
|
name: json['name'] as String? ?? '',
|
||||||
email: json['email'] as String? ?? '',
|
email: json['email'] as String? ?? '',
|
||||||
|
verified: json['verified'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'_id': id,
|
||||||
|
'name': name,
|
||||||
|
'email': email,
|
||||||
|
'verified': verified,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,13 @@ class AuthRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> verifyEmail(String email, String code) async {
|
Future<void> verifyEmail(String email, String code) async {
|
||||||
await _apiClient.verify(email, code);
|
final response = await _apiClient.verify(email, code);
|
||||||
// After successful verification, the user should log in.
|
// Auto-login user after successful verification
|
||||||
|
await _storageService.saveToken(response.token);
|
||||||
|
await _storageService.saveUserData(
|
||||||
|
name: response.user.name,
|
||||||
|
email: response.user.email,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resendVerificationCode(String email) async {
|
Future<void> resendVerificationCode(String email) async {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ReactionsRepository {
|
|||||||
return await _apiClient.getReactionCounts(mediaType, mediaId);
|
return await _apiClient.getReactionCounts(mediaType, mediaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<UserReaction> getMyReaction(String mediaType,String mediaId) async {
|
Future<UserReaction?> getMyReaction(String mediaType,String mediaId) async {
|
||||||
return await _apiClient.getMyReaction(mediaType, mediaId);
|
return await _apiClient.getMyReaction(mediaType, mediaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
130
lib/data/services/player_embed_service.dart
Normal file
130
lib/data/services/player_embed_service.dart
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// Service for getting player embed URLs from NeoMovies API server
|
||||||
|
class PlayerEmbedService {
|
||||||
|
static const String _baseUrl = 'https://neomovies.site'; // Replace with actual base URL
|
||||||
|
|
||||||
|
/// Get Vibix player embed URL from server
|
||||||
|
static Future<String> getVibixEmbedUrl({
|
||||||
|
required String videoUrl,
|
||||||
|
required String title,
|
||||||
|
String? imdbId,
|
||||||
|
String? season,
|
||||||
|
String? episode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$_baseUrl/api/player/vibix/embed'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'videoUrl': videoUrl,
|
||||||
|
'title': title,
|
||||||
|
'imdbId': imdbId,
|
||||||
|
'season': season,
|
||||||
|
'episode': episode,
|
||||||
|
'autoplay': true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
return data['embedUrl'] as String;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to get Vibix embed URL: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to direct URL if server is unavailable
|
||||||
|
final encodedVideoUrl = Uri.encodeComponent(videoUrl);
|
||||||
|
final encodedTitle = Uri.encodeComponent(title);
|
||||||
|
return 'https://vibix.me/embed/?src=$encodedVideoUrl&autoplay=1&title=$encodedTitle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Alloha player embed URL from server
|
||||||
|
static Future<String> getAllohaEmbedUrl({
|
||||||
|
required String videoUrl,
|
||||||
|
required String title,
|
||||||
|
String? imdbId,
|
||||||
|
String? season,
|
||||||
|
String? episode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$_baseUrl/api/player/alloha/embed'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'videoUrl': videoUrl,
|
||||||
|
'title': title,
|
||||||
|
'imdbId': imdbId,
|
||||||
|
'season': season,
|
||||||
|
'episode': episode,
|
||||||
|
'autoplay': true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
return data['embedUrl'] as String;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to get Alloha embed URL: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to direct URL if server is unavailable
|
||||||
|
final encodedVideoUrl = Uri.encodeComponent(videoUrl);
|
||||||
|
final encodedTitle = Uri.encodeComponent(title);
|
||||||
|
return 'https://alloha.tv/embed?src=$encodedVideoUrl&autoplay=1&title=$encodedTitle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get player configuration from server
|
||||||
|
static Future<Map<String, dynamic>?> getPlayerConfig({
|
||||||
|
required String playerType,
|
||||||
|
String? imdbId,
|
||||||
|
String? season,
|
||||||
|
String? episode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_baseUrl/api/player/$playerType/config').replace(
|
||||||
|
queryParameters: {
|
||||||
|
if (imdbId != null) 'imdbId': imdbId,
|
||||||
|
if (season != null) 'season': season,
|
||||||
|
if (episode != null) 'episode': episode,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if server player API is available
|
||||||
|
static Future<bool> isServerApiAvailable() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_baseUrl/api/player/health'),
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
596
lib/data/services/torrent_platform_service.dart
Normal file
596
lib/data/services/torrent_platform_service.dart
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../models/torrent_info.dart';
|
||||||
|
|
||||||
|
/// Data classes for torrent metadata (matching Kotlin side)
|
||||||
|
|
||||||
|
/// Базовая информация из magnet-ссылки
|
||||||
|
class MagnetBasicInfo {
|
||||||
|
final String name;
|
||||||
|
final String infoHash;
|
||||||
|
final List<String> trackers;
|
||||||
|
final int totalSize;
|
||||||
|
|
||||||
|
MagnetBasicInfo({
|
||||||
|
required this.name,
|
||||||
|
required this.infoHash,
|
||||||
|
required this.trackers,
|
||||||
|
this.totalSize = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MagnetBasicInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MagnetBasicInfo(
|
||||||
|
name: json['name'] as String,
|
||||||
|
infoHash: json['infoHash'] as String,
|
||||||
|
trackers: List<String>.from(json['trackers'] as List),
|
||||||
|
totalSize: json['totalSize'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'infoHash': infoHash,
|
||||||
|
'trackers': trackers,
|
||||||
|
'totalSize': totalSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Информация о файле в торренте
|
||||||
|
class FileInfo {
|
||||||
|
final String name;
|
||||||
|
final String path;
|
||||||
|
final int size;
|
||||||
|
final int index;
|
||||||
|
final String extension;
|
||||||
|
final bool isVideo;
|
||||||
|
final bool isAudio;
|
||||||
|
final bool isImage;
|
||||||
|
final bool isDocument;
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
|
FileInfo({
|
||||||
|
required this.name,
|
||||||
|
required this.path,
|
||||||
|
required this.size,
|
||||||
|
required this.index,
|
||||||
|
this.extension = '',
|
||||||
|
this.isVideo = false,
|
||||||
|
this.isAudio = false,
|
||||||
|
this.isImage = false,
|
||||||
|
this.isDocument = false,
|
||||||
|
this.selected = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FileInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FileInfo(
|
||||||
|
name: json['name'] as String,
|
||||||
|
path: json['path'] as String,
|
||||||
|
size: json['size'] as int,
|
||||||
|
index: json['index'] as int,
|
||||||
|
extension: json['extension'] as String? ?? '',
|
||||||
|
isVideo: json['isVideo'] as bool? ?? false,
|
||||||
|
isAudio: json['isAudio'] as bool? ?? false,
|
||||||
|
isImage: json['isImage'] as bool? ?? false,
|
||||||
|
isDocument: json['isDocument'] as bool? ?? false,
|
||||||
|
selected: json['selected'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'path': path,
|
||||||
|
'size': size,
|
||||||
|
'index': index,
|
||||||
|
'extension': extension,
|
||||||
|
'isVideo': isVideo,
|
||||||
|
'isAudio': isAudio,
|
||||||
|
'isImage': isImage,
|
||||||
|
'isDocument': isDocument,
|
||||||
|
'selected': selected,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
FileInfo copyWith({
|
||||||
|
String? name,
|
||||||
|
String? path,
|
||||||
|
int? size,
|
||||||
|
int? index,
|
||||||
|
String? extension,
|
||||||
|
bool? isVideo,
|
||||||
|
bool? isAudio,
|
||||||
|
bool? isImage,
|
||||||
|
bool? isDocument,
|
||||||
|
bool? selected,
|
||||||
|
}) {
|
||||||
|
return FileInfo(
|
||||||
|
name: name ?? this.name,
|
||||||
|
path: path ?? this.path,
|
||||||
|
size: size ?? this.size,
|
||||||
|
index: index ?? this.index,
|
||||||
|
extension: extension ?? this.extension,
|
||||||
|
isVideo: isVideo ?? this.isVideo,
|
||||||
|
isAudio: isAudio ?? this.isAudio,
|
||||||
|
isImage: isImage ?? this.isImage,
|
||||||
|
isDocument: isDocument ?? this.isDocument,
|
||||||
|
selected: selected ?? this.selected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Узел директории
|
||||||
|
class DirectoryNode {
|
||||||
|
final String name;
|
||||||
|
final String path;
|
||||||
|
final List<FileInfo> files;
|
||||||
|
final List<DirectoryNode> subdirectories;
|
||||||
|
final int totalSize;
|
||||||
|
final int fileCount;
|
||||||
|
|
||||||
|
DirectoryNode({
|
||||||
|
required this.name,
|
||||||
|
required this.path,
|
||||||
|
required this.files,
|
||||||
|
required this.subdirectories,
|
||||||
|
required this.totalSize,
|
||||||
|
required this.fileCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DirectoryNode.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DirectoryNode(
|
||||||
|
name: json['name'] as String,
|
||||||
|
path: json['path'] as String,
|
||||||
|
files: (json['files'] as List)
|
||||||
|
.map((file) => FileInfo.fromJson(file as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
subdirectories: (json['subdirectories'] as List)
|
||||||
|
.map((dir) => DirectoryNode.fromJson(dir as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
totalSize: json['totalSize'] as int,
|
||||||
|
fileCount: json['fileCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Структура файлов торрента
|
||||||
|
class FileStructure {
|
||||||
|
final DirectoryNode rootDirectory;
|
||||||
|
final int totalFiles;
|
||||||
|
final Map<String, int> filesByType;
|
||||||
|
|
||||||
|
FileStructure({
|
||||||
|
required this.rootDirectory,
|
||||||
|
required this.totalFiles,
|
||||||
|
required this.filesByType,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FileStructure.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FileStructure(
|
||||||
|
rootDirectory: DirectoryNode.fromJson(json['rootDirectory'] as Map<String, dynamic>),
|
||||||
|
totalFiles: json['totalFiles'] as int,
|
||||||
|
filesByType: Map<String, int>.from(json['filesByType'] as Map),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Полные метаданные торрента
|
||||||
|
class TorrentMetadataFull {
|
||||||
|
final String name;
|
||||||
|
final String infoHash;
|
||||||
|
final int totalSize;
|
||||||
|
final int pieceLength;
|
||||||
|
final int numPieces;
|
||||||
|
final FileStructure fileStructure;
|
||||||
|
final List<String> trackers;
|
||||||
|
final int creationDate;
|
||||||
|
final String comment;
|
||||||
|
final String createdBy;
|
||||||
|
|
||||||
|
TorrentMetadataFull({
|
||||||
|
required this.name,
|
||||||
|
required this.infoHash,
|
||||||
|
required this.totalSize,
|
||||||
|
required this.pieceLength,
|
||||||
|
required this.numPieces,
|
||||||
|
required this.fileStructure,
|
||||||
|
required this.trackers,
|
||||||
|
required this.creationDate,
|
||||||
|
required this.comment,
|
||||||
|
required this.createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TorrentMetadataFull.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TorrentMetadataFull(
|
||||||
|
name: json['name'] as String,
|
||||||
|
infoHash: json['infoHash'] as String,
|
||||||
|
totalSize: json['totalSize'] as int,
|
||||||
|
pieceLength: json['pieceLength'] as int,
|
||||||
|
numPieces: json['numPieces'] as int,
|
||||||
|
fileStructure: FileStructure.fromJson(json['fileStructure'] as Map<String, dynamic>),
|
||||||
|
trackers: List<String>.from(json['trackers'] as List),
|
||||||
|
creationDate: json['creationDate'] as int,
|
||||||
|
comment: json['comment'] as String,
|
||||||
|
createdBy: json['createdBy'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить плоский список всех файлов
|
||||||
|
List<FileInfo> getAllFiles() {
|
||||||
|
final List<FileInfo> allFiles = [];
|
||||||
|
_collectFiles(fileStructure.rootDirectory, allFiles);
|
||||||
|
return allFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _collectFiles(DirectoryNode directory, List<FileInfo> result) {
|
||||||
|
result.addAll(directory.files);
|
||||||
|
for (final subdir in directory.subdirectories) {
|
||||||
|
_collectFiles(subdir, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TorrentFileInfo {
|
||||||
|
final String path;
|
||||||
|
final int size;
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
|
TorrentFileInfo({
|
||||||
|
required this.path,
|
||||||
|
required this.size,
|
||||||
|
this.selected = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TorrentFileInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TorrentFileInfo(
|
||||||
|
path: json['path'] as String,
|
||||||
|
size: json['size'] as int,
|
||||||
|
selected: json['selected'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'path': path,
|
||||||
|
'size': size,
|
||||||
|
'selected': selected,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
TorrentFileInfo copyWith({
|
||||||
|
String? path,
|
||||||
|
int? size,
|
||||||
|
bool? selected,
|
||||||
|
}) {
|
||||||
|
return TorrentFileInfo(
|
||||||
|
path: path ?? this.path,
|
||||||
|
size: size ?? this.size,
|
||||||
|
selected: selected ?? this.selected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TorrentMetadata {
|
||||||
|
final String name;
|
||||||
|
final int totalSize;
|
||||||
|
final List<TorrentFileInfo> files;
|
||||||
|
final String infoHash;
|
||||||
|
|
||||||
|
TorrentMetadata({
|
||||||
|
required this.name,
|
||||||
|
required this.totalSize,
|
||||||
|
required this.files,
|
||||||
|
required this.infoHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TorrentMetadata.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TorrentMetadata(
|
||||||
|
name: json['name'] as String,
|
||||||
|
totalSize: json['totalSize'] as int,
|
||||||
|
files: (json['files'] as List)
|
||||||
|
.map((file) => TorrentFileInfo.fromJson(file as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
infoHash: json['infoHash'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'totalSize': totalSize,
|
||||||
|
'files': files.map((file) => file.toJson()).toList(),
|
||||||
|
'infoHash': infoHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadProgress {
|
||||||
|
final String infoHash;
|
||||||
|
final double progress;
|
||||||
|
final int downloadRate;
|
||||||
|
final int uploadRate;
|
||||||
|
final int numSeeds;
|
||||||
|
final int numPeers;
|
||||||
|
final String state;
|
||||||
|
|
||||||
|
DownloadProgress({
|
||||||
|
required this.infoHash,
|
||||||
|
required this.progress,
|
||||||
|
required this.downloadRate,
|
||||||
|
required this.uploadRate,
|
||||||
|
required this.numSeeds,
|
||||||
|
required this.numPeers,
|
||||||
|
required this.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DownloadProgress.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DownloadProgress(
|
||||||
|
infoHash: json['infoHash'] as String,
|
||||||
|
progress: (json['progress'] as num).toDouble(),
|
||||||
|
downloadRate: json['downloadRate'] as int,
|
||||||
|
uploadRate: json['uploadRate'] as int,
|
||||||
|
numSeeds: json['numSeeds'] as int,
|
||||||
|
numPeers: json['numPeers'] as int,
|
||||||
|
state: json['state'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Platform service for torrent operations using jlibtorrent on Android
|
||||||
|
class TorrentPlatformService {
|
||||||
|
static const MethodChannel _channel = MethodChannel('com.neo.neomovies_mobile/torrent');
|
||||||
|
|
||||||
|
/// Add torrent from magnet URI and start downloading
|
||||||
|
static Future<String> addTorrent({
|
||||||
|
required String magnetUri,
|
||||||
|
String? savePath,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final String infoHash = await _channel.invokeMethod('addTorrent', {
|
||||||
|
'magnetUri': magnetUri,
|
||||||
|
'savePath': savePath ?? '/storage/emulated/0/Download/NeoMovies',
|
||||||
|
});
|
||||||
|
|
||||||
|
return infoHash;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception('Failed to add torrent: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all torrents
|
||||||
|
static Future<List<DownloadProgress>> getAllDownloads() async {
|
||||||
|
try {
|
||||||
|
final String result = await _channel.invokeMethod('getTorrents');
|
||||||
|
|
||||||
|
final List<dynamic> jsonList = jsonDecode(result);
|
||||||
|
return jsonList.map((json) {
|
||||||
|
final data = json as Map<String, dynamic>;
|
||||||
|
return DownloadProgress(
|
||||||
|
infoHash: data['infoHash'] as String,
|
||||||
|
progress: (data['progress'] as num).toDouble(),
|
||||||
|
downloadRate: data['downloadSpeed'] as int,
|
||||||
|
uploadRate: data['uploadSpeed'] as int,
|
||||||
|
numSeeds: data['numSeeds'] as int,
|
||||||
|
numPeers: data['numPeers'] as int,
|
||||||
|
state: data['state'] as String,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception('Failed to get all downloads: ${e.message}');
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to parse downloads: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get single torrent info
|
||||||
|
static Future<TorrentInfo?> getTorrent(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final String result = await _channel.invokeMethod('getTorrent', {
|
||||||
|
'infoHash': infoHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> json = jsonDecode(result);
|
||||||
|
return TorrentInfo.fromAndroidJson(json);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (e.code == 'NOT_FOUND') return null;
|
||||||
|
throw Exception('Failed to get torrent: ${e.message}');
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to parse torrent: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get download progress for a torrent
|
||||||
|
static Future<DownloadProgress?> getDownloadProgress(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final torrentInfo = await getTorrent(infoHash);
|
||||||
|
if (torrentInfo == null) return null;
|
||||||
|
|
||||||
|
return DownloadProgress(
|
||||||
|
infoHash: torrentInfo.infoHash,
|
||||||
|
progress: torrentInfo.progress,
|
||||||
|
downloadRate: torrentInfo.downloadSpeed,
|
||||||
|
uploadRate: torrentInfo.uploadSpeed,
|
||||||
|
numSeeds: torrentInfo.numSeeds,
|
||||||
|
numPeers: torrentInfo.numPeers,
|
||||||
|
state: torrentInfo.state,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause download
|
||||||
|
static Future<bool> pauseDownload(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final bool result = await _channel.invokeMethod('pauseTorrent', {
|
||||||
|
'infoHash': infoHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception('Failed to pause download: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume download
|
||||||
|
static Future<bool> resumeDownload(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final bool result = await _channel.invokeMethod('resumeTorrent', {
|
||||||
|
'infoHash': infoHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception('Failed to resume download: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel and remove download
|
||||||
|
static Future<bool> cancelDownload(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final bool result = await _channel.invokeMethod('removeTorrent', {
|
||||||
|
'infoHash': infoHash,
|
||||||
|
'deleteFiles': true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception('Failed to cancel download: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set file priority
|
||||||
|
static Future<bool> setFilePriority(String infoHash, int fileIndex, FilePriority priority) async {
|
||||||
|
try {
|
||||||
|
final bool result = await _channel.invokeMethod('setFilePriority', {
|
||||||
|
'infoHash': infoHash,
|
||||||
|
'fileIndex': fileIndex,
|
||||||
|
'priority': priority.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception('Failed to set file priority: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start downloading selected files from torrent
|
||||||
|
static Future<String> startDownload({
|
||||||
|
required String magnetLink,
|
||||||
|
required List<int> selectedFiles,
|
||||||
|
String? downloadPath,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// First add the torrent
|
||||||
|
final String infoHash = await addTorrent(
|
||||||
|
magnetUri: magnetLink,
|
||||||
|
savePath: downloadPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for metadata to be received
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// Set file priorities
|
||||||
|
final torrentInfo = await getTorrent(infoHash);
|
||||||
|
if (torrentInfo != null) {
|
||||||
|
for (int i = 0; i < torrentInfo.files.length; i++) {
|
||||||
|
final priority = selectedFiles.contains(i)
|
||||||
|
? FilePriority.NORMAL
|
||||||
|
: FilePriority.DONT_DOWNLOAD;
|
||||||
|
await setFilePriority(infoHash, i, priority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return infoHash;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to start download: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy methods for compatibility with existing code
|
||||||
|
|
||||||
|
/// Get torrent metadata from magnet link (legacy method)
|
||||||
|
static Future<TorrentMetadata> getTorrentMetadata(String magnetLink) async {
|
||||||
|
try {
|
||||||
|
// This is a simplified implementation that adds the torrent and gets metadata
|
||||||
|
final infoHash = await addTorrent(magnetUri: magnetLink);
|
||||||
|
await Future.delayed(const Duration(seconds: 3)); // Wait for metadata
|
||||||
|
|
||||||
|
final torrentInfo = await getTorrent(infoHash);
|
||||||
|
if (torrentInfo == null) {
|
||||||
|
throw Exception('Failed to get torrent metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
return TorrentMetadata(
|
||||||
|
name: torrentInfo.name,
|
||||||
|
totalSize: torrentInfo.totalSize,
|
||||||
|
files: torrentInfo.files.map((file) => TorrentFileInfo(
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
selected: file.priority > FilePriority.DONT_DOWNLOAD,
|
||||||
|
)).toList(),
|
||||||
|
infoHash: torrentInfo.infoHash,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to get torrent metadata: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить базовую информацию из magnet-ссылки (legacy)
|
||||||
|
static Future<MagnetBasicInfo> parseMagnetBasicInfo(String magnetUri) async {
|
||||||
|
try {
|
||||||
|
// Parse magnet URI manually since Android implementation doesn't have this
|
||||||
|
final uri = Uri.parse(magnetUri);
|
||||||
|
final params = uri.queryParameters;
|
||||||
|
|
||||||
|
return MagnetBasicInfo(
|
||||||
|
name: params['dn'] ?? 'Unknown',
|
||||||
|
infoHash: params['xt']?.replaceFirst('urn:btih:', '') ?? '',
|
||||||
|
trackers: params['tr'] != null ? [params['tr']!] : [],
|
||||||
|
totalSize: 0,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to parse magnet basic info: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить полные метаданные торрента (legacy)
|
||||||
|
static Future<TorrentMetadataFull> fetchFullMetadata(String magnetUri) async {
|
||||||
|
try {
|
||||||
|
final basicInfo = await parseMagnetBasicInfo(magnetUri);
|
||||||
|
final metadata = await getTorrentMetadata(magnetUri);
|
||||||
|
|
||||||
|
return TorrentMetadataFull(
|
||||||
|
name: metadata.name,
|
||||||
|
infoHash: metadata.infoHash,
|
||||||
|
totalSize: metadata.totalSize,
|
||||||
|
pieceLength: 0,
|
||||||
|
numPieces: 0,
|
||||||
|
fileStructure: FileStructure(
|
||||||
|
rootDirectory: DirectoryNode(
|
||||||
|
name: metadata.name,
|
||||||
|
path: '/',
|
||||||
|
files: metadata.files.map((file) => FileInfo(
|
||||||
|
name: file.path.split('/').last,
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
index: metadata.files.indexOf(file),
|
||||||
|
)).toList(),
|
||||||
|
subdirectories: [],
|
||||||
|
totalSize: metadata.totalSize,
|
||||||
|
fileCount: metadata.files.length,
|
||||||
|
),
|
||||||
|
totalFiles: metadata.files.length,
|
||||||
|
filesByType: {'video': metadata.files.length},
|
||||||
|
),
|
||||||
|
trackers: basicInfo.trackers,
|
||||||
|
creationDate: 0,
|
||||||
|
comment: '',
|
||||||
|
createdBy: '',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to fetch full metadata: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
lib/data/services/torrent_service.dart
Normal file
163
lib/data/services/torrent_service.dart
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import '../models/torrent.dart';
|
||||||
|
|
||||||
|
class TorrentService {
|
||||||
|
static const String _baseUrl = 'API_URL';
|
||||||
|
|
||||||
|
String get apiUrl => dotenv.env[_baseUrl] ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
/// Получить торренты по IMDB ID
|
||||||
|
/// [imdbId] - IMDB ID фильма/сериала (например, 'tt1234567')
|
||||||
|
/// [type] - тип контента: 'movie' или 'tv'
|
||||||
|
/// [season] - номер сезона для сериалов (опционально)
|
||||||
|
Future<List<Torrent>> getTorrents({
|
||||||
|
required String imdbId,
|
||||||
|
required String type,
|
||||||
|
int? season,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse('$apiUrl/torrents/search/$imdbId').replace(
|
||||||
|
queryParameters: {
|
||||||
|
'type': type,
|
||||||
|
if (season != null) 'season': season.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
uri,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final results = data['results'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
return results
|
||||||
|
.map((json) => Torrent.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} else if (response.statusCode == 404) {
|
||||||
|
// Торренты не найдены - возвращаем пустой список
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
throw Exception('HTTP ${response.statusCode}: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Ошибка загрузки торрентов: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Определить качество из названия торрента
|
||||||
|
String? detectQuality(String title) {
|
||||||
|
final titleLower = title.toLowerCase();
|
||||||
|
|
||||||
|
// Порядок важен - сначала более специфичные паттерны
|
||||||
|
if (titleLower.contains('2160p') || titleLower.contains('4k')) {
|
||||||
|
return '4K';
|
||||||
|
}
|
||||||
|
if (titleLower.contains('1440p') || titleLower.contains('2k')) {
|
||||||
|
return '1440p';
|
||||||
|
}
|
||||||
|
if (titleLower.contains('1080p')) {
|
||||||
|
return '1080p';
|
||||||
|
}
|
||||||
|
if (titleLower.contains('720p')) {
|
||||||
|
return '720p';
|
||||||
|
}
|
||||||
|
if (titleLower.contains('480p')) {
|
||||||
|
return '480p';
|
||||||
|
}
|
||||||
|
if (titleLower.contains('360p')) {
|
||||||
|
return '360p';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматировать размер из байтов в читаемый формат
|
||||||
|
String formatFileSize(int? sizeInBytes) {
|
||||||
|
if (sizeInBytes == null || sizeInBytes == 0) return 'Неизвестно';
|
||||||
|
|
||||||
|
const int kb = 1024;
|
||||||
|
const int mb = kb * 1024;
|
||||||
|
const int gb = mb * 1024;
|
||||||
|
|
||||||
|
if (sizeInBytes >= gb) {
|
||||||
|
return '${(sizeInBytes / gb).toStringAsFixed(1)} GB';
|
||||||
|
} else if (sizeInBytes >= mb) {
|
||||||
|
return '${(sizeInBytes / mb).toStringAsFixed(0)} MB';
|
||||||
|
} else if (sizeInBytes >= kb) {
|
||||||
|
return '${(sizeInBytes / kb).toStringAsFixed(0)} KB';
|
||||||
|
} else {
|
||||||
|
return '$sizeInBytes B';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Группировать торренты по качеству
|
||||||
|
Map<String, List<Torrent>> groupTorrentsByQuality(List<Torrent> torrents) {
|
||||||
|
final groups = <String, List<Torrent>>{};
|
||||||
|
|
||||||
|
for (final torrent in torrents) {
|
||||||
|
final title = torrent.title ?? torrent.name ?? '';
|
||||||
|
final quality = detectQuality(title) ?? 'Неизвестно';
|
||||||
|
|
||||||
|
if (!groups.containsKey(quality)) {
|
||||||
|
groups[quality] = [];
|
||||||
|
}
|
||||||
|
groups[quality]!.add(torrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем торренты внутри каждой группы по количеству сидов (убывание)
|
||||||
|
for (final group in groups.values) {
|
||||||
|
group.sort((a, b) => (b.seeders ?? 0).compareTo(a.seeders ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаем группы в порядке качества (от высокого к низкому)
|
||||||
|
final sortedGroups = <String, List<Torrent>>{};
|
||||||
|
const qualityOrder = ['4K', '1440p', '1080p', '720p', '480p', '360p', 'Неизвестно'];
|
||||||
|
|
||||||
|
for (final quality in qualityOrder) {
|
||||||
|
if (groups.containsKey(quality) && groups[quality]!.isNotEmpty) {
|
||||||
|
sortedGroups[quality] = groups[quality]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить доступные сезоны для сериала
|
||||||
|
/// [imdbId] - IMDB ID сериала
|
||||||
|
Future<List<int>> getAvailableSeasons(String imdbId) async {
|
||||||
|
try {
|
||||||
|
// Получаем все торренты для сериала без указания сезона
|
||||||
|
final torrents = await getTorrents(imdbId: imdbId, type: 'tv');
|
||||||
|
|
||||||
|
// Извлекаем номера сезонов из названий торрентов
|
||||||
|
final seasons = <int>{};
|
||||||
|
|
||||||
|
for (final torrent in torrents) {
|
||||||
|
final title = torrent.title ?? torrent.name ?? '';
|
||||||
|
final seasonRegex = RegExp(r'(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон', caseSensitive: false);
|
||||||
|
final matches = seasonRegex.allMatches(title);
|
||||||
|
|
||||||
|
for (final match in matches) {
|
||||||
|
final seasonStr = match.group(1) ?? match.group(2);
|
||||||
|
if (seasonStr != null) {
|
||||||
|
final seasonNumber = int.tryParse(seasonStr);
|
||||||
|
if (seasonNumber != null && seasonNumber > 0) {
|
||||||
|
seasons.add(seasonNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final sortedSeasons = seasons.toList()..sort();
|
||||||
|
return sortedSeasons;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Ошибка получения списка сезонов: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import 'package:neomovies_mobile/presentation/providers/favorites_provider.dart'
|
|||||||
import 'package:neomovies_mobile/presentation/providers/home_provider.dart';
|
import 'package:neomovies_mobile/presentation/providers/home_provider.dart';
|
||||||
import 'package:neomovies_mobile/presentation/providers/movie_detail_provider.dart';
|
import 'package:neomovies_mobile/presentation/providers/movie_detail_provider.dart';
|
||||||
import 'package:neomovies_mobile/presentation/providers/reactions_provider.dart';
|
import 'package:neomovies_mobile/presentation/providers/reactions_provider.dart';
|
||||||
|
import 'package:neomovies_mobile/presentation/providers/downloads_provider.dart';
|
||||||
import 'package:neomovies_mobile/presentation/screens/main_screen.dart';
|
import 'package:neomovies_mobile/presentation/screens/main_screen.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
|||||||
115
lib/presentation/cubits/torrent/torrent_cubit.dart
Normal file
115
lib/presentation/cubits/torrent/torrent_cubit.dart
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../data/services/torrent_service.dart';
|
||||||
|
import 'torrent_state.dart';
|
||||||
|
|
||||||
|
class TorrentCubit extends Cubit<TorrentState> {
|
||||||
|
final TorrentService _torrentService;
|
||||||
|
|
||||||
|
TorrentCubit({required TorrentService torrentService})
|
||||||
|
: _torrentService = torrentService,
|
||||||
|
super(const TorrentState.initial());
|
||||||
|
|
||||||
|
/// Загрузить торренты для фильма или сериала
|
||||||
|
Future<void> loadTorrents({
|
||||||
|
required String imdbId,
|
||||||
|
required String mediaType,
|
||||||
|
int? season,
|
||||||
|
}) async {
|
||||||
|
emit(const TorrentState.loading());
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<int>? availableSeasons;
|
||||||
|
|
||||||
|
// Для сериалов получаем список доступных сезонов
|
||||||
|
if (mediaType == 'tv') {
|
||||||
|
availableSeasons = await _torrentService.getAvailableSeasons(imdbId);
|
||||||
|
|
||||||
|
// Если сезон не указан, выбираем первый доступный
|
||||||
|
if (season == null && availableSeasons.isNotEmpty) {
|
||||||
|
season = availableSeasons.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем торренты
|
||||||
|
final torrents = await _torrentService.getTorrents(
|
||||||
|
imdbId: imdbId,
|
||||||
|
type: mediaType,
|
||||||
|
season: season,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Группируем торренты по качеству
|
||||||
|
final qualityGroups = _torrentService.groupTorrentsByQuality(torrents);
|
||||||
|
|
||||||
|
emit(TorrentState.loaded(
|
||||||
|
torrents: torrents,
|
||||||
|
qualityGroups: qualityGroups,
|
||||||
|
imdbId: imdbId,
|
||||||
|
mediaType: mediaType,
|
||||||
|
selectedSeason: season,
|
||||||
|
availableSeasons: availableSeasons,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(TorrentState.error(message: e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Переключить сезон для сериала
|
||||||
|
Future<void> selectSeason(int season) async {
|
||||||
|
state.when(
|
||||||
|
initial: () {},
|
||||||
|
loading: () {},
|
||||||
|
error: (_) {},
|
||||||
|
loaded: (torrents, qualityGroups, imdbId, mediaType, selectedSeason, availableSeasons, selectedQuality) async {
|
||||||
|
emit(const TorrentState.loading());
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newTorrents = await _torrentService.getTorrents(
|
||||||
|
imdbId: imdbId,
|
||||||
|
type: mediaType,
|
||||||
|
season: season,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Группируем торренты по качеству
|
||||||
|
final newQualityGroups = _torrentService.groupTorrentsByQuality(newTorrents);
|
||||||
|
|
||||||
|
emit(TorrentState.loaded(
|
||||||
|
torrents: newTorrents,
|
||||||
|
qualityGroups: newQualityGroups,
|
||||||
|
imdbId: imdbId,
|
||||||
|
mediaType: mediaType,
|
||||||
|
selectedSeason: season,
|
||||||
|
availableSeasons: availableSeasons,
|
||||||
|
selectedQuality: null, // Сбрасываем фильтр качества при смене сезона
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(TorrentState.error(message: e.toString()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать фильтр по качеству
|
||||||
|
void selectQuality(String? quality) {
|
||||||
|
state.when(
|
||||||
|
initial: () {},
|
||||||
|
loading: () {},
|
||||||
|
error: (_) {},
|
||||||
|
loaded: (torrents, qualityGroups, imdbId, mediaType, selectedSeason, availableSeasons, selectedQuality) {
|
||||||
|
emit(TorrentState.loaded(
|
||||||
|
torrents: torrents,
|
||||||
|
qualityGroups: qualityGroups,
|
||||||
|
imdbId: imdbId,
|
||||||
|
mediaType: mediaType,
|
||||||
|
selectedSeason: selectedSeason,
|
||||||
|
availableSeasons: availableSeasons,
|
||||||
|
selectedQuality: quality,
|
||||||
|
));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сбросить состояние
|
||||||
|
void reset() {
|
||||||
|
emit(const TorrentState.initial());
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/presentation/cubits/torrent/torrent_state.dart
Normal file
25
lib/presentation/cubits/torrent/torrent_state.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import '../../../data/models/torrent.dart';
|
||||||
|
|
||||||
|
part 'torrent_state.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class TorrentState with _$TorrentState {
|
||||||
|
const factory TorrentState.initial() = _Initial;
|
||||||
|
|
||||||
|
const factory TorrentState.loading() = _Loading;
|
||||||
|
|
||||||
|
const factory TorrentState.loaded({
|
||||||
|
required List<Torrent> torrents,
|
||||||
|
required Map<String, List<Torrent>> qualityGroups,
|
||||||
|
required String imdbId,
|
||||||
|
required String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality, // Фильтр по качеству
|
||||||
|
}) = _Loaded;
|
||||||
|
|
||||||
|
const factory TorrentState.error({
|
||||||
|
required String message,
|
||||||
|
}) = _Error;
|
||||||
|
}
|
||||||
840
lib/presentation/cubits/torrent/torrent_state.freezed.dart
Normal file
840
lib/presentation/cubits/torrent/torrent_state.freezed.dart
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'torrent_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$TorrentState {
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult when<TResult extends Object?>({
|
||||||
|
required TResult Function() initial,
|
||||||
|
required TResult Function() loading,
|
||||||
|
required TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)
|
||||||
|
loaded,
|
||||||
|
required TResult Function(String message) error,
|
||||||
|
}) =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? whenOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function()? initial,
|
||||||
|
TResult? Function()? loading,
|
||||||
|
TResult? Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult? Function(String message)? error,
|
||||||
|
}) =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeWhen<TResult extends Object?>({
|
||||||
|
TResult Function()? initial,
|
||||||
|
TResult Function()? loading,
|
||||||
|
TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult Function(String message)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult map<TResult extends Object?>({
|
||||||
|
required TResult Function(_Initial value) initial,
|
||||||
|
required TResult Function(_Loading value) loading,
|
||||||
|
required TResult Function(_Loaded value) loaded,
|
||||||
|
required TResult Function(_Error value) error,
|
||||||
|
}) =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? mapOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function(_Initial value)? initial,
|
||||||
|
TResult? Function(_Loading value)? loading,
|
||||||
|
TResult? Function(_Loaded value)? loaded,
|
||||||
|
TResult? Function(_Error value)? error,
|
||||||
|
}) =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeMap<TResult extends Object?>({
|
||||||
|
TResult Function(_Initial value)? initial,
|
||||||
|
TResult Function(_Loading value)? loading,
|
||||||
|
TResult Function(_Loaded value)? loaded,
|
||||||
|
TResult Function(_Error value)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $TorrentStateCopyWith<$Res> {
|
||||||
|
factory $TorrentStateCopyWith(
|
||||||
|
TorrentState value, $Res Function(TorrentState) then) =
|
||||||
|
_$TorrentStateCopyWithImpl<$Res, TorrentState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$TorrentStateCopyWithImpl<$Res, $Val extends TorrentState>
|
||||||
|
implements $TorrentStateCopyWith<$Res> {
|
||||||
|
_$TorrentStateCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$InitialImplCopyWith<$Res> {
|
||||||
|
factory _$$InitialImplCopyWith(
|
||||||
|
_$InitialImpl value, $Res Function(_$InitialImpl) then) =
|
||||||
|
__$$InitialImplCopyWithImpl<$Res>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$InitialImplCopyWithImpl<$Res>
|
||||||
|
extends _$TorrentStateCopyWithImpl<$Res, _$InitialImpl>
|
||||||
|
implements _$$InitialImplCopyWith<$Res> {
|
||||||
|
__$$InitialImplCopyWithImpl(
|
||||||
|
_$InitialImpl _value, $Res Function(_$InitialImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$InitialImpl implements _Initial {
|
||||||
|
const _$InitialImpl();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'TorrentState.initial()';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType && other is _$InitialImpl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => runtimeType.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult when<TResult extends Object?>({
|
||||||
|
required TResult Function() initial,
|
||||||
|
required TResult Function() loading,
|
||||||
|
required TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)
|
||||||
|
loaded,
|
||||||
|
required TResult Function(String message) error,
|
||||||
|
}) {
|
||||||
|
return initial();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? whenOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function()? initial,
|
||||||
|
TResult? Function()? loading,
|
||||||
|
TResult? Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult? Function(String message)? error,
|
||||||
|
}) {
|
||||||
|
return initial?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeWhen<TResult extends Object?>({
|
||||||
|
TResult Function()? initial,
|
||||||
|
TResult Function()? loading,
|
||||||
|
TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult Function(String message)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) {
|
||||||
|
if (initial != null) {
|
||||||
|
return initial();
|
||||||
|
}
|
||||||
|
return orElse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult map<TResult extends Object?>({
|
||||||
|
required TResult Function(_Initial value) initial,
|
||||||
|
required TResult Function(_Loading value) loading,
|
||||||
|
required TResult Function(_Loaded value) loaded,
|
||||||
|
required TResult Function(_Error value) error,
|
||||||
|
}) {
|
||||||
|
return initial(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? mapOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function(_Initial value)? initial,
|
||||||
|
TResult? Function(_Loading value)? loading,
|
||||||
|
TResult? Function(_Loaded value)? loaded,
|
||||||
|
TResult? Function(_Error value)? error,
|
||||||
|
}) {
|
||||||
|
return initial?.call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeMap<TResult extends Object?>({
|
||||||
|
TResult Function(_Initial value)? initial,
|
||||||
|
TResult Function(_Loading value)? loading,
|
||||||
|
TResult Function(_Loaded value)? loaded,
|
||||||
|
TResult Function(_Error value)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) {
|
||||||
|
if (initial != null) {
|
||||||
|
return initial(this);
|
||||||
|
}
|
||||||
|
return orElse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Initial implements TorrentState {
|
||||||
|
const factory _Initial() = _$InitialImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$LoadingImplCopyWith<$Res> {
|
||||||
|
factory _$$LoadingImplCopyWith(
|
||||||
|
_$LoadingImpl value, $Res Function(_$LoadingImpl) then) =
|
||||||
|
__$$LoadingImplCopyWithImpl<$Res>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$LoadingImplCopyWithImpl<$Res>
|
||||||
|
extends _$TorrentStateCopyWithImpl<$Res, _$LoadingImpl>
|
||||||
|
implements _$$LoadingImplCopyWith<$Res> {
|
||||||
|
__$$LoadingImplCopyWithImpl(
|
||||||
|
_$LoadingImpl _value, $Res Function(_$LoadingImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$LoadingImpl implements _Loading {
|
||||||
|
const _$LoadingImpl();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'TorrentState.loading()';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType && other is _$LoadingImpl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => runtimeType.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult when<TResult extends Object?>({
|
||||||
|
required TResult Function() initial,
|
||||||
|
required TResult Function() loading,
|
||||||
|
required TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)
|
||||||
|
loaded,
|
||||||
|
required TResult Function(String message) error,
|
||||||
|
}) {
|
||||||
|
return loading();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? whenOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function()? initial,
|
||||||
|
TResult? Function()? loading,
|
||||||
|
TResult? Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult? Function(String message)? error,
|
||||||
|
}) {
|
||||||
|
return loading?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeWhen<TResult extends Object?>({
|
||||||
|
TResult Function()? initial,
|
||||||
|
TResult Function()? loading,
|
||||||
|
TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult Function(String message)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) {
|
||||||
|
if (loading != null) {
|
||||||
|
return loading();
|
||||||
|
}
|
||||||
|
return orElse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult map<TResult extends Object?>({
|
||||||
|
required TResult Function(_Initial value) initial,
|
||||||
|
required TResult Function(_Loading value) loading,
|
||||||
|
required TResult Function(_Loaded value) loaded,
|
||||||
|
required TResult Function(_Error value) error,
|
||||||
|
}) {
|
||||||
|
return loading(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? mapOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function(_Initial value)? initial,
|
||||||
|
TResult? Function(_Loading value)? loading,
|
||||||
|
TResult? Function(_Loaded value)? loaded,
|
||||||
|
TResult? Function(_Error value)? error,
|
||||||
|
}) {
|
||||||
|
return loading?.call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeMap<TResult extends Object?>({
|
||||||
|
TResult Function(_Initial value)? initial,
|
||||||
|
TResult Function(_Loading value)? loading,
|
||||||
|
TResult Function(_Loaded value)? loaded,
|
||||||
|
TResult Function(_Error value)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) {
|
||||||
|
if (loading != null) {
|
||||||
|
return loading(this);
|
||||||
|
}
|
||||||
|
return orElse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Loading implements TorrentState {
|
||||||
|
const factory _Loading() = _$LoadingImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$LoadedImplCopyWith<$Res> {
|
||||||
|
factory _$$LoadedImplCopyWith(
|
||||||
|
_$LoadedImpl value, $Res Function(_$LoadedImpl) then) =
|
||||||
|
__$$LoadedImplCopyWithImpl<$Res>;
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$LoadedImplCopyWithImpl<$Res>
|
||||||
|
extends _$TorrentStateCopyWithImpl<$Res, _$LoadedImpl>
|
||||||
|
implements _$$LoadedImplCopyWith<$Res> {
|
||||||
|
__$$LoadedImplCopyWithImpl(
|
||||||
|
_$LoadedImpl _value, $Res Function(_$LoadedImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? torrents = null,
|
||||||
|
Object? qualityGroups = null,
|
||||||
|
Object? imdbId = null,
|
||||||
|
Object? mediaType = null,
|
||||||
|
Object? selectedSeason = freezed,
|
||||||
|
Object? availableSeasons = freezed,
|
||||||
|
Object? selectedQuality = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_$LoadedImpl(
|
||||||
|
torrents: null == torrents
|
||||||
|
? _value._torrents
|
||||||
|
: torrents // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<Torrent>,
|
||||||
|
qualityGroups: null == qualityGroups
|
||||||
|
? _value._qualityGroups
|
||||||
|
: qualityGroups // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, List<Torrent>>,
|
||||||
|
imdbId: null == imdbId
|
||||||
|
? _value.imdbId
|
||||||
|
: imdbId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
mediaType: null == mediaType
|
||||||
|
? _value.mediaType
|
||||||
|
: mediaType // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
selectedSeason: freezed == selectedSeason
|
||||||
|
? _value.selectedSeason
|
||||||
|
: selectedSeason // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
availableSeasons: freezed == availableSeasons
|
||||||
|
? _value._availableSeasons
|
||||||
|
: availableSeasons // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<int>?,
|
||||||
|
selectedQuality: freezed == selectedQuality
|
||||||
|
? _value.selectedQuality
|
||||||
|
: selectedQuality // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$LoadedImpl implements _Loaded {
|
||||||
|
const _$LoadedImpl(
|
||||||
|
{required final List<Torrent> torrents,
|
||||||
|
required final Map<String, List<Torrent>> qualityGroups,
|
||||||
|
required this.imdbId,
|
||||||
|
required this.mediaType,
|
||||||
|
this.selectedSeason,
|
||||||
|
final List<int>? availableSeasons,
|
||||||
|
this.selectedQuality})
|
||||||
|
: _torrents = torrents,
|
||||||
|
_qualityGroups = qualityGroups,
|
||||||
|
_availableSeasons = availableSeasons;
|
||||||
|
|
||||||
|
final List<Torrent> _torrents;
|
||||||
|
@override
|
||||||
|
List<Torrent> get torrents {
|
||||||
|
if (_torrents is EqualUnmodifiableListView) return _torrents;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_torrents);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, List<Torrent>> _qualityGroups;
|
||||||
|
@override
|
||||||
|
Map<String, List<Torrent>> get qualityGroups {
|
||||||
|
if (_qualityGroups is EqualUnmodifiableMapView) return _qualityGroups;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableMapView(_qualityGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String imdbId;
|
||||||
|
@override
|
||||||
|
final String mediaType;
|
||||||
|
@override
|
||||||
|
final int? selectedSeason;
|
||||||
|
final List<int>? _availableSeasons;
|
||||||
|
@override
|
||||||
|
List<int>? get availableSeasons {
|
||||||
|
final value = _availableSeasons;
|
||||||
|
if (value == null) return null;
|
||||||
|
if (_availableSeasons is EqualUnmodifiableListView)
|
||||||
|
return _availableSeasons;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String? selectedQuality;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'TorrentState.loaded(torrents: $torrents, qualityGroups: $qualityGroups, imdbId: $imdbId, mediaType: $mediaType, selectedSeason: $selectedSeason, availableSeasons: $availableSeasons, selectedQuality: $selectedQuality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$LoadedImpl &&
|
||||||
|
const DeepCollectionEquality().equals(other._torrents, _torrents) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other._qualityGroups, _qualityGroups) &&
|
||||||
|
(identical(other.imdbId, imdbId) || other.imdbId == imdbId) &&
|
||||||
|
(identical(other.mediaType, mediaType) ||
|
||||||
|
other.mediaType == mediaType) &&
|
||||||
|
(identical(other.selectedSeason, selectedSeason) ||
|
||||||
|
other.selectedSeason == selectedSeason) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other._availableSeasons, _availableSeasons) &&
|
||||||
|
(identical(other.selectedQuality, selectedQuality) ||
|
||||||
|
other.selectedQuality == selectedQuality));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
const DeepCollectionEquality().hash(_torrents),
|
||||||
|
const DeepCollectionEquality().hash(_qualityGroups),
|
||||||
|
imdbId,
|
||||||
|
mediaType,
|
||||||
|
selectedSeason,
|
||||||
|
const DeepCollectionEquality().hash(_availableSeasons),
|
||||||
|
selectedQuality);
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$LoadedImplCopyWith<_$LoadedImpl> get copyWith =>
|
||||||
|
__$$LoadedImplCopyWithImpl<_$LoadedImpl>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult when<TResult extends Object?>({
|
||||||
|
required TResult Function() initial,
|
||||||
|
required TResult Function() loading,
|
||||||
|
required TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)
|
||||||
|
loaded,
|
||||||
|
required TResult Function(String message) error,
|
||||||
|
}) {
|
||||||
|
return loaded(torrents, qualityGroups, imdbId, mediaType, selectedSeason,
|
||||||
|
availableSeasons, selectedQuality);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? whenOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function()? initial,
|
||||||
|
TResult? Function()? loading,
|
||||||
|
TResult? Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult? Function(String message)? error,
|
||||||
|
}) {
|
||||||
|
return loaded?.call(torrents, qualityGroups, imdbId, mediaType,
|
||||||
|
selectedSeason, availableSeasons, selectedQuality);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeWhen<TResult extends Object?>({
|
||||||
|
TResult Function()? initial,
|
||||||
|
TResult Function()? loading,
|
||||||
|
TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult Function(String message)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) {
|
||||||
|
if (loaded != null) {
|
||||||
|
return loaded(torrents, qualityGroups, imdbId, mediaType, selectedSeason,
|
||||||
|
availableSeasons, selectedQuality);
|
||||||
|
}
|
||||||
|
return orElse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult map<TResult extends Object?>({
|
||||||
|
required TResult Function(_Initial value) initial,
|
||||||
|
required TResult Function(_Loading value) loading,
|
||||||
|
required TResult Function(_Loaded value) loaded,
|
||||||
|
required TResult Function(_Error value) error,
|
||||||
|
}) {
|
||||||
|
return loaded(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? mapOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function(_Initial value)? initial,
|
||||||
|
TResult? Function(_Loading value)? loading,
|
||||||
|
TResult? Function(_Loaded value)? loaded,
|
||||||
|
TResult? Function(_Error value)? error,
|
||||||
|
}) {
|
||||||
|
return loaded?.call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeMap<TResult extends Object?>({
|
||||||
|
TResult Function(_Initial value)? initial,
|
||||||
|
TResult Function(_Loading value)? loading,
|
||||||
|
TResult Function(_Loaded value)? loaded,
|
||||||
|
TResult Function(_Error value)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) {
|
||||||
|
if (loaded != null) {
|
||||||
|
return loaded(this);
|
||||||
|
}
|
||||||
|
return orElse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Loaded implements TorrentState {
|
||||||
|
const factory _Loaded(
|
||||||
|
{required final List<Torrent> torrents,
|
||||||
|
required final Map<String, List<Torrent>> qualityGroups,
|
||||||
|
required final String imdbId,
|
||||||
|
required final String mediaType,
|
||||||
|
final int? selectedSeason,
|
||||||
|
final List<int>? availableSeasons,
|
||||||
|
final String? selectedQuality}) = _$LoadedImpl;
|
||||||
|
|
||||||
|
List<Torrent> get torrents;
|
||||||
|
Map<String, List<Torrent>> get qualityGroups;
|
||||||
|
String get imdbId;
|
||||||
|
String get mediaType;
|
||||||
|
int? get selectedSeason;
|
||||||
|
List<int>? get availableSeasons;
|
||||||
|
String? get selectedQuality;
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$LoadedImplCopyWith<_$LoadedImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$ErrorImplCopyWith<$Res> {
|
||||||
|
factory _$$ErrorImplCopyWith(
|
||||||
|
_$ErrorImpl value, $Res Function(_$ErrorImpl) then) =
|
||||||
|
__$$ErrorImplCopyWithImpl<$Res>;
|
||||||
|
@useResult
|
||||||
|
$Res call({String message});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$ErrorImplCopyWithImpl<$Res>
|
||||||
|
extends _$TorrentStateCopyWithImpl<$Res, _$ErrorImpl>
|
||||||
|
implements _$$ErrorImplCopyWith<$Res> {
|
||||||
|
__$$ErrorImplCopyWithImpl(
|
||||||
|
_$ErrorImpl _value, $Res Function(_$ErrorImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? message = null,
|
||||||
|
}) {
|
||||||
|
return _then(_$ErrorImpl(
|
||||||
|
message: null == message
|
||||||
|
? _value.message
|
||||||
|
: message // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$ErrorImpl implements _Error {
|
||||||
|
const _$ErrorImpl({required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'TorrentState.error(message: $message)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$ErrorImpl &&
|
||||||
|
(identical(other.message, message) || other.message == message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType, message);
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$ErrorImplCopyWith<_$ErrorImpl> get copyWith =>
|
||||||
|
__$$ErrorImplCopyWithImpl<_$ErrorImpl>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult when<TResult extends Object?>({
|
||||||
|
required TResult Function() initial,
|
||||||
|
required TResult Function() loading,
|
||||||
|
required TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)
|
||||||
|
loaded,
|
||||||
|
required TResult Function(String message) error,
|
||||||
|
}) {
|
||||||
|
return error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? whenOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function()? initial,
|
||||||
|
TResult? Function()? loading,
|
||||||
|
TResult? Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult? Function(String message)? error,
|
||||||
|
}) {
|
||||||
|
return error?.call(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeWhen<TResult extends Object?>({
|
||||||
|
TResult Function()? initial,
|
||||||
|
TResult Function()? loading,
|
||||||
|
TResult Function(
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String imdbId,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality)?
|
||||||
|
loaded,
|
||||||
|
TResult Function(String message)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) {
|
||||||
|
if (error != null) {
|
||||||
|
return error(message);
|
||||||
|
}
|
||||||
|
return orElse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult map<TResult extends Object?>({
|
||||||
|
required TResult Function(_Initial value) initial,
|
||||||
|
required TResult Function(_Loading value) loading,
|
||||||
|
required TResult Function(_Loaded value) loaded,
|
||||||
|
required TResult Function(_Error value) error,
|
||||||
|
}) {
|
||||||
|
return error(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult? mapOrNull<TResult extends Object?>({
|
||||||
|
TResult? Function(_Initial value)? initial,
|
||||||
|
TResult? Function(_Loading value)? loading,
|
||||||
|
TResult? Function(_Loaded value)? loaded,
|
||||||
|
TResult? Function(_Error value)? error,
|
||||||
|
}) {
|
||||||
|
return error?.call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@optionalTypeArgs
|
||||||
|
TResult maybeMap<TResult extends Object?>({
|
||||||
|
TResult Function(_Initial value)? initial,
|
||||||
|
TResult Function(_Loading value)? loading,
|
||||||
|
TResult Function(_Loaded value)? loaded,
|
||||||
|
TResult Function(_Error value)? error,
|
||||||
|
required TResult orElse(),
|
||||||
|
}) {
|
||||||
|
if (error != null) {
|
||||||
|
return error(this);
|
||||||
|
}
|
||||||
|
return orElse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _Error implements TorrentState {
|
||||||
|
const factory _Error({required final String message}) = _$ErrorImpl;
|
||||||
|
|
||||||
|
String get message;
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$ErrorImplCopyWith<_$ErrorImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
@@ -93,9 +93,9 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
await _authRepository.verifyEmail(email, code);
|
await _authRepository.verifyEmail(email, code);
|
||||||
// After verification, user should log in.
|
// Auto-login after successful verification
|
||||||
// For a better UX, we could auto-login them, but for now, we'll just go to the unauthenticated state.
|
_user = await _authRepository.getCurrentUser();
|
||||||
_state = AuthState.unauthenticated;
|
_state = AuthState.authenticated;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
_state = AuthState.error;
|
_state = AuthState.error;
|
||||||
|
|||||||
174
lib/presentation/providers/downloads_provider.dart
Normal file
174
lib/presentation/providers/downloads_provider.dart
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../data/services/torrent_platform_service.dart';
|
||||||
|
import '../../data/models/torrent_info.dart';
|
||||||
|
|
||||||
|
/// Provider для управления загрузками торрентов
|
||||||
|
class DownloadsProvider with ChangeNotifier {
|
||||||
|
final List<TorrentInfo> _torrents = [];
|
||||||
|
Timer? _progressTimer;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
String? _stackTrace;
|
||||||
|
|
||||||
|
List<TorrentInfo> get torrents => List.unmodifiable(_torrents);
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get error => _error;
|
||||||
|
String? get stackTrace => _stackTrace;
|
||||||
|
|
||||||
|
DownloadsProvider() {
|
||||||
|
_startProgressUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startProgressUpdates() {
|
||||||
|
_progressTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
||||||
|
if (_torrents.isNotEmpty && !_isLoading) {
|
||||||
|
refreshDownloads();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загрузить список активных загрузок
|
||||||
|
Future<void> refreshDownloads() async {
|
||||||
|
try {
|
||||||
|
_setLoading(true);
|
||||||
|
_setError(null);
|
||||||
|
|
||||||
|
final progress = await TorrentPlatformService.getAllDownloads();
|
||||||
|
|
||||||
|
// Получаем полную информацию о каждом торренте
|
||||||
|
_torrents.clear();
|
||||||
|
for (final progressItem in progress) {
|
||||||
|
try {
|
||||||
|
final torrentInfo = await TorrentPlatformService.getTorrent(progressItem.infoHash);
|
||||||
|
if (torrentInfo != null) {
|
||||||
|
_torrents.add(torrentInfo);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Если не удалось получить полную информацию, создаем базовую
|
||||||
|
_torrents.add(TorrentInfo(
|
||||||
|
infoHash: progressItem.infoHash,
|
||||||
|
name: 'Торрент ${progressItem.infoHash.substring(0, 8)}',
|
||||||
|
totalSize: 0,
|
||||||
|
progress: progressItem.progress,
|
||||||
|
downloadSpeed: progressItem.downloadRate,
|
||||||
|
uploadSpeed: progressItem.uploadRate,
|
||||||
|
numSeeds: progressItem.numSeeds,
|
||||||
|
numPeers: progressItem.numPeers,
|
||||||
|
state: progressItem.state,
|
||||||
|
savePath: '/storage/emulated/0/Download/NeoMovies',
|
||||||
|
files: [],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить информацию о конкретном торренте
|
||||||
|
Future<TorrentInfo?> getTorrentInfo(String infoHash) async {
|
||||||
|
try {
|
||||||
|
return await TorrentPlatformService.getTorrent(infoHash);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Ошибка получения информации о торренте: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Приостановить торрент
|
||||||
|
Future<void> pauseTorrent(String infoHash) async {
|
||||||
|
try {
|
||||||
|
await TorrentPlatformService.pauseDownload(infoHash);
|
||||||
|
await refreshDownloads(); // Обновляем список
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возобновить торрент
|
||||||
|
Future<void> resumeTorrent(String infoHash) async {
|
||||||
|
try {
|
||||||
|
await TorrentPlatformService.resumeDownload(infoHash);
|
||||||
|
await refreshDownloads(); // Обновляем список
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удалить торрент
|
||||||
|
Future<void> removeTorrent(String infoHash) async {
|
||||||
|
try {
|
||||||
|
await TorrentPlatformService.cancelDownload(infoHash);
|
||||||
|
await refreshDownloads(); // Обновляем список
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Установить приоритет файла
|
||||||
|
Future<void> setFilePriority(String infoHash, int fileIndex, FilePriority priority) async {
|
||||||
|
try {
|
||||||
|
await TorrentPlatformService.setFilePriority(infoHash, fileIndex, priority);
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавить новый торрент
|
||||||
|
Future<String?> addTorrent(String magnetUri, {String? savePath}) async {
|
||||||
|
try {
|
||||||
|
final infoHash = await TorrentPlatformService.addTorrent(
|
||||||
|
magnetUri: magnetUri,
|
||||||
|
savePath: savePath,
|
||||||
|
);
|
||||||
|
await refreshDownloads(); // Обновляем список
|
||||||
|
return infoHash;
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматировать скорость
|
||||||
|
String formatSpeed(int bytesPerSecond) {
|
||||||
|
if (bytesPerSecond < 1024) return '${bytesPerSecond}B/s';
|
||||||
|
if (bytesPerSecond < 1024 * 1024) return '${(bytesPerSecond / 1024).toStringAsFixed(1)}KB/s';
|
||||||
|
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)}MB/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматировать продолжительность
|
||||||
|
String formatDuration(Duration duration) {
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes.remainder(60);
|
||||||
|
final seconds = duration.inSeconds.remainder(60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}ч ${minutes}м ${seconds}с';
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return '${minutes}м ${seconds}с';
|
||||||
|
} else {
|
||||||
|
return '${seconds}с';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setLoading(bool loading) {
|
||||||
|
_isLoading = loading;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setError(String? error, [String? stackTrace]) {
|
||||||
|
_error = error;
|
||||||
|
_stackTrace = stackTrace;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
221
lib/presentation/providers/downloads_provider_old.dart
Normal file
221
lib/presentation/providers/downloads_provider_old.dart
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../data/services/torrent_platform_service.dart';
|
||||||
|
import '../../data/models/torrent_info.dart';
|
||||||
|
|
||||||
|
class ActiveDownload {
|
||||||
|
final String infoHash;
|
||||||
|
final String name;
|
||||||
|
final DownloadProgress progress;
|
||||||
|
final DateTime startTime;
|
||||||
|
final List<String> selectedFiles;
|
||||||
|
|
||||||
|
ActiveDownload({
|
||||||
|
required this.infoHash,
|
||||||
|
required this.name,
|
||||||
|
required this.progress,
|
||||||
|
required this.startTime,
|
||||||
|
required this.selectedFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
ActiveDownload copyWith({
|
||||||
|
String? infoHash,
|
||||||
|
String? name,
|
||||||
|
DownloadProgress? progress,
|
||||||
|
DateTime? startTime,
|
||||||
|
List<String>? selectedFiles,
|
||||||
|
}) {
|
||||||
|
return ActiveDownload(
|
||||||
|
infoHash: infoHash ?? this.infoHash,
|
||||||
|
name: name ?? this.name,
|
||||||
|
progress: progress ?? this.progress,
|
||||||
|
startTime: startTime ?? this.startTime,
|
||||||
|
selectedFiles: selectedFiles ?? this.selectedFiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadsProvider with ChangeNotifier {
|
||||||
|
final List<TorrentInfo> _torrents = [];
|
||||||
|
Timer? _progressTimer;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
List<TorrentInfo> get torrents => List.unmodifiable(_torrents);
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get error => _error;
|
||||||
|
|
||||||
|
DownloadsProvider() {
|
||||||
|
_startProgressUpdates();
|
||||||
|
loadDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startProgressUpdates() {
|
||||||
|
_progressTimer = Timer.periodic(const Duration(seconds: 2), (timer) {
|
||||||
|
_updateProgress();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadDownloads() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final progressList = await TorrentPlatformService.getAllDownloads();
|
||||||
|
|
||||||
|
_downloads = progressList.map((progress) {
|
||||||
|
// Try to find existing download to preserve metadata
|
||||||
|
final existing = _downloads.where((d) => d.infoHash == progress.infoHash).firstOrNull;
|
||||||
|
|
||||||
|
return ActiveDownload(
|
||||||
|
infoHash: progress.infoHash,
|
||||||
|
name: existing?.name ?? 'Unnamed Torrent',
|
||||||
|
progress: progress,
|
||||||
|
startTime: existing?.startTime ?? DateTime.now(),
|
||||||
|
selectedFiles: existing?.selectedFiles ?? [],
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateProgress() async {
|
||||||
|
if (_downloads.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<ActiveDownload> updatedDownloads = [];
|
||||||
|
|
||||||
|
for (final download in _downloads) {
|
||||||
|
final progress = await TorrentPlatformService.getDownloadProgress(download.infoHash);
|
||||||
|
if (progress != null) {
|
||||||
|
updatedDownloads.add(download.copyWith(progress: progress));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_downloads = updatedDownloads;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure for progress updates
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Failed to update progress: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> pauseDownload(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final success = await TorrentPlatformService.pauseDownload(infoHash);
|
||||||
|
if (success) {
|
||||||
|
await _updateProgress();
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> resumeDownload(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final success = await TorrentPlatformService.resumeDownload(infoHash);
|
||||||
|
if (success) {
|
||||||
|
await _updateProgress();
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> cancelDownload(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final success = await TorrentPlatformService.cancelDownload(infoHash);
|
||||||
|
if (success) {
|
||||||
|
_downloads.removeWhere((d) => d.infoHash == infoHash);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void addDownload({
|
||||||
|
required String infoHash,
|
||||||
|
required String name,
|
||||||
|
required List<String> selectedFiles,
|
||||||
|
}) {
|
||||||
|
final download = ActiveDownload(
|
||||||
|
infoHash: infoHash,
|
||||||
|
name: name,
|
||||||
|
progress: DownloadProgress(
|
||||||
|
infoHash: infoHash,
|
||||||
|
progress: 0.0,
|
||||||
|
downloadRate: 0,
|
||||||
|
uploadRate: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
numPeers: 0,
|
||||||
|
state: 'starting',
|
||||||
|
),
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
selectedFiles: selectedFiles,
|
||||||
|
);
|
||||||
|
|
||||||
|
_downloads.add(download);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
ActiveDownload? getDownload(String infoHash) {
|
||||||
|
try {
|
||||||
|
return _downloads.where((d) => d.infoHash == infoHash).first;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatFileSize(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatSpeed(int bytesPerSecond) {
|
||||||
|
return '${formatFileSize(bytesPerSecond)}/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatDuration(Duration duration) {
|
||||||
|
if (duration.inDays > 0) {
|
||||||
|
return '${duration.inDays}d ${duration.inHours % 24}h';
|
||||||
|
}
|
||||||
|
if (duration.inHours > 0) {
|
||||||
|
return '${duration.inHours}h ${duration.inMinutes % 60}m';
|
||||||
|
}
|
||||||
|
if (duration.inMinutes > 0) {
|
||||||
|
return '${duration.inMinutes}m ${duration.inSeconds % 60}s';
|
||||||
|
}
|
||||||
|
return '${duration.inSeconds}s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ListExtension<T> on List<T> {
|
||||||
|
T? get firstOrNull => isEmpty ? null : first;
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ class MovieDetailProvider with ChangeNotifier {
|
|||||||
String? _error;
|
String? _error;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
|
|
||||||
|
String? _stackTrace;
|
||||||
|
String? get stackTrace => _stackTrace;
|
||||||
|
|
||||||
Future<void> loadMedia(int mediaId, String mediaType) async {
|
Future<void> loadMedia(int mediaId, String mediaType) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_isImdbLoading = true;
|
_isImdbLoading = true;
|
||||||
@@ -33,21 +36,40 @@ class MovieDetailProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
print('Loading media: ID=$mediaId, type=$mediaType');
|
||||||
|
|
||||||
|
// Load movie/TV details
|
||||||
if (mediaType == 'movie') {
|
if (mediaType == 'movie') {
|
||||||
_movie = await _movieRepository.getMovieById(mediaId.toString());
|
_movie = await _movieRepository.getMovieById(mediaId.toString());
|
||||||
|
print('Movie loaded successfully: ${_movie?.title}');
|
||||||
} else {
|
} else {
|
||||||
_movie = await _movieRepository.getTvById(mediaId.toString());
|
_movie = await _movieRepository.getTvById(mediaId.toString());
|
||||||
|
print('TV show loaded successfully: ${_movie?.title}');
|
||||||
}
|
}
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// Try to load IMDb ID (non-blocking)
|
||||||
if (_movie != null) {
|
if (_movie != null) {
|
||||||
_imdbId = await _apiClient.getImdbId(mediaId, mediaType);
|
try {
|
||||||
|
print('Loading IMDb ID for $mediaType $mediaId');
|
||||||
|
_imdbId = await _apiClient.getImdbId(mediaId.toString(), mediaType);
|
||||||
|
print('IMDb ID loaded: $_imdbId');
|
||||||
|
} catch (e) {
|
||||||
|
// IMDb ID loading failed, but don't fail the whole screen
|
||||||
|
print('Failed to load IMDb ID: $e');
|
||||||
|
_imdbId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
|
print('Error loading media: $e');
|
||||||
|
print('Stack trace: $stackTrace');
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
} finally {
|
_stackTrace = stackTrace.toString();
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
_isImdbLoading = false;
|
_isImdbLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'package:chewie/chewie.dart';
|
|||||||
import 'package:neomovies_mobile/data/models/player/video_source.dart';
|
import 'package:neomovies_mobile/data/models/player/video_source.dart';
|
||||||
import 'package:neomovies_mobile/data/models/player/video_quality.dart';
|
import 'package:neomovies_mobile/data/models/player/video_quality.dart';
|
||||||
import 'package:neomovies_mobile/data/models/player/audio_track.dart';
|
import 'package:neomovies_mobile/data/models/player/audio_track.dart';
|
||||||
import 'package:neomovies_mobile/data/models/player/subtitle.dart';
|
import 'package:neomovies_mobile/data/models/player/subtitle.dart' as local_subtitle;
|
||||||
import 'package:neomovies_mobile/data/models/player/player_settings.dart';
|
import 'package:neomovies_mobile/data/models/player/player_settings.dart';
|
||||||
|
|
||||||
class PlayerProvider with ChangeNotifier {
|
class PlayerProvider with ChangeNotifier {
|
||||||
@@ -37,13 +37,13 @@ class PlayerProvider with ChangeNotifier {
|
|||||||
List<VideoSource> _sources = [];
|
List<VideoSource> _sources = [];
|
||||||
List<VideoQuality> _qualities = [];
|
List<VideoQuality> _qualities = [];
|
||||||
List<AudioTrack> _audioTracks = [];
|
List<AudioTrack> _audioTracks = [];
|
||||||
List<Subtitle> _subtitles = [];
|
List<local_subtitle.Subtitle> _subtitles = [];
|
||||||
|
|
||||||
// Selected options
|
// Selected options
|
||||||
VideoSource? _selectedSource;
|
VideoSource? _selectedSource;
|
||||||
VideoQuality? _selectedQuality;
|
VideoQuality? _selectedQuality;
|
||||||
AudioTrack? _selectedAudioTrack;
|
AudioTrack? _selectedAudioTrack;
|
||||||
Subtitle? _selectedSubtitle;
|
local_subtitle.Subtitle? _selectedSubtitle;
|
||||||
|
|
||||||
// Playback state
|
// Playback state
|
||||||
double _volume = 1.0;
|
double _volume = 1.0;
|
||||||
@@ -67,11 +67,11 @@ class PlayerProvider with ChangeNotifier {
|
|||||||
List<VideoSource> get sources => _sources;
|
List<VideoSource> get sources => _sources;
|
||||||
List<VideoQuality> get qualities => _qualities;
|
List<VideoQuality> get qualities => _qualities;
|
||||||
List<AudioTrack> get audioTracks => _audioTracks;
|
List<AudioTrack> get audioTracks => _audioTracks;
|
||||||
List<Subtitle> get subtitles => _subtitles;
|
List<local_subtitle.Subtitle> get subtitles => _subtitles;
|
||||||
VideoSource? get selectedSource => _selectedSource;
|
VideoSource? get selectedSource => _selectedSource;
|
||||||
VideoQuality? get selectedQuality => _selectedQuality;
|
VideoQuality? get selectedQuality => _selectedQuality;
|
||||||
AudioTrack? get selectedAudioTrack => _selectedAudioTrack;
|
AudioTrack? get selectedAudioTrack => _selectedAudioTrack;
|
||||||
Subtitle? get selectedSubtitle => _selectedSubtitle;
|
local_subtitle.Subtitle? get selectedSubtitle => _selectedSubtitle;
|
||||||
double get volume => _volume;
|
double get volume => _volume;
|
||||||
bool get isMuted => _isMuted;
|
bool get isMuted => _isMuted;
|
||||||
double get playbackSpeed => _playbackSpeed;
|
double get playbackSpeed => _playbackSpeed;
|
||||||
@@ -94,7 +94,7 @@ class PlayerProvider with ChangeNotifier {
|
|||||||
List<VideoSource>? sources,
|
List<VideoSource>? sources,
|
||||||
List<VideoQuality>? qualities,
|
List<VideoQuality>? qualities,
|
||||||
List<AudioTrack>? audioTracks,
|
List<AudioTrack>? audioTracks,
|
||||||
List<Subtitle>? subtitles,
|
List<local_subtitle.Subtitle>? subtitles,
|
||||||
}) async {
|
}) async {
|
||||||
_mediaId = mediaId;
|
_mediaId = mediaId;
|
||||||
_mediaType = mediaType;
|
_mediaType = mediaType;
|
||||||
@@ -305,7 +305,7 @@ class PlayerProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Change subtitle
|
// Change subtitle
|
||||||
void setSubtitle(Subtitle subtitle) {
|
void setSubtitle(local_subtitle.Subtitle subtitle) {
|
||||||
if (_selectedSubtitle == subtitle) return;
|
if (_selectedSubtitle == subtitle) return;
|
||||||
|
|
||||||
_selectedSubtitle = subtitle;
|
_selectedSubtitle = subtitle;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class ReactionsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (_authProvider.isAuthenticated) {
|
if (_authProvider.isAuthenticated) {
|
||||||
final userReactionResult = await _repository.getMyReaction(mediaType, mediaId);
|
final userReactionResult = await _repository.getMyReaction(mediaType, mediaId);
|
||||||
_userReaction = userReactionResult.reactionType;
|
_userReaction = userReactionResult?.reactionType;
|
||||||
} else {
|
} else {
|
||||||
_userReaction = null;
|
_userReaction = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,16 +61,7 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
|||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
_formKey.currentState!.save();
|
_formKey.currentState!.save();
|
||||||
Provider.of<AuthProvider>(context, listen: false)
|
Provider.of<AuthProvider>(context, listen: false)
|
||||||
.verifyEmail(widget.email, _code)
|
.verifyEmail(widget.email, _code);
|
||||||
.then((_) {
|
|
||||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
|
||||||
if (auth.state != AuthState.error) {
|
|
||||||
Navigator.of(context).pop(); // Go back to LoginScreen
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Email verified. You can now login.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +73,16 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
|||||||
),
|
),
|
||||||
body: Consumer<AuthProvider>(
|
body: Consumer<AuthProvider>(
|
||||||
builder: (context, auth, child) {
|
builder: (context, auth, child) {
|
||||||
|
// Auto-navigate when user becomes authenticated
|
||||||
|
if (auth.state == AuthState.authenticated) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
Navigator.of(context).pop(); // Go back to previous screen
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Email verified and logged in successfully!')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
535
lib/presentation/screens/downloads/download_detail_screen.dart
Normal file
535
lib/presentation/screens/downloads/download_detail_screen.dart
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import '../../providers/downloads_provider.dart';
|
||||||
|
import '../player/native_video_player_screen.dart';
|
||||||
|
import '../player/webview_player_screen.dart';
|
||||||
|
|
||||||
|
class DownloadDetailScreen extends StatefulWidget {
|
||||||
|
final ActiveDownload download;
|
||||||
|
|
||||||
|
const DownloadDetailScreen({
|
||||||
|
super.key,
|
||||||
|
required this.download,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DownloadDetailScreen> createState() => _DownloadDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadDetailScreenState extends State<DownloadDetailScreen> {
|
||||||
|
List<DownloadedFile> _files = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadDownloadedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDownloadedFiles() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get downloads directory
|
||||||
|
final downloadsDir = await getApplicationDocumentsDirectory();
|
||||||
|
final torrentDir = Directory('${downloadsDir.path}/torrents/${widget.download.infoHash}');
|
||||||
|
|
||||||
|
if (await torrentDir.exists()) {
|
||||||
|
final files = await _scanDirectory(torrentDir);
|
||||||
|
setState(() {
|
||||||
|
_files = files;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_files = [];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_files = [];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<DownloadedFile>> _scanDirectory(Directory directory) async {
|
||||||
|
final List<DownloadedFile> files = [];
|
||||||
|
|
||||||
|
await for (final entity in directory.list(recursive: true)) {
|
||||||
|
if (entity is File) {
|
||||||
|
final stat = await entity.stat();
|
||||||
|
final fileName = entity.path.split('/').last;
|
||||||
|
final extension = fileName.split('.').last.toLowerCase();
|
||||||
|
|
||||||
|
files.add(DownloadedFile(
|
||||||
|
name: fileName,
|
||||||
|
path: entity.path,
|
||||||
|
size: stat.size,
|
||||||
|
isVideo: _isVideoFile(extension),
|
||||||
|
isAudio: _isAudioFile(extension),
|
||||||
|
extension: extension,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files..sort((a, b) => a.name.compareTo(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isVideoFile(String extension) {
|
||||||
|
const videoExtensions = ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'];
|
||||||
|
return videoExtensions.contains(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isAudioFile(String extension) {
|
||||||
|
const audioExtensions = ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg'];
|
||||||
|
return audioExtensions.contains(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.download.name),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 1,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: _loadDownloadedFiles,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
_buildProgressSection(),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: _buildFilesSection(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressSection() {
|
||||||
|
final progress = widget.download.progress;
|
||||||
|
final isCompleted = progress.progress >= 1.0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Прогресс загрузки',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${(progress.progress * 100).toStringAsFixed(1)}% - ${progress.state}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCompleted
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isCompleted ? 'Завершено' : 'Загружается',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: isCompleted ? Colors.green : Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progress.progress,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
isCompleted ? Colors.green : Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildProgressStat('Скорость', '${_formatSpeed(progress.downloadRate)}'),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
_buildProgressStat('Сиды', '${progress.numSeeds}'),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
_buildProgressStat('Пиры', '${progress.numPeers}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressStat(String label, String value) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilesSection() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Сканирование файлов...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_files.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.folder_open,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Файлы не найдены',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Возможно, загрузка еще не завершена',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Файлы (${_files.length})',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: _files.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = _files[index];
|
||||||
|
return _buildFileItem(file);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileItem(DownloadedFile file) {
|
||||||
|
return Card(
|
||||||
|
elevation: 1,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: file.isVideo || file.isAudio ? () => _openFile(file) : null,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildFileIcon(file),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
file.name,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_formatFileSize(file.size),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) => _handleFileAction(value, file),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
if (file.isVideo || file.isAudio) ...[
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_native',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.play_arrow),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Нативный плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (file.isVideo) ...[
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_vibix',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Vibix плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_alloha',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Alloha плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
],
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileIcon(DownloadedFile file) {
|
||||||
|
IconData icon;
|
||||||
|
Color color;
|
||||||
|
|
||||||
|
if (file.isVideo) {
|
||||||
|
icon = Icons.movie;
|
||||||
|
color = Colors.blue;
|
||||||
|
} else if (file.isAudio) {
|
||||||
|
icon = Icons.music_note;
|
||||||
|
color = Colors.orange;
|
||||||
|
} else {
|
||||||
|
icon = Icons.insert_drive_file;
|
||||||
|
color = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openFile(DownloadedFile file) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => NativeVideoPlayerScreen(
|
||||||
|
filePath: file.path,
|
||||||
|
title: file.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFileAction(String action, DownloadedFile file) {
|
||||||
|
switch (action) {
|
||||||
|
case 'play_native':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => NativeVideoPlayerScreen(
|
||||||
|
filePath: file.path,
|
||||||
|
title: file.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'play_vibix':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => WebViewPlayerScreen(
|
||||||
|
url: 'https://vibix.org/player',
|
||||||
|
title: file.name,
|
||||||
|
playerType: 'vibix',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'play_alloha':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => WebViewPlayerScreen(
|
||||||
|
url: 'https://alloha.org/player',
|
||||||
|
title: file.name,
|
||||||
|
playerType: 'alloha',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
_showDeleteDialog(file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDeleteDialog(DownloadedFile file) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить файл'),
|
||||||
|
content: Text('Вы уверены, что хотите удалить файл "${file.name}"?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await _deleteFile(file);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteFile(DownloadedFile file) async {
|
||||||
|
try {
|
||||||
|
final fileToDelete = File(file.path);
|
||||||
|
if (await fileToDelete.exists()) {
|
||||||
|
await fileToDelete.delete();
|
||||||
|
_loadDownloadedFiles(); // Refresh the list
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Файл "${file.name}" удален'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Ошибка удаления файла: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFileSize(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSpeed(int bytesPerSecond) {
|
||||||
|
return '${_formatFileSize(bytesPerSecond)}/s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadedFile {
|
||||||
|
final String name;
|
||||||
|
final String path;
|
||||||
|
final int size;
|
||||||
|
final bool isVideo;
|
||||||
|
final bool isAudio;
|
||||||
|
final String extension;
|
||||||
|
|
||||||
|
DownloadedFile({
|
||||||
|
required this.name,
|
||||||
|
required this.path,
|
||||||
|
required this.size,
|
||||||
|
required this.isVideo,
|
||||||
|
required this.isAudio,
|
||||||
|
required this.extension,
|
||||||
|
});
|
||||||
|
}
|
||||||
419
lib/presentation/screens/downloads/downloads_screen.dart
Normal file
419
lib/presentation/screens/downloads/downloads_screen.dart
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/downloads_provider.dart';
|
||||||
|
import '../../widgets/error_display.dart';
|
||||||
|
import '../../../data/models/torrent_info.dart';
|
||||||
|
import 'torrent_detail_screen.dart';
|
||||||
|
|
||||||
|
class DownloadsScreen extends StatefulWidget {
|
||||||
|
const DownloadsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DownloadsScreen> createState() => _DownloadsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<DownloadsProvider>().refreshDownloads();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Загрузки'),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
foregroundColor: Theme.of(context).textTheme.titleLarge?.color,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () {
|
||||||
|
context.read<DownloadsProvider>().refreshDownloads();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Consumer<DownloadsProvider>(
|
||||||
|
builder: (context, provider, child) {
|
||||||
|
if (provider.isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.error != null) {
|
||||||
|
return ErrorDisplay(
|
||||||
|
title: 'Ошибка загрузки торрентов',
|
||||||
|
error: provider.error!,
|
||||||
|
stackTrace: provider.stackTrace,
|
||||||
|
onRetry: () {
|
||||||
|
provider.refreshDownloads();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.torrents.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.download_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Нет активных загрузок',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Загруженные торренты будут отображаться здесь',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await provider.refreshDownloads();
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: provider.torrents.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final torrent = provider.torrents[index];
|
||||||
|
return TorrentListItem(
|
||||||
|
torrent: torrent,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => TorrentDetailScreen(
|
||||||
|
infoHash: torrent.infoHash,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onMenuPressed: (action) {
|
||||||
|
_handleTorrentAction(action, torrent);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTorrentAction(TorrentAction action, TorrentInfo torrent) {
|
||||||
|
final provider = context.read<DownloadsProvider>();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case TorrentAction.pause:
|
||||||
|
provider.pauseTorrent(torrent.infoHash);
|
||||||
|
break;
|
||||||
|
case TorrentAction.resume:
|
||||||
|
provider.resumeTorrent(torrent.infoHash);
|
||||||
|
break;
|
||||||
|
case TorrentAction.remove:
|
||||||
|
_showRemoveConfirmation(torrent);
|
||||||
|
break;
|
||||||
|
case TorrentAction.openFolder:
|
||||||
|
_openFolder(torrent);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRemoveConfirmation(TorrentInfo torrent) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Удалить торрент'),
|
||||||
|
content: Text(
|
||||||
|
'Вы уверены, что хотите удалить "${torrent.name}"?\n\nФайлы будут удалены с устройства.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
context.read<DownloadsProvider>().removeTorrent(torrent.infoHash);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openFolder(TorrentInfo torrent) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Папка: ${torrent.savePath}'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Копировать',
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Copy path to clipboard
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TorrentAction { pause, resume, remove, openFolder }
|
||||||
|
|
||||||
|
class TorrentListItem extends StatelessWidget {
|
||||||
|
final TorrentInfo torrent;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Function(TorrentAction) onMenuPressed;
|
||||||
|
|
||||||
|
const TorrentListItem({
|
||||||
|
super.key,
|
||||||
|
required this.torrent,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onMenuPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
torrent.name,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
PopupMenuButton<TorrentAction>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: onMenuPressed,
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
if (torrent.isPaused)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: TorrentAction.resume,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.play_arrow),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Возобновить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: TorrentAction.pause,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.pause),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Приостановить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: TorrentAction.openFolder,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.folder_open),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Открыть папку'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: TorrentAction.remove,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildProgressBar(context),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildStatusChip(),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
torrent.formattedTotalSize,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (torrent.isDownloading || torrent.isSeeding) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.download,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.green.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
torrent.formattedDownloadSpeed,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Icon(
|
||||||
|
Icons.upload,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.blue.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
torrent.formattedUploadSpeed,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'S: ${torrent.numSeeds} P: ${torrent.numPeers}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressBar(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Прогресс',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${(torrent.progress * 100).toStringAsFixed(1)}%',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: torrent.progress,
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
torrent.isCompleted
|
||||||
|
? Colors.green.shade600
|
||||||
|
: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusChip() {
|
||||||
|
Color color;
|
||||||
|
IconData icon;
|
||||||
|
String text;
|
||||||
|
|
||||||
|
if (torrent.isCompleted) {
|
||||||
|
color = Colors.green;
|
||||||
|
icon = Icons.check_circle;
|
||||||
|
text = 'Завершен';
|
||||||
|
} else if (torrent.isDownloading) {
|
||||||
|
color = Colors.blue;
|
||||||
|
icon = Icons.download;
|
||||||
|
text = 'Загружается';
|
||||||
|
} else if (torrent.isPaused) {
|
||||||
|
color = Colors.orange;
|
||||||
|
icon = Icons.pause;
|
||||||
|
text = 'Приостановлен';
|
||||||
|
} else if (torrent.isSeeding) {
|
||||||
|
color = Colors.purple;
|
||||||
|
icon = Icons.upload;
|
||||||
|
text = 'Раздача';
|
||||||
|
} else {
|
||||||
|
color = Colors.grey;
|
||||||
|
icon = Icons.help_outline;
|
||||||
|
text = torrent.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
574
lib/presentation/screens/downloads/torrent_detail_screen.dart
Normal file
574
lib/presentation/screens/downloads/torrent_detail_screen.dart
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/downloads_provider.dart';
|
||||||
|
import '../../../data/models/torrent_info.dart';
|
||||||
|
import '../player/video_player_screen.dart';
|
||||||
|
import '../player/webview_player_screen.dart';
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class TorrentDetailScreen extends StatefulWidget {
|
||||||
|
final String infoHash;
|
||||||
|
|
||||||
|
const TorrentDetailScreen({
|
||||||
|
super.key,
|
||||||
|
required this.infoHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TorrentDetailScreen> createState() => _TorrentDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TorrentDetailScreenState extends State<TorrentDetailScreen> {
|
||||||
|
TorrentInfo? torrentInfo;
|
||||||
|
bool isLoading = true;
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadTorrentInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTorrentInfo() async {
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = context.read<DownloadsProvider>();
|
||||||
|
final info = await provider.getTorrentInfo(widget.infoHash);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
torrentInfo = info;
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
error = e.toString();
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(torrentInfo?.name ?? 'Торрент'),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
foregroundColor: Theme.of(context).textTheme.titleLarge?.color,
|
||||||
|
actions: [
|
||||||
|
if (torrentInfo != null)
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (value) => _handleAction(value),
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
if (torrentInfo!.isPaused)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'resume',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.play_arrow),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Возобновить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'pause',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.pause),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Приостановить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'refresh',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.refresh),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Обновить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'remove',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.red.shade300,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Ошибка загрузки',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadTorrentInfo,
|
||||||
|
child: const Text('Попробовать снова'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (torrentInfo == null) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('Торрент не найден'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildTorrentInfo(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildFilesSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTorrentInfo() {
|
||||||
|
final torrent = torrentInfo!;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Информация о торренте',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildInfoRow('Название', torrent.name),
|
||||||
|
_buildInfoRow('Размер', torrent.formattedTotalSize),
|
||||||
|
_buildInfoRow('Прогресс', '${(torrent.progress * 100).toStringAsFixed(1)}%'),
|
||||||
|
_buildInfoRow('Статус', _getStatusText(torrent)),
|
||||||
|
_buildInfoRow('Путь сохранения', torrent.savePath),
|
||||||
|
if (torrent.isDownloading || torrent.isSeeding) ...[
|
||||||
|
const Divider(),
|
||||||
|
_buildInfoRow('Скорость загрузки', torrent.formattedDownloadSpeed),
|
||||||
|
_buildInfoRow('Скорость раздачи', torrent.formattedUploadSpeed),
|
||||||
|
_buildInfoRow('Сиды', '${torrent.numSeeds}'),
|
||||||
|
_buildInfoRow('Пиры', '${torrent.numPeers}'),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: torrent.progress,
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
torrent.isCompleted
|
||||||
|
? Colors.green.shade600
|
||||||
|
: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 140,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getStatusText(TorrentInfo torrent) {
|
||||||
|
if (torrent.isCompleted) return 'Завершен';
|
||||||
|
if (torrent.isDownloading) return 'Загружается';
|
||||||
|
if (torrent.isPaused) return 'Приостановлен';
|
||||||
|
if (torrent.isSeeding) return 'Раздача';
|
||||||
|
return torrent.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilesSection() {
|
||||||
|
final torrent = torrentInfo!;
|
||||||
|
final videoFiles = torrent.videoFiles;
|
||||||
|
final otherFiles = torrent.files.where((file) => !videoFiles.contains(file)).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Файлы',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Video files section
|
||||||
|
if (videoFiles.isNotEmpty) ...[
|
||||||
|
_buildFileTypeSection('Видео файлы', videoFiles, Icons.play_circle_fill),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Other files section
|
||||||
|
if (otherFiles.isNotEmpty) ...[
|
||||||
|
_buildFileTypeSection('Другие файлы', otherFiles, Icons.insert_drive_file),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileTypeSection(String title, List<TorrentFileInfo> files, IconData icon) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${files.length} файлов',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: files.length,
|
||||||
|
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = files[index];
|
||||||
|
return _buildFileItem(file, icon == Icons.play_circle_fill);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileItem(TorrentFileInfo file, bool isVideo) {
|
||||||
|
final fileName = file.path.split('/').last;
|
||||||
|
final fileExtension = fileName.split('.').last.toUpperCase();
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: isVideo
|
||||||
|
? Colors.red.shade100
|
||||||
|
: Colors.blue.shade100,
|
||||||
|
child: Text(
|
||||||
|
fileExtension,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isVideo
|
||||||
|
? Colors.red.shade700
|
||||||
|
: Colors.blue.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
fileName,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatFileSize(file.size),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (file.progress > 0 && file.progress < 1.0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: file.progress,
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) => _handleFileAction(value, file),
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
if (isVideo && file.progress >= 0.1) ...[
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_native',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.play_arrow),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Нативный плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_vibix',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Vibix плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_alloha',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Alloha плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
],
|
||||||
|
PopupMenuItem(
|
||||||
|
value: file.priority == FilePriority.DONT_DOWNLOAD ? 'download' : 'stop_download',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(file.priority == FilePriority.DONT_DOWNLOAD ? Icons.download : Icons.stop),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(file.priority == FilePriority.DONT_DOWNLOAD ? 'Скачать' : 'Остановить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'priority_${file.priority == FilePriority.HIGH ? 'normal' : 'high'}',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(file.priority == FilePriority.HIGH ? Icons.flag : Icons.flag_outlined),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(file.priority == FilePriority.HIGH ? 'Обычный приоритет' : 'Высокий приоритет'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: isVideo && file.progress >= 0.1
|
||||||
|
? () => _playVideo(file, 'native')
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFileSize(int bytes) {
|
||||||
|
if (bytes < 1024) return '${bytes}B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB';
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleAction(String action) async {
|
||||||
|
final provider = context.read<DownloadsProvider>();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'pause':
|
||||||
|
await provider.pauseTorrent(widget.infoHash);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
case 'resume':
|
||||||
|
await provider.resumeTorrent(widget.infoHash);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
case 'refresh':
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
case 'remove':
|
||||||
|
_showRemoveConfirmation();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFileAction(String action, TorrentFileInfo file) async {
|
||||||
|
final provider = context.read<DownloadsProvider>();
|
||||||
|
|
||||||
|
if (action.startsWith('play_')) {
|
||||||
|
final playerType = action.replaceFirst('play_', '');
|
||||||
|
_playVideo(file, playerType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.startsWith('priority_')) {
|
||||||
|
final priority = action.replaceFirst('priority_', '');
|
||||||
|
final newPriority = priority == 'high' ? FilePriority.HIGH : FilePriority.NORMAL;
|
||||||
|
|
||||||
|
final fileIndex = torrentInfo!.files.indexOf(file);
|
||||||
|
await provider.setFilePriority(widget.infoHash, fileIndex, newPriority);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'download':
|
||||||
|
final fileIndex = torrentInfo!.files.indexOf(file);
|
||||||
|
await provider.setFilePriority(widget.infoHash, fileIndex, FilePriority.NORMAL);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
case 'stop_download':
|
||||||
|
final fileIndex = torrentInfo!.files.indexOf(file);
|
||||||
|
await provider.setFilePriority(widget.infoHash, fileIndex, FilePriority.DONT_DOWNLOAD);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _playVideo(TorrentFileInfo file, String playerType) {
|
||||||
|
final filePath = '${torrentInfo!.savePath}/${file.path}';
|
||||||
|
|
||||||
|
switch (playerType) {
|
||||||
|
case 'native':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => VideoPlayerScreen(
|
||||||
|
filePath: filePath,
|
||||||
|
title: file.path.split('/').last,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'vibix':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => WebViewPlayerScreen(
|
||||||
|
playerType: WebPlayerType.vibix,
|
||||||
|
videoUrl: filePath,
|
||||||
|
title: file.path.split('/').last,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'alloha':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => WebViewPlayerScreen(
|
||||||
|
playerType: WebPlayerType.alloha,
|
||||||
|
videoUrl: filePath,
|
||||||
|
title: file.path.split('/').last,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRemoveConfirmation() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Удалить торрент'),
|
||||||
|
content: Text(
|
||||||
|
'Вы уверены, что хотите удалить "${torrentInfo!.name}"?\n\nФайлы будут удалены с устройства.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
context.read<DownloadsProvider>().removeTorrent(widget.infoHash);
|
||||||
|
Navigator.of(context).pop(); // Возвращаемся к списку загрузок
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:neomovies_mobile/presentation/screens/auth/profile_screen.dart';
|
|||||||
import 'package:neomovies_mobile/presentation/screens/favorites/favorites_screen.dart';
|
import 'package:neomovies_mobile/presentation/screens/favorites/favorites_screen.dart';
|
||||||
import 'package:neomovies_mobile/presentation/screens/search/search_screen.dart';
|
import 'package:neomovies_mobile/presentation/screens/search/search_screen.dart';
|
||||||
import 'package:neomovies_mobile/presentation/screens/home/home_screen.dart';
|
import 'package:neomovies_mobile/presentation/screens/home/home_screen.dart';
|
||||||
|
import 'package:neomovies_mobile/presentation/screens/downloads/downloads_screen.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class MainScreen extends StatefulWidget {
|
class MainScreen extends StatefulWidget {
|
||||||
@@ -30,7 +31,7 @@ class _MainScreenState extends State<MainScreen> {
|
|||||||
HomeScreen(),
|
HomeScreen(),
|
||||||
SearchScreen(),
|
SearchScreen(),
|
||||||
FavoritesScreen(),
|
FavoritesScreen(),
|
||||||
Center(child: Text('Downloads Page')),
|
DownloadsScreen(),
|
||||||
ProfileScreen(),
|
ProfileScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:neomovies_mobile/presentation/providers/favorites_provider.dart'
|
|||||||
import 'package:neomovies_mobile/presentation/providers/reactions_provider.dart';
|
import 'package:neomovies_mobile/presentation/providers/reactions_provider.dart';
|
||||||
import 'package:neomovies_mobile/presentation/providers/movie_detail_provider.dart';
|
import 'package:neomovies_mobile/presentation/providers/movie_detail_provider.dart';
|
||||||
import 'package:neomovies_mobile/presentation/screens/player/video_player_screen.dart';
|
import 'package:neomovies_mobile/presentation/screens/player/video_player_screen.dart';
|
||||||
|
import 'package:neomovies_mobile/presentation/screens/torrent_selector/torrent_selector_screen.dart';
|
||||||
|
import 'package:neomovies_mobile/presentation/widgets/error_display.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class MovieDetailScreen extends StatefulWidget {
|
class MovieDetailScreen extends StatefulWidget {
|
||||||
@@ -29,6 +31,28 @@ class _MovieDetailScreenState extends State<MovieDetailScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openTorrentSelector(BuildContext context, String? imdbId, String title) {
|
||||||
|
if (imdbId == null || imdbId.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('IMDB ID не найден. Невозможно загрузить торренты.'),
|
||||||
|
duration: Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => TorrentSelectorScreen(
|
||||||
|
imdbId: imdbId,
|
||||||
|
mediaType: widget.mediaType,
|
||||||
|
title: title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _openPlayer(BuildContext context, String? imdbId, String title) {
|
void _openPlayer(BuildContext context, String? imdbId, String title) {
|
||||||
if (imdbId == null || imdbId.isEmpty) {
|
if (imdbId == null || imdbId.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -40,13 +64,11 @@ class _MovieDetailScreenState extends State<MovieDetailScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Navigator.of(context).push(
|
// TODO: Implement proper player navigation with mediaId
|
||||||
MaterialPageRoute(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
builder: (context) => VideoPlayerScreen(
|
SnackBar(
|
||||||
mediaId: imdbId,
|
content: Text('Player feature will be implemented. Media ID: $imdbId'),
|
||||||
mediaType: widget.mediaType,
|
duration: Duration(seconds: 2),
|
||||||
title: title,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,7 +90,15 @@ class _MovieDetailScreenState extends State<MovieDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider.error != null) {
|
if (provider.error != null) {
|
||||||
return Center(child: Text('Error: ${provider.error}'));
|
return ErrorDisplay(
|
||||||
|
title: 'Ошибка загрузки ${widget.mediaType == 'movie' ? 'фильма' : 'сериала'}',
|
||||||
|
error: provider.error!,
|
||||||
|
stackTrace: provider.stackTrace,
|
||||||
|
onRetry: () {
|
||||||
|
Provider.of<MovieDetailProvider>(context, listen: false)
|
||||||
|
.loadMedia(int.parse(widget.movieId), widget.mediaType);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.movie == null) {
|
if (provider.movie == null) {
|
||||||
@@ -205,9 +235,9 @@ class _MovieDetailScreenState extends State<MovieDetailScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
).copyWith(
|
).copyWith(
|
||||||
// Устанавливаем цвет для неактивного состояния
|
// Устанавливаем цвет для неактивного состояния
|
||||||
backgroundColor: MaterialStateProperty.resolveWith<Color?>(
|
backgroundColor: WidgetStateProperty.resolveWith<Color?>(
|
||||||
(Set<MaterialState> states) {
|
(Set<WidgetState> states) {
|
||||||
if (states.contains(MaterialState.disabled)) {
|
if (states.contains(WidgetState.disabled)) {
|
||||||
return Colors.grey;
|
return Colors.grey;
|
||||||
}
|
}
|
||||||
return Theme.of(context).colorScheme.primary;
|
return Theme.of(context).colorScheme.primary;
|
||||||
@@ -262,6 +292,33 @@ class _MovieDetailScreenState extends State<MovieDetailScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// Download button
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Consumer<MovieDetailProvider>(
|
||||||
|
builder: (context, provider, child) {
|
||||||
|
final imdbId = provider.imdbId;
|
||||||
|
final isImdbLoading = provider.isImdbLoading;
|
||||||
|
|
||||||
|
return IconButton(
|
||||||
|
onPressed: (isImdbLoading || imdbId == null)
|
||||||
|
? null
|
||||||
|
: () => _openTorrentSelector(context, imdbId, movie.title),
|
||||||
|
icon: isImdbLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
iconSize: 28,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer,
|
||||||
|
foregroundColor: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'Скачать торрент',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,163 +1,290 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'dart:io';
|
||||||
import 'package:neomovies_mobile/utils/device_utils.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:neomovies_mobile/presentation/widgets/player/web_player_widget.dart';
|
|
||||||
import 'package:neomovies_mobile/data/models/player/video_source.dart';
|
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
class VideoPlayerScreen extends StatefulWidget {
|
class VideoPlayerScreen extends StatefulWidget {
|
||||||
final String mediaId; // Теперь это IMDB ID
|
final String filePath;
|
||||||
final String mediaType; // 'movie' or 'tv'
|
final String title;
|
||||||
final String? title;
|
|
||||||
final String? subtitle;
|
|
||||||
final String? posterUrl;
|
|
||||||
|
|
||||||
const VideoPlayerScreen({
|
const VideoPlayerScreen({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.mediaId,
|
required this.filePath,
|
||||||
required this.mediaType,
|
required this.title,
|
||||||
this.title,
|
});
|
||||||
this.subtitle,
|
|
||||||
this.posterUrl,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||||
VideoSource _selectedSource = VideoSource.defaultSources.first;
|
VideoPlayerController? _controller;
|
||||||
|
bool _isControlsVisible = true;
|
||||||
|
bool _isFullscreen = false;
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_setupPlayerEnvironment();
|
_initializePlayer();
|
||||||
}
|
|
||||||
|
|
||||||
void _setupPlayerEnvironment() {
|
|
||||||
// Keep screen awake during video playback
|
|
||||||
WakelockPlus.enable();
|
|
||||||
|
|
||||||
// Set landscape orientation
|
|
||||||
SystemChrome.setPreferredOrientations([
|
|
||||||
DeviceOrientation.landscapeLeft,
|
|
||||||
DeviceOrientation.landscapeRight,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Hide system UI for immersive experience
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_restoreSystemSettings();
|
_controller?.dispose();
|
||||||
|
_setOrientation(false);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _restoreSystemSettings() {
|
Future<void> _initializePlayer() async {
|
||||||
// Restore system UI and allow screen to sleep
|
try {
|
||||||
WakelockPlus.disable();
|
final file = File(widget.filePath);
|
||||||
|
if (!await file.exists()) {
|
||||||
// Restore orientation: phones back to portrait, tablets/TV keep free rotation
|
setState(() {
|
||||||
if (DeviceUtils.isLargeScreen(context)) {
|
_error = 'Файл не найден: ${widget.filePath}';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_controller = VideoPlayerController.file(file);
|
||||||
|
|
||||||
|
await _controller!.initialize();
|
||||||
|
|
||||||
|
_controller!.addListener(() {
|
||||||
|
setState(() {});
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto play
|
||||||
|
_controller!.play();
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Ошибка инициализации плеера: $e';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _togglePlayPause() {
|
||||||
|
if (_controller!.value.isPlaying) {
|
||||||
|
_controller!.pause();
|
||||||
|
} else {
|
||||||
|
_controller!.play();
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFullscreen() {
|
||||||
|
setState(() {
|
||||||
|
_isFullscreen = !_isFullscreen;
|
||||||
|
});
|
||||||
|
_setOrientation(_isFullscreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setOrientation(bool isFullscreen) {
|
||||||
|
if (isFullscreen) {
|
||||||
SystemChrome.setPreferredOrientations([
|
SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.portraitUp,
|
|
||||||
DeviceOrientation.portraitDown,
|
|
||||||
DeviceOrientation.landscapeLeft,
|
DeviceOrientation.landscapeLeft,
|
||||||
DeviceOrientation.landscapeRight,
|
DeviceOrientation.landscapeRight,
|
||||||
]);
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
} else {
|
} else {
|
||||||
SystemChrome.setPreferredOrientations([
|
SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.portraitUp,
|
DeviceOrientation.portraitUp,
|
||||||
]);
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.manual,
|
||||||
|
overlays: SystemUiOverlay.values,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleControls() {
|
||||||
|
setState(() {
|
||||||
|
_isControlsVisible = !_isControlsVisible;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_isControlsVisible) {
|
||||||
|
// Hide controls after 3 seconds
|
||||||
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (mounted && _controller!.value.isPlaying) {
|
||||||
|
setState(() {
|
||||||
|
_isControlsVisible = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||||
|
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||||
|
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||||
|
final hours = duration.inHours;
|
||||||
|
|
||||||
// Restore system UI
|
if (hours > 0) {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
return '$hours:$minutes:$seconds';
|
||||||
|
} else {
|
||||||
|
return '$minutes:$seconds';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return WillPopScope(
|
|
||||||
onWillPop: () async {
|
|
||||||
_restoreSystemSettings();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
child: _VideoPlayerScreenContent(
|
|
||||||
title: widget.title,
|
|
||||||
mediaId: widget.mediaId,
|
|
||||||
selectedSource: _selectedSource,
|
|
||||||
onSourceChanged: (source) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_selectedSource = source;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VideoPlayerScreenContent extends StatelessWidget {
|
|
||||||
final String mediaId; // IMDB ID
|
|
||||||
final String? title;
|
|
||||||
final VideoSource selectedSource;
|
|
||||||
final ValueChanged<VideoSource> onSourceChanged;
|
|
||||||
|
|
||||||
const _VideoPlayerScreenContent({
|
|
||||||
Key? key,
|
|
||||||
required this.mediaId,
|
|
||||||
this.title,
|
|
||||||
required this.selectedSource,
|
|
||||||
required this.onSourceChanged,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: SafeArea(
|
appBar: _isFullscreen ? null : AppBar(
|
||||||
|
title: Text(
|
||||||
|
widget.title,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Source selector header
|
const Icon(
|
||||||
Container(
|
Icons.error_outline,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
size: 64,
|
||||||
color: Colors.black87,
|
color: Colors.white,
|
||||||
child: Row(
|
),
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
IconButton(
|
const Text(
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
'Ошибка воспроизведения',
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
style: TextStyle(
|
||||||
),
|
color: Colors.white,
|
||||||
const SizedBox(width: 8),
|
fontSize: 18,
|
||||||
const Text(
|
fontWeight: FontWeight.w600,
|
||||||
'Источник: ',
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
|
||||||
),
|
|
||||||
_buildSourceSelector(),
|
|
||||||
const Spacer(),
|
|
||||||
if (title != null)
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: Text(
|
|
||||||
title!,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: TextAlign.end,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
// Video player
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Назад'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controller == null || !_controller!.value.isInitialized) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _toggleControls,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Video player
|
||||||
|
Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: _controller!.value.aspectRatio,
|
||||||
|
child: VideoPlayer(_controller!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Controls overlay
|
||||||
|
if (_isControlsVisible)
|
||||||
|
_buildControlsOverlay(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildControlsOverlay() {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withOpacity(0.7),
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.black.withOpacity(0.7),
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.3, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Top bar
|
||||||
|
if (_isFullscreen) _buildTopBar(),
|
||||||
|
|
||||||
|
// Center play/pause
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: _buildCenterControls(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom controls
|
||||||
|
_buildBottomControls(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTopBar() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: WebPlayerWidget(
|
child: Text(
|
||||||
key: ValueKey(selectedSource.id),
|
widget.title,
|
||||||
mediaId: mediaId,
|
style: const TextStyle(
|
||||||
source: selectedSource,
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -166,24 +293,137 @@ class _VideoPlayerScreenContent extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSourceSelector() {
|
Widget _buildCenterControls() {
|
||||||
return DropdownButton<VideoSource>(
|
return Row(
|
||||||
value: selectedSource,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
dropdownColor: Colors.black87,
|
children: [
|
||||||
style: const TextStyle(color: Colors.white),
|
IconButton(
|
||||||
underline: Container(),
|
iconSize: 48,
|
||||||
items: VideoSource.defaultSources
|
icon: Icon(
|
||||||
.where((source) => source.isActive)
|
Icons.replay_10,
|
||||||
.map((source) => DropdownMenuItem<VideoSource>(
|
color: Colors.white.withOpacity(0.8),
|
||||||
value: source,
|
),
|
||||||
child: Text(source.name),
|
onPressed: () {
|
||||||
))
|
final newPosition = _controller!.value.position - const Duration(seconds: 10);
|
||||||
.toList(),
|
_controller!.seekTo(newPosition < Duration.zero ? Duration.zero : newPosition);
|
||||||
onChanged: (VideoSource? newSource) {
|
},
|
||||||
if (newSource != null) {
|
),
|
||||||
onSourceChanged(newSource);
|
const SizedBox(width: 32),
|
||||||
}
|
Container(
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
iconSize: 64,
|
||||||
|
icon: Icon(
|
||||||
|
_controller!.value.isPlaying ? Icons.pause : Icons.play_arrow,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _togglePlayPause,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 32),
|
||||||
|
IconButton(
|
||||||
|
iconSize: 48,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.forward_10,
|
||||||
|
color: Colors.white.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
final newPosition = _controller!.value.position + const Duration(seconds: 10);
|
||||||
|
final maxDuration = _controller!.value.duration;
|
||||||
|
_controller!.seekTo(newPosition > maxDuration ? maxDuration : newPosition);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Widget _buildBottomControls() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Progress bar
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatDuration(_controller!.value.position),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: VideoProgressIndicator(
|
||||||
|
_controller!,
|
||||||
|
allowScrubbing: true,
|
||||||
|
colors: VideoProgressColors(
|
||||||
|
playedColor: Theme.of(context).primaryColor,
|
||||||
|
backgroundColor: Colors.white.withOpacity(0.3),
|
||||||
|
bufferedColor: Colors.white.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_formatDuration(_controller!.value.duration),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Control buttons
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_controller!.value.volume == 0 ? Icons.volume_off : Icons.volume_up,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (_controller!.value.volume == 0) {
|
||||||
|
_controller!.setVolume(1.0);
|
||||||
|
} else {
|
||||||
|
_controller!.setVolume(0.0);
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _toggleFullscreen,
|
||||||
|
),
|
||||||
|
PopupMenuButton<double>(
|
||||||
|
icon: const Icon(Icons.speed, color: Colors.white),
|
||||||
|
onSelected: (speed) {
|
||||||
|
_controller!.setPlaybackSpeed(speed);
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(value: 0.5, child: Text('0.5x')),
|
||||||
|
const PopupMenuItem(value: 0.75, child: Text('0.75x')),
|
||||||
|
const PopupMenuItem(value: 1.0, child: Text('1.0x')),
|
||||||
|
const PopupMenuItem(value: 1.25, child: Text('1.25x')),
|
||||||
|
const PopupMenuItem(value: 1.5, child: Text('1.5x')),
|
||||||
|
const PopupMenuItem(value: 2.0, child: Text('2.0x')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
469
lib/presentation/screens/player/webview_player_screen.dart
Normal file
469
lib/presentation/screens/player/webview_player_screen.dart
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import '../../../data/services/player_embed_service.dart';
|
||||||
|
|
||||||
|
enum WebPlayerType { vibix, alloha }
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class WebViewPlayerScreen extends StatefulWidget {
|
||||||
|
final WebPlayerType playerType;
|
||||||
|
final String videoUrl;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const WebViewPlayerScreen({
|
||||||
|
super.key,
|
||||||
|
required this.playerType,
|
||||||
|
required this.videoUrl,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WebViewPlayerScreen> createState() => _WebViewPlayerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebViewPlayerScreenState extends State<WebViewPlayerScreen> {
|
||||||
|
late WebViewController _controller;
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isFullscreen = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeWebView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_setOrientation(false);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeWebView() {
|
||||||
|
_controller = WebViewController()
|
||||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onProgress: (int progress) {
|
||||||
|
// Update loading progress
|
||||||
|
},
|
||||||
|
onPageStarted: (String url) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPageFinished: (String url) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onWebResourceError: (WebResourceError error) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Ошибка загрузки: ${error.description}';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_loadPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadPlayer() async {
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final playerUrl = await _getPlayerUrl();
|
||||||
|
_controller.loadRequest(Uri.parse(playerUrl));
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Ошибка получения URL плеера: $e';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _getPlayerUrl() async {
|
||||||
|
switch (widget.playerType) {
|
||||||
|
case WebPlayerType.vibix:
|
||||||
|
return await _getVibixUrl();
|
||||||
|
case WebPlayerType.alloha:
|
||||||
|
return await _getAllohaUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _getVibixUrl() async {
|
||||||
|
try {
|
||||||
|
// Try to get embed URL from API server first
|
||||||
|
return await PlayerEmbedService.getVibixEmbedUrl(
|
||||||
|
videoUrl: widget.videoUrl,
|
||||||
|
title: widget.title,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to direct URL if server is unavailable
|
||||||
|
final encodedVideoUrl = Uri.encodeComponent(widget.videoUrl);
|
||||||
|
return 'https://vibix.me/embed/?src=$encodedVideoUrl&autoplay=1&title=${Uri.encodeComponent(widget.title)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _getAllohaUrl() async {
|
||||||
|
try {
|
||||||
|
// Try to get embed URL from API server first
|
||||||
|
return await PlayerEmbedService.getAllohaEmbedUrl(
|
||||||
|
videoUrl: widget.videoUrl,
|
||||||
|
title: widget.title,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to direct URL if server is unavailable
|
||||||
|
final encodedVideoUrl = Uri.encodeComponent(widget.videoUrl);
|
||||||
|
return 'https://alloha.tv/embed?src=$encodedVideoUrl&autoplay=1&title=${Uri.encodeComponent(widget.title)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFullscreen() {
|
||||||
|
setState(() {
|
||||||
|
_isFullscreen = !_isFullscreen;
|
||||||
|
});
|
||||||
|
_setOrientation(_isFullscreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setOrientation(bool isFullscreen) {
|
||||||
|
if (isFullscreen) {
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.landscapeLeft,
|
||||||
|
DeviceOrientation.landscapeRight,
|
||||||
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
|
} else {
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.portraitUp,
|
||||||
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.manual,
|
||||||
|
overlays: SystemUiOverlay.values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getPlayerName() {
|
||||||
|
switch (widget.playerType) {
|
||||||
|
case WebPlayerType.vibix:
|
||||||
|
return 'Vibix';
|
||||||
|
case WebPlayerType.alloha:
|
||||||
|
return 'Alloha';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: _isFullscreen ? null : AppBar(
|
||||||
|
title: Text(
|
||||||
|
'${_getPlayerName()} - ${widget.title}',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _toggleFullscreen,
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
|
onSelected: (value) => _handleMenuAction(value),
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'reload',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.refresh),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Перезагрузить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'share',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.share),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Поделиться'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_error != null) {
|
||||||
|
return _buildErrorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// WebView
|
||||||
|
WebViewWidget(controller: _controller),
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
if (_isLoading)
|
||||||
|
Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Загрузка плеера...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Fullscreen toggle for when player is loaded
|
||||||
|
if (!_isLoading && !_isFullscreen)
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.7),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.fullscreen, color: Colors.white),
|
||||||
|
onPressed: _toggleFullscreen,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorState() {
|
||||||
|
return Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.red.shade300,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Ошибка загрузки плеера',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
_loadPlayer();
|
||||||
|
},
|
||||||
|
child: const Text('Повторить'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
side: const BorderSide(color: Colors.white),
|
||||||
|
),
|
||||||
|
child: const Text('Назад'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPlayerInfo(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlayerInfo() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade900.withOpacity(0.8),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Информация о плеере',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildInfoRow('Плеер', _getPlayerName()),
|
||||||
|
_buildInfoRow('Файл', widget.title),
|
||||||
|
_buildInfoRow('URL', widget.videoUrl),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 60,
|
||||||
|
child: Text(
|
||||||
|
'$label:',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleMenuAction(String action) {
|
||||||
|
switch (action) {
|
||||||
|
case 'reload':
|
||||||
|
_loadPlayer();
|
||||||
|
break;
|
||||||
|
case 'share':
|
||||||
|
_shareVideo();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shareVideo() {
|
||||||
|
// TODO: Implement sharing functionality
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Поделиться: ${widget.title}'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper widget for creating custom HTML player if needed
|
||||||
|
class CustomPlayerWidget extends StatelessWidget {
|
||||||
|
final String videoUrl;
|
||||||
|
final String title;
|
||||||
|
final WebPlayerType playerType;
|
||||||
|
|
||||||
|
const CustomPlayerWidget({
|
||||||
|
super.key,
|
||||||
|
required this.videoUrl,
|
||||||
|
required this.title,
|
||||||
|
required this.playerType,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.play_circle_filled,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.white.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Плеер: ${playerType == WebPlayerType.vibix ? 'Vibix' : 'Alloha'}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'Нажмите для воспроизведения',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../../../data/services/torrent_platform_service.dart';
|
||||||
|
|
||||||
|
class TorrentFileSelectorScreen extends StatefulWidget {
|
||||||
|
final String magnetLink;
|
||||||
|
final String torrentTitle;
|
||||||
|
|
||||||
|
const TorrentFileSelectorScreen({
|
||||||
|
super.key,
|
||||||
|
required this.magnetLink,
|
||||||
|
required this.torrentTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TorrentFileSelectorScreen> createState() => _TorrentFileSelectorScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TorrentFileSelectorScreenState extends State<TorrentFileSelectorScreen> {
|
||||||
|
TorrentMetadataFull? _metadata;
|
||||||
|
List<FileInfo> _files = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
bool _isDownloading = false;
|
||||||
|
bool _selectAll = false;
|
||||||
|
MagnetBasicInfo? _basicInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadTorrentMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTorrentMetadata() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Сначала получаем базовую информацию
|
||||||
|
_basicInfo = await TorrentPlatformService.parseMagnetBasicInfo(widget.magnetLink);
|
||||||
|
|
||||||
|
// Затем пытаемся получить полные метаданные
|
||||||
|
final metadata = await TorrentPlatformService.fetchFullMetadata(widget.magnetLink);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_metadata = metadata;
|
||||||
|
_files = metadata.getAllFiles().map((file) => file.copyWith(selected: false)).toList();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Если не удалось получить полные метаданные, используем базовую информацию
|
||||||
|
if (_basicInfo != null) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Не удалось получить полные метаданные. Показана базовая информация.';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFileSelection(int index) {
|
||||||
|
setState(() {
|
||||||
|
_files[index] = _files[index].copyWith(selected: !_files[index].selected);
|
||||||
|
_updateSelectAllState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSelectAll() {
|
||||||
|
setState(() {
|
||||||
|
_selectAll = !_selectAll;
|
||||||
|
_files = _files.map((file) => file.copyWith(selected: _selectAll)).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSelectAllState() {
|
||||||
|
final selectedCount = _files.where((file) => file.selected).length;
|
||||||
|
_selectAll = selectedCount == _files.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startDownload() async {
|
||||||
|
final selectedFiles = <int>[];
|
||||||
|
for (int i = 0; i < _files.length; i++) {
|
||||||
|
if (_files[i].selected) {
|
||||||
|
selectedFiles.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFiles.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Выберите хотя бы один файл для скачивания'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isDownloading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final infoHash = await TorrentPlatformService.startDownload(
|
||||||
|
magnetLink: widget.magnetLink,
|
||||||
|
selectedFiles: selectedFiles,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Скачивание начато! ID: ${infoHash.substring(0, 8)}...'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Ошибка скачивания: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isDownloading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFileSize(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Выбор файлов'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 1,
|
||||||
|
actions: [
|
||||||
|
if (!_isLoading && _files.isNotEmpty)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _toggleSelectAll,
|
||||||
|
child: Text(_selectAll ? 'Снять все' : 'Выбрать все'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Header with torrent info
|
||||||
|
_buildTorrentHeader(),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Expanded(
|
||||||
|
child: _buildContent(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Download button
|
||||||
|
if (!_isLoading && _files.isNotEmpty && _metadata != null) _buildDownloadButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTorrentHeader() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.folder_zip,
|
||||||
|
size: 24,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.torrentTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_metadata != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Общий размер: ${_formatFileSize(_metadata!.totalSize)} • Файлов: ${_metadata!.fileStructure.totalFiles}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else if (_basicInfo != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Инфо хэш: ${_basicInfo!.infoHash.substring(0, 8)}... • Трекеров: ${_basicInfo!.trackers.length}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Получение информации о торренте...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: 'Ошибка загрузки метаданных\n',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: _error!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _loadTorrentMetadata,
|
||||||
|
child: const Text('Повторить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_files.isEmpty && _basicInfo != null) {
|
||||||
|
// Показываем базовую информацию о торренте
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Базовая информация о торренте',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Название: ${_basicInfo!.name}'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Инфо хэш: ${_basicInfo!.infoHash}'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Трекеров: ${_basicInfo!.trackers.length}'),
|
||||||
|
if (_basicInfo!.trackers.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Основной трекер: ${_basicInfo!.trackers.first}'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _loadTorrentMetadata,
|
||||||
|
child: const Text('Получить полные метаданные'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_files.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('Файлы не найдены'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _files.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = _files[index];
|
||||||
|
final isDirectory = file.path.contains('/');
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: CheckboxListTile(
|
||||||
|
value: file.selected,
|
||||||
|
onChanged: (_) => _toggleFileSelection(index),
|
||||||
|
title: Text(
|
||||||
|
file.path.split('/').last,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (isDirectory) ...[
|
||||||
|
Text(
|
||||||
|
file.path.substring(0, file.path.lastIndexOf('/')),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
_formatFileSize(file.size),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
secondary: Icon(
|
||||||
|
_getFileIcon(file.path),
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadButton() {
|
||||||
|
final selectedCount = _files.where((file) => file.selected).length;
|
||||||
|
final selectedSize = _files
|
||||||
|
.where((file) => file.selected)
|
||||||
|
.fold<int>(0, (sum, file) => sum + file.size);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Theme.of(context).colorScheme.shadow.withOpacity(0.1),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (selectedCount > 0) ...[
|
||||||
|
Text(
|
||||||
|
'Выбрано: $selectedCount файл(ов) • ${_formatFileSize(selectedSize)}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _isDownloading ? null : _startDownload,
|
||||||
|
icon: _isDownloading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
label: Text(_isDownloading ? 'Начинаем скачивание...' : 'Скачать выбранные'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getFileIcon(String path) {
|
||||||
|
final extension = path.split('.').last.toLowerCase();
|
||||||
|
|
||||||
|
switch (extension) {
|
||||||
|
case 'mp4':
|
||||||
|
case 'mkv':
|
||||||
|
case 'avi':
|
||||||
|
case 'mov':
|
||||||
|
case 'wmv':
|
||||||
|
return Icons.movie;
|
||||||
|
case 'mp3':
|
||||||
|
case 'wav':
|
||||||
|
case 'flac':
|
||||||
|
case 'aac':
|
||||||
|
return Icons.music_note;
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
case 'png':
|
||||||
|
case 'gif':
|
||||||
|
return Icons.image;
|
||||||
|
case 'txt':
|
||||||
|
case 'nfo':
|
||||||
|
return Icons.description;
|
||||||
|
case 'srt':
|
||||||
|
case 'sub':
|
||||||
|
case 'ass':
|
||||||
|
return Icons.subtitles;
|
||||||
|
default:
|
||||||
|
return Icons.insert_drive_file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../data/models/torrent.dart';
|
||||||
|
import '../../../data/services/torrent_service.dart';
|
||||||
|
import '../../cubits/torrent/torrent_cubit.dart';
|
||||||
|
import '../../cubits/torrent/torrent_state.dart';
|
||||||
|
import '../torrent_file_selector/torrent_file_selector_screen.dart';
|
||||||
|
|
||||||
|
class TorrentSelectorScreen extends StatefulWidget {
|
||||||
|
final String imdbId;
|
||||||
|
final String mediaType;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const TorrentSelectorScreen({
|
||||||
|
super.key,
|
||||||
|
required this.imdbId,
|
||||||
|
required this.mediaType,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TorrentSelectorScreen> createState() => _TorrentSelectorScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
|
||||||
|
String? _selectedMagnet;
|
||||||
|
bool _isCopied = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => TorrentCubit(torrentService: TorrentService())
|
||||||
|
..loadTorrents(
|
||||||
|
imdbId: widget.imdbId,
|
||||||
|
mediaType: widget.mediaType,
|
||||||
|
),
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Выбор для загрузки'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 1,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Header with movie info
|
||||||
|
_buildMovieHeader(context),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<TorrentCubit, TorrentState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return state.when(
|
||||||
|
initial: () => const SizedBox.shrink(),
|
||||||
|
loading: () => const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Загрузка торрентов...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
loaded: (torrents, qualityGroups, imdbId, mediaType, selectedSeason, availableSeasons, selectedQuality) =>
|
||||||
|
_buildLoadedContent(
|
||||||
|
context,
|
||||||
|
torrents,
|
||||||
|
qualityGroups,
|
||||||
|
mediaType,
|
||||||
|
selectedSeason,
|
||||||
|
availableSeasons,
|
||||||
|
selectedQuality,
|
||||||
|
),
|
||||||
|
error: (message) => _buildErrorContent(context, message),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Selected magnet section
|
||||||
|
if (_selectedMagnet != null) _buildSelectedMagnetSection(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMovieHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
widget.mediaType == 'tv' ? Icons.tv : Icons.movie,
|
||||||
|
size: 24,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
widget.mediaType == 'tv' ? 'Сериал' : 'Фильм',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadedContent(
|
||||||
|
BuildContext context,
|
||||||
|
List<Torrent> torrents,
|
||||||
|
Map<String, List<Torrent>> qualityGroups,
|
||||||
|
String mediaType,
|
||||||
|
int? selectedSeason,
|
||||||
|
List<int>? availableSeasons,
|
||||||
|
String? selectedQuality,
|
||||||
|
) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Season selector for TV shows
|
||||||
|
if (mediaType == 'tv' && availableSeasons != null && availableSeasons.isNotEmpty)
|
||||||
|
_buildSeasonSelector(context, availableSeasons, selectedSeason),
|
||||||
|
|
||||||
|
// Quality selector
|
||||||
|
if (qualityGroups.isNotEmpty)
|
||||||
|
_buildQualitySelector(context, qualityGroups, selectedQuality),
|
||||||
|
|
||||||
|
// Torrents list
|
||||||
|
Expanded(
|
||||||
|
child: torrents.isEmpty
|
||||||
|
? _buildEmptyState(context)
|
||||||
|
: _buildTorrentsGroupedList(context, qualityGroups, selectedQuality),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSeasonSelector(BuildContext context, List<int> seasons, int? selectedSeason) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Сезон',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: seasons.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final season = seasons[index];
|
||||||
|
final isSelected = season == selectedSeason;
|
||||||
|
return FilterChip(
|
||||||
|
label: Text('Сезон $season'),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
context.read<TorrentCubit>().selectSeason(season);
|
||||||
|
setState(() {
|
||||||
|
_selectedMagnet = null;
|
||||||
|
_isCopied = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQualitySelector(BuildContext context, Map<String, List<Torrent>> qualityGroups, String? selectedQuality) {
|
||||||
|
final qualities = qualityGroups.keys.toList();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Качество',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: qualities.length + 1, // +1 для кнопки "Все"
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == 0) {
|
||||||
|
// Кнопка "Все"
|
||||||
|
return FilterChip(
|
||||||
|
label: const Text('Все'),
|
||||||
|
selected: selectedQuality == null,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
context.read<TorrentCubit>().selectQuality(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final quality = qualities[index - 1];
|
||||||
|
final count = qualityGroups[quality]?.length ?? 0;
|
||||||
|
return FilterChip(
|
||||||
|
label: Text('$quality ($count)'),
|
||||||
|
selected: quality == selectedQuality,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
context.read<TorrentCubit>().selectQuality(quality);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTorrentsGroupedList(BuildContext context, Map<String, List<Torrent>> qualityGroups, String? selectedQuality) {
|
||||||
|
// Если выбрано конкретное качество, показываем только его
|
||||||
|
if (selectedQuality != null) {
|
||||||
|
final torrents = qualityGroups[selectedQuality] ?? [];
|
||||||
|
if (torrents.isEmpty) {
|
||||||
|
return _buildEmptyState(context);
|
||||||
|
}
|
||||||
|
return _buildTorrentsList(context, torrents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе показываем все группы
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: qualityGroups.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final quality = qualityGroups.keys.elementAt(index);
|
||||||
|
final torrents = qualityGroups[quality]!;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Заголовок группы качества
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
quality,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'${torrents.length} раздач',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Список торрентов в группе
|
||||||
|
...torrents.map((torrent) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _buildTorrentItem(context, torrent),
|
||||||
|
)).toList(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTorrentsList(BuildContext context, List<Torrent> torrents) {
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: torrents.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final torrent = torrents[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _buildTorrentItem(context, torrent),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTorrentItem(BuildContext context, Torrent torrent) {
|
||||||
|
final title = torrent.title ?? torrent.name ?? 'Неизвестная раздача';
|
||||||
|
final quality = torrent.quality;
|
||||||
|
final seeders = torrent.seeders;
|
||||||
|
final isSelected = _selectedMagnet == torrent.magnet;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: isSelected ? 4 : 1,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedMagnet = torrent.magnet;
|
||||||
|
_isCopied = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (quality != null) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
quality,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
if (seeders != null) ...[
|
||||||
|
Icon(
|
||||||
|
Icons.upload,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'$seeders',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
if (torrent.size != null) ...[
|
||||||
|
Icon(
|
||||||
|
Icons.storage,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_formatFileSize(torrent.size),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (isSelected) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Выбрано',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.search_off,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Торренты не найдены',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Попробуйте выбрать другой сезон',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorContent(BuildContext context, String message) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: 'Ошибка загрузки\n',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: message,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
context.read<TorrentCubit>().loadTorrents(
|
||||||
|
imdbId: widget.imdbId,
|
||||||
|
mediaType: widget.mediaType,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Повторить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectedMagnetSection(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Theme.of(context).colorScheme.shadow.withOpacity(0.1),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Magnet-ссылка',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_selectedMagnet!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _copyToClipboard,
|
||||||
|
icon: Icon(_isCopied ? Icons.check : Icons.copy, size: 20),
|
||||||
|
label: Text(_isCopied ? 'Скопировано!' : 'Копировать'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _openFileSelector,
|
||||||
|
icon: const Icon(Icons.download, size: 20),
|
||||||
|
label: const Text('Скачать'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFileSize(int? sizeInBytes) {
|
||||||
|
if (sizeInBytes == null || sizeInBytes == 0) return 'Неизвестно';
|
||||||
|
|
||||||
|
const int kb = 1024;
|
||||||
|
const int mb = kb * 1024;
|
||||||
|
const int gb = mb * 1024;
|
||||||
|
|
||||||
|
if (sizeInBytes >= gb) {
|
||||||
|
return '${(sizeInBytes / gb).toStringAsFixed(1)} GB';
|
||||||
|
} else if (sizeInBytes >= mb) {
|
||||||
|
return '${(sizeInBytes / mb).toStringAsFixed(0)} MB';
|
||||||
|
} else if (sizeInBytes >= kb) {
|
||||||
|
return '${(sizeInBytes / kb).toStringAsFixed(0)} KB';
|
||||||
|
} else {
|
||||||
|
return '$sizeInBytes B';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openFileSelector() {
|
||||||
|
if (_selectedMagnet != null) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => TorrentFileSelectorScreen(
|
||||||
|
magnetLink: _selectedMagnet!,
|
||||||
|
torrentTitle: widget.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyToClipboard() {
|
||||||
|
if (_selectedMagnet != null) {
|
||||||
|
Clipboard.setData(ClipboardData(text: _selectedMagnet!));
|
||||||
|
setState(() {
|
||||||
|
_isCopied = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Показываем снэкбар
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Magnet-ссылка скопирована в буфер обмена'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сбрасываем состояние через 2 секунды
|
||||||
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isCopied = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
254
lib/presentation/widgets/error_display.dart
Normal file
254
lib/presentation/widgets/error_display.dart
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Widget that displays detailed error information for debugging
|
||||||
|
class ErrorDisplay extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String error;
|
||||||
|
final String? stackTrace;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
|
||||||
|
const ErrorDisplay({
|
||||||
|
super.key,
|
||||||
|
this.title = 'Произошла ошибка',
|
||||||
|
required this.error,
|
||||||
|
this.stackTrace,
|
||||||
|
this.onRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Error icon and title
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.red.shade400,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Error message card
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.red.shade200),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, size: 20, color: Colors.red.shade700),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Сообщение об ошибке:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SelectableText(
|
||||||
|
error,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: error));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Ошибка скопирована в буфер обмена'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
|
label: const Text('Копировать ошибку'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red.shade700,
|
||||||
|
side: BorderSide(color: Colors.red.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Stack trace (if available)
|
||||||
|
if (stackTrace != null && stackTrace!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ExpansionTile(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.bug_report, size: 20, color: Colors.orange.shade700),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Stack Trace (для разработчиков)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.orange.shade50,
|
||||||
|
collapsedBackgroundColor: Colors.orange.shade50,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
side: BorderSide(color: Colors.orange.shade200),
|
||||||
|
),
|
||||||
|
collapsedShape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
side: BorderSide(color: Colors.orange.shade200),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade900,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(8),
|
||||||
|
bottomRight: Radius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SelectableText(
|
||||||
|
stackTrace!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.greenAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: stackTrace!));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Stack trace скопирован в буфер обмена'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
|
label: const Text('Копировать stack trace'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.greenAccent,
|
||||||
|
side: const BorderSide(color: Colors.greenAccent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Retry button
|
||||||
|
if (onRetry != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Попробовать снова'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Debug tips
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.blue.shade200),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lightbulb_outline, size: 20, color: Colors.blue.shade700),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Советы по отладке:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'• Скопируйте ошибку и отправьте разработчику\n'
|
||||||
|
'• Проверьте соединение с интернетом\n'
|
||||||
|
'• Проверьте логи Flutter в консоли\n'
|
||||||
|
'• Попробуйте перезапустить приложение',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue.shade900,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:neomovies_mobile/data/models/player/video_source.dart';
|
import 'package:neomovies_mobile/data/models/player/video_source.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class WebPlayerWidget extends StatefulWidget {
|
class WebPlayerWidget extends StatefulWidget {
|
||||||
final VideoSource source;
|
final VideoSource source;
|
||||||
@@ -17,14 +21,29 @@ class WebPlayerWidget extends StatefulWidget {
|
|||||||
State<WebPlayerWidget> createState() => _WebPlayerWidgetState();
|
State<WebPlayerWidget> createState() => _WebPlayerWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WebPlayerWidgetState extends State<WebPlayerWidget> {
|
class _WebPlayerWidgetState extends State<WebPlayerWidget>
|
||||||
|
with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
|
||||||
late final WebViewController _controller;
|
late final WebViewController _controller;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
bool _isDisposed = false;
|
||||||
|
Timer? _retryTimer;
|
||||||
|
int _retryCount = 0;
|
||||||
|
static const int _maxRetries = 3;
|
||||||
|
static const Duration _retryDelay = Duration(seconds: 2);
|
||||||
|
|
||||||
|
// Performance optimization flags
|
||||||
|
bool _hasInitialized = false;
|
||||||
|
String? _lastLoadedUrl;
|
||||||
|
|
||||||
|
// Keep alive for better performance
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_initializeWebView();
|
_initializeWebView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
162
lib/utils/focus_manager.dart
Normal file
162
lib/utils/focus_manager.dart
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Глобальный менеджер фокуса для управления навигацией между элементами интерфейса
|
||||||
|
class GlobalFocusManager {
|
||||||
|
static final GlobalFocusManager _instance = GlobalFocusManager._internal();
|
||||||
|
factory GlobalFocusManager() => _instance;
|
||||||
|
GlobalFocusManager._internal();
|
||||||
|
|
||||||
|
// Фокус ноды для разных элементов интерфейса
|
||||||
|
FocusNode? _appBarFocusNode;
|
||||||
|
FocusNode? _contentFocusNode;
|
||||||
|
FocusNode? _bottomNavFocusNode;
|
||||||
|
|
||||||
|
// Текущее состояние фокуса
|
||||||
|
FocusArea _currentFocusArea = FocusArea.content;
|
||||||
|
|
||||||
|
// Callback для уведомления об изменении фокуса
|
||||||
|
VoidCallback? _onFocusChanged;
|
||||||
|
|
||||||
|
void initialize({
|
||||||
|
FocusNode? appBarFocusNode,
|
||||||
|
FocusNode? contentFocusNode,
|
||||||
|
FocusNode? bottomNavFocusNode,
|
||||||
|
VoidCallback? onFocusChanged,
|
||||||
|
}) {
|
||||||
|
_appBarFocusNode = appBarFocusNode;
|
||||||
|
_contentFocusNode = contentFocusNode;
|
||||||
|
_bottomNavFocusNode = bottomNavFocusNode;
|
||||||
|
_onFocusChanged = onFocusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка глобальных клавиш
|
||||||
|
KeyEventResult handleGlobalKey(KeyEvent event) {
|
||||||
|
if (event is KeyDownEvent) {
|
||||||
|
switch (event.logicalKey) {
|
||||||
|
case LogicalKeyboardKey.escape:
|
||||||
|
case LogicalKeyboardKey.goBack:
|
||||||
|
_focusAppBar();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
|
||||||
|
case LogicalKeyboardKey.arrowUp:
|
||||||
|
if (_currentFocusArea == FocusArea.appBar) {
|
||||||
|
_focusContent();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LogicalKeyboardKey.arrowDown:
|
||||||
|
if (_currentFocusArea == FocusArea.content) {
|
||||||
|
_focusBottomNav();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
} else if (_currentFocusArea == FocusArea.appBar) {
|
||||||
|
_focusContent();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _focusAppBar() {
|
||||||
|
if (_appBarFocusNode != null) {
|
||||||
|
_currentFocusArea = FocusArea.appBar;
|
||||||
|
_appBarFocusNode!.requestFocus();
|
||||||
|
_onFocusChanged?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _focusContent() {
|
||||||
|
if (_contentFocusNode != null) {
|
||||||
|
_currentFocusArea = FocusArea.content;
|
||||||
|
_contentFocusNode!.requestFocus();
|
||||||
|
_onFocusChanged?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _focusBottomNav() {
|
||||||
|
if (_bottomNavFocusNode != null) {
|
||||||
|
_currentFocusArea = FocusArea.bottomNav;
|
||||||
|
_bottomNavFocusNode!.requestFocus();
|
||||||
|
_onFocusChanged?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Установить фокус на контент (для использования извне)
|
||||||
|
void focusContent() => _focusContent();
|
||||||
|
|
||||||
|
/// Установить фокус на навбар (для использования извне)
|
||||||
|
void focusAppBar() => _focusAppBar();
|
||||||
|
|
||||||
|
/// Получить текущую область фокуса
|
||||||
|
FocusArea get currentFocusArea => _currentFocusArea;
|
||||||
|
|
||||||
|
/// Проверить, находится ли фокус в контенте
|
||||||
|
bool get isContentFocused => _currentFocusArea == FocusArea.content;
|
||||||
|
|
||||||
|
/// Проверить, находится ли фокус в навбаре
|
||||||
|
bool get isAppBarFocused => _currentFocusArea == FocusArea.appBar;
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_appBarFocusNode = null;
|
||||||
|
_contentFocusNode = null;
|
||||||
|
_bottomNavFocusNode = null;
|
||||||
|
_onFocusChanged = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Области фокуса в приложении
|
||||||
|
enum FocusArea {
|
||||||
|
appBar,
|
||||||
|
content,
|
||||||
|
bottomNav,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Виджет-обертка для глобального управления фокусом
|
||||||
|
class GlobalFocusWrapper extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final FocusNode? contentFocusNode;
|
||||||
|
|
||||||
|
const GlobalFocusWrapper({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.contentFocusNode,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GlobalFocusWrapper> createState() => _GlobalFocusWrapperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GlobalFocusWrapperState extends State<GlobalFocusWrapper> {
|
||||||
|
final GlobalFocusManager _focusManager = GlobalFocusManager();
|
||||||
|
late final FocusNode _wrapperFocusNode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_wrapperFocusNode = FocusNode();
|
||||||
|
|
||||||
|
// Инициализируем глобальный менеджер фокуса
|
||||||
|
_focusManager.initialize(
|
||||||
|
contentFocusNode: widget.contentFocusNode ?? _wrapperFocusNode,
|
||||||
|
onFocusChanged: () => setState(() {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_wrapperFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Focus(
|
||||||
|
focusNode: _wrapperFocusNode,
|
||||||
|
onKeyEvent: (node, event) => _focusManager.handleGlobalKey(event),
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,8 @@ endif()
|
|||||||
# of modifying this function.
|
# of modifying this function.
|
||||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
# Allow all warnings as errors except the deprecated literal operator warning used in nlohmann::json
|
||||||
|
target_compile_options(${TARGET} PRIVATE -Wall -Werror -Wno-deprecated-literal-operator)
|
||||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||||
endfunction()
|
endfunction()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import path_provider_foundation
|
|||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
import video_player_avfoundation
|
||||||
import wakelock_plus
|
import wakelock_plus
|
||||||
import webview_flutter_wkwebview
|
import webview_flutter_wkwebview
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
396
pubspec.lock
396
pubspec.lock
@@ -5,18 +5,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "85.0.0"
|
version: "67.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: de617bfdc64f3d8b00835ec2957441ceca0a29cdf7881f7ab231bc14f71159c0
|
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.5.6"
|
version: "6.4.1"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,6 +41,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
version: "2.13.0"
|
||||||
|
auto_route:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: auto_route
|
||||||
|
sha256: a9001a90539ca3effc168f7e1029a5885c7326b9032c09ac895e303c1d137704
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.3.0"
|
||||||
|
auto_route_generator:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: auto_route_generator
|
||||||
|
sha256: a21d7a936c917488653c972f62d884d8adcf8c5d37acc7cd24da33cf784546c0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.0"
|
||||||
|
bloc:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bloc
|
||||||
|
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.4"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -53,10 +77,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
|
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.4"
|
version: "2.4.1"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -77,26 +101,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_resolvers
|
name: build_resolvers
|
||||||
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
|
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.4"
|
version: "2.4.2"
|
||||||
build_runner:
|
build_runner:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
|
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.4"
|
version: "2.4.13"
|
||||||
build_runner_core:
|
build_runner_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_runner_core
|
name: build_runner_core
|
||||||
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
|
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.1.2"
|
version: "7.3.2"
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -109,18 +133,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: built_value
|
name: built_value
|
||||||
sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27"
|
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.10.1"
|
version: "8.12.0"
|
||||||
cached_network_image:
|
cached_network_image:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: cached_network_image
|
name: cached_network_image
|
||||||
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
version: "3.4.0"
|
||||||
cached_network_image_platform_interface:
|
cached_network_image_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -133,10 +157,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: cached_network_image_web
|
name: cached_network_image_web
|
||||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.1"
|
version: "1.3.0"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -153,6 +177,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.4"
|
||||||
|
chewie:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: chewie
|
||||||
|
sha256: "44bcfc5f0dfd1de290c87c9d86a61308b3282a70b63435d5557cfd60f54a69ca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.13.0"
|
||||||
cli_util:
|
cli_util:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -173,10 +205,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_builder
|
name: code_builder
|
||||||
sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e"
|
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.10.1"
|
version: "4.11.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -201,6 +233,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.6"
|
version: "3.0.6"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -213,10 +253,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af"
|
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "2.3.6"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -225,6 +265,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.11"
|
||||||
|
dio:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.0"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
dynamic_color:
|
dynamic_color:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -278,6 +334,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_bloc:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_bloc
|
||||||
|
sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.6"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -368,6 +432,22 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
freezed:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: freezed
|
||||||
|
sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.2"
|
||||||
|
freezed_annotation:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: freezed_annotation
|
||||||
|
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.4"
|
||||||
frontend_server_client:
|
frontend_server_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -388,10 +468,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: google_fonts
|
name: google_fonts
|
||||||
sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82
|
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.2.1"
|
version: "6.3.2"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -416,14 +496,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
hive_generator:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: hive_generator
|
||||||
|
sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
|
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.5.0"
|
||||||
|
http_mock_adapter:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: http_mock_adapter
|
||||||
|
sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.1"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -473,37 +577,45 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.7"
|
version: "0.6.7"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.0"
|
version: "4.9.0"
|
||||||
|
json_serializable:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: json_serializable
|
||||||
|
sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.8.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.9"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -512,6 +624,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.1"
|
version: "5.1.1"
|
||||||
|
logger:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logger
|
||||||
|
sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -580,18 +700,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191"
|
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.3.0"
|
version: "9.0.0"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: package_info_plus_platform_interface
|
name: package_info_plus_platform_interface
|
||||||
sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c"
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -601,7 +721,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
@@ -612,18 +732,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
|
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.17"
|
version: "2.2.18"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
|
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -648,14 +768,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.4.0"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.1.0"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.4.7"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3+5"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.3.0"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: petitparser
|
name: petitparser
|
||||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "7.0.1"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -676,10 +844,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pool
|
name: pool
|
||||||
sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
|
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.1"
|
version: "1.5.2"
|
||||||
posix:
|
posix:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -692,10 +860,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: provider
|
name: provider
|
||||||
sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84"
|
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5"
|
version: "6.1.5+1"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -732,10 +900,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
|
sha256: "0b0f98d535319cb5cdd4f65783c2a54ee6d417a2f093dbb18be3e36e4c3d181f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.10"
|
version: "2.4.14"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -788,15 +956,31 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shelf_web_socket
|
name: shelf_web_socket
|
||||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "2.0.1"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
source_gen:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_gen
|
||||||
|
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.0"
|
||||||
|
source_helper:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_helper
|
||||||
|
sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.5"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -825,18 +1009,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_android
|
name: sqflite_android
|
||||||
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
|
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2+2"
|
||||||
sqflite_common:
|
sqflite_common:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_common
|
name: sqflite_common
|
||||||
sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b"
|
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.5"
|
version: "2.5.6"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -905,10 +1089,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.6"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -937,18 +1121,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
|
sha256: c0fb544b9ac7efa10254efaf00a951615c362d1ea1877472f8f6c0fa00fcf15b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.16"
|
version: "6.3.23"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_ios
|
name: url_launcher_ios
|
||||||
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
|
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.3"
|
version: "6.3.4"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -961,10 +1145,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_macos
|
name: url_launcher_macos
|
||||||
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
|
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.2"
|
version: "3.2.3"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1001,50 +1185,90 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
|
video_player:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: video_player
|
||||||
|
sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.10.0"
|
||||||
|
video_player_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_android
|
||||||
|
sha256: "6cfe0b1e102522eda1e139b82bf00602181c5844fd2885340f595fb213d74842"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.8.14"
|
||||||
|
video_player_avfoundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_avfoundation
|
||||||
|
sha256: f9a780aac57802b2892f93787e5ea53b5f43cc57dc107bee9436458365be71cd
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.8.4"
|
||||||
|
video_player_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_platform_interface
|
||||||
|
sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.4.0"
|
||||||
|
video_player_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_web
|
||||||
|
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.0"
|
version: "15.0.2"
|
||||||
wakelock_plus:
|
wakelock_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: wakelock_plus
|
name: wakelock_plus
|
||||||
sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678
|
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.2"
|
version: "1.4.0"
|
||||||
wakelock_plus_platform_interface:
|
wakelock_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: wakelock_plus_platform_interface
|
name: wakelock_plus_platform_interface
|
||||||
sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207
|
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.3"
|
version: "1.3.0"
|
||||||
watcher:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: watcher
|
name: watcher
|
||||||
sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
|
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.4"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "0.5.1"
|
||||||
web_socket:
|
web_socket:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1073,26 +1297,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_android
|
name: webview_flutter_android
|
||||||
sha256: f6e6afef6e234801da77170f7a1847ded8450778caf2fe13979d140484be3678
|
sha256: "21507ea5a326ceeba4d29dea19e37d92d53d9959cfc746317b9f9f7a57418d87"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.7.0"
|
version: "4.10.3"
|
||||||
webview_flutter_platform_interface:
|
webview_flutter_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_platform_interface
|
name: webview_flutter_platform_interface
|
||||||
sha256: f0dc2dc3a2b1e3a6abdd6801b9355ebfeb3b8f6cde6b9dc7c9235909c4a1f147
|
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.14.0"
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_wkwebview
|
name: webview_flutter_wkwebview
|
||||||
sha256: a3d461fe3467014e05f3ac4962e5fdde2a4bf44c561cb53e9ae5c586600fdbc3
|
sha256: fea63576b3b7e02b2df8b78ba92b48ed66caec2bb041e9a0b1cbd586d5d80bfd
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.22.0"
|
version: "3.23.1"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1113,10 +1337,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.5.0"
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1126,5 +1350,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.1 <4.0.0"
|
dart: ">=3.9.0 <4.0.0"
|
||||||
flutter: ">=3.27.0"
|
flutter: ">=3.35.0"
|
||||||
|
|||||||
18
pubspec.yaml
18
pubspec.yaml
@@ -34,6 +34,9 @@ dependencies:
|
|||||||
# Core
|
# Core
|
||||||
http: ^1.2.1
|
http: ^1.2.1
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
|
flutter_bloc: ^8.1.3
|
||||||
|
freezed_annotation: ^2.4.1
|
||||||
|
json_annotation: ^4.9.0
|
||||||
flutter_dotenv: ^5.1.0
|
flutter_dotenv: ^5.1.0
|
||||||
# UI & Theming
|
# UI & Theming
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.2
|
||||||
@@ -49,16 +52,29 @@ dependencies:
|
|||||||
# Video Player (WebView only)
|
# Video Player (WebView only)
|
||||||
webview_flutter: ^4.7.0
|
webview_flutter: ^4.7.0
|
||||||
wakelock_plus: ^1.2.1
|
wakelock_plus: ^1.2.1
|
||||||
|
# Video Player with native controls
|
||||||
|
video_player: ^2.9.2
|
||||||
|
chewie: ^1.8.5
|
||||||
# Utils
|
# Utils
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
|
auto_route: ^8.3.0
|
||||||
|
# File operations and path management
|
||||||
|
path_provider: ^2.1.4
|
||||||
|
permission_handler: ^11.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
freezed: ^2.4.5
|
||||||
|
json_serializable: ^6.7.1
|
||||||
|
hive_generator: ^2.0.1
|
||||||
|
auto_route_generator: ^8.1.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^5.0.0
|
flutter_lints: ^5.0.0
|
||||||
build_runner: ^2.5.4
|
build_runner: ^2.4.13
|
||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.13.1
|
||||||
|
# HTTP mocking for testing
|
||||||
|
http_mock_adapter: ^0.6.1
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
|
|||||||
83
test/integration/ci_environment_test.dart
Normal file
83
test/integration/ci_environment_test.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CI Environment Tests', () {
|
||||||
|
test('should detect GitHub Actions environment', () {
|
||||||
|
final isGitHubActions = Platform.environment['GITHUB_ACTIONS'] == 'true';
|
||||||
|
final isCI = Platform.environment['CI'] == 'true';
|
||||||
|
final runnerOS = Platform.environment['RUNNER_OS'];
|
||||||
|
|
||||||
|
print('Environment Variables:');
|
||||||
|
print(' GITHUB_ACTIONS: ${Platform.environment['GITHUB_ACTIONS']}');
|
||||||
|
print(' CI: ${Platform.environment['CI']}');
|
||||||
|
print(' RUNNER_OS: $runnerOS');
|
||||||
|
print(' Platform: ${Platform.operatingSystem}');
|
||||||
|
|
||||||
|
if (isGitHubActions || isCI) {
|
||||||
|
print('Running in CI/GitHub Actions environment');
|
||||||
|
expect(isCI, isTrue, reason: 'CI environment variable should be set');
|
||||||
|
|
||||||
|
if (isGitHubActions) {
|
||||||
|
expect(runnerOS, isNotNull, reason: 'RUNNER_OS should be set in GitHub Actions');
|
||||||
|
print(' GitHub Actions Runner OS: $runnerOS');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('Running in local development environment');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test should always pass regardless of environment
|
||||||
|
expect(Platform.operatingSystem, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have correct Dart/Flutter environment in CI', () {
|
||||||
|
final dartVersion = Platform.version;
|
||||||
|
print('Dart version: $dartVersion');
|
||||||
|
|
||||||
|
// In CI, we should have Dart available
|
||||||
|
expect(dartVersion, isNotEmpty);
|
||||||
|
expect(dartVersion, contains('Dart'));
|
||||||
|
|
||||||
|
// Check if running in CI and validate expected environment
|
||||||
|
final isCI = Platform.environment['CI'] == 'true';
|
||||||
|
if (isCI) {
|
||||||
|
print('Dart environment validated in CI');
|
||||||
|
|
||||||
|
// CI should have these basic characteristics
|
||||||
|
expect(Platform.operatingSystem, anyOf('linux', 'macos', 'windows'));
|
||||||
|
|
||||||
|
// GitHub Actions typically runs on Linux
|
||||||
|
final runnerOS = Platform.environment['RUNNER_OS'];
|
||||||
|
if (runnerOS == 'Linux') {
|
||||||
|
expect(Platform.operatingSystem, 'linux');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle network connectivity gracefully', () async {
|
||||||
|
// Simple network test that won't fail in restricted environments
|
||||||
|
try {
|
||||||
|
// Test with a reliable endpoint
|
||||||
|
final socket = await Socket.connect('8.8.8.8', 53, timeout: const Duration(seconds: 5));
|
||||||
|
socket.destroy();
|
||||||
|
print('Network connectivity available');
|
||||||
|
} catch (e) {
|
||||||
|
print('Limited network connectivity: $e');
|
||||||
|
// Don't fail the test - some CI environments have restricted network
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test should always pass
|
||||||
|
expect(true, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate test infrastructure', () {
|
||||||
|
// Basic test framework validation
|
||||||
|
expect(testWidgets, isNotNull, reason: 'Flutter test framework should be available');
|
||||||
|
expect(setUp, isNotNull, reason: 'Test setup functions should be available');
|
||||||
|
expect(tearDown, isNotNull, reason: 'Test teardown functions should be available');
|
||||||
|
|
||||||
|
print('Test infrastructure validated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
196
test/models/torrent_info_test.dart
Normal file
196
test/models/torrent_info_test.dart
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:neomovies_mobile/data/models/torrent_info.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TorrentInfo', () {
|
||||||
|
test('fromAndroidJson creates valid TorrentInfo', () {
|
||||||
|
final json = {
|
||||||
|
'infoHash': 'test_hash',
|
||||||
|
'name': 'Test Torrent',
|
||||||
|
'totalSize': 1024000000,
|
||||||
|
'progress': 0.5,
|
||||||
|
'downloadSpeed': 1024000,
|
||||||
|
'uploadSpeed': 512000,
|
||||||
|
'numSeeds': 10,
|
||||||
|
'numPeers': 5,
|
||||||
|
'state': 'DOWNLOADING',
|
||||||
|
'savePath': '/test/path',
|
||||||
|
'files': [
|
||||||
|
{
|
||||||
|
'path': 'test.mp4',
|
||||||
|
'size': 1024000000,
|
||||||
|
'priority': 4,
|
||||||
|
'progress': 0.5,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'pieceLength': 16384,
|
||||||
|
'numPieces': 62500,
|
||||||
|
'addedTime': 1640995200000,
|
||||||
|
};
|
||||||
|
|
||||||
|
final torrentInfo = TorrentInfo.fromAndroidJson(json);
|
||||||
|
|
||||||
|
expect(torrentInfo.infoHash, equals('test_hash'));
|
||||||
|
expect(torrentInfo.name, equals('Test Torrent'));
|
||||||
|
expect(torrentInfo.totalSize, equals(1024000000));
|
||||||
|
expect(torrentInfo.progress, equals(0.5));
|
||||||
|
expect(torrentInfo.downloadSpeed, equals(1024000));
|
||||||
|
expect(torrentInfo.uploadSpeed, equals(512000));
|
||||||
|
expect(torrentInfo.numSeeds, equals(10));
|
||||||
|
expect(torrentInfo.numPeers, equals(5));
|
||||||
|
expect(torrentInfo.state, equals('DOWNLOADING'));
|
||||||
|
expect(torrentInfo.savePath, equals('/test/path'));
|
||||||
|
expect(torrentInfo.files.length, equals(1));
|
||||||
|
expect(torrentInfo.files.first.path, equals('test.mp4'));
|
||||||
|
expect(torrentInfo.files.first.size, equals(1024000000));
|
||||||
|
expect(torrentInfo.files.first.priority, equals(FilePriority.NORMAL));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDownloading returns true for DOWNLOADING state', () {
|
||||||
|
final torrent = TorrentInfo(
|
||||||
|
infoHash: 'test',
|
||||||
|
name: 'test',
|
||||||
|
totalSize: 100,
|
||||||
|
progress: 0.5,
|
||||||
|
downloadSpeed: 1000,
|
||||||
|
uploadSpeed: 500,
|
||||||
|
numSeeds: 5,
|
||||||
|
numPeers: 3,
|
||||||
|
state: 'DOWNLOADING',
|
||||||
|
savePath: '/test',
|
||||||
|
files: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(torrent.isDownloading, isTrue);
|
||||||
|
expect(torrent.isPaused, isFalse);
|
||||||
|
expect(torrent.isSeeding, isFalse);
|
||||||
|
expect(torrent.isCompleted, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCompleted returns true for progress >= 1.0', () {
|
||||||
|
final torrent = TorrentInfo(
|
||||||
|
infoHash: 'test',
|
||||||
|
name: 'test',
|
||||||
|
totalSize: 100,
|
||||||
|
progress: 1.0,
|
||||||
|
downloadSpeed: 0,
|
||||||
|
uploadSpeed: 500,
|
||||||
|
numSeeds: 5,
|
||||||
|
numPeers: 3,
|
||||||
|
state: 'SEEDING',
|
||||||
|
savePath: '/test',
|
||||||
|
files: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(torrent.isCompleted, isTrue);
|
||||||
|
expect(torrent.isSeeding, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('videoFiles returns only video files', () {
|
||||||
|
final torrent = TorrentInfo(
|
||||||
|
infoHash: 'test',
|
||||||
|
name: 'test',
|
||||||
|
totalSize: 100,
|
||||||
|
progress: 1.0,
|
||||||
|
downloadSpeed: 0,
|
||||||
|
uploadSpeed: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
numPeers: 0,
|
||||||
|
state: 'COMPLETED',
|
||||||
|
savePath: '/test',
|
||||||
|
files: [
|
||||||
|
TorrentFileInfo(
|
||||||
|
path: 'movie.mp4',
|
||||||
|
size: 1000000,
|
||||||
|
priority: FilePriority.NORMAL,
|
||||||
|
),
|
||||||
|
TorrentFileInfo(
|
||||||
|
path: 'subtitle.srt',
|
||||||
|
size: 10000,
|
||||||
|
priority: FilePriority.NORMAL,
|
||||||
|
),
|
||||||
|
TorrentFileInfo(
|
||||||
|
path: 'episode.mkv',
|
||||||
|
size: 2000000,
|
||||||
|
priority: FilePriority.NORMAL,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final videoFiles = torrent.videoFiles;
|
||||||
|
expect(videoFiles.length, equals(2));
|
||||||
|
expect(videoFiles.any((file) => file.path == 'movie.mp4'), isTrue);
|
||||||
|
expect(videoFiles.any((file) => file.path == 'episode.mkv'), isTrue);
|
||||||
|
expect(videoFiles.any((file) => file.path == 'subtitle.srt'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mainVideoFile returns largest video file', () {
|
||||||
|
final torrent = TorrentInfo(
|
||||||
|
infoHash: 'test',
|
||||||
|
name: 'test',
|
||||||
|
totalSize: 100,
|
||||||
|
progress: 1.0,
|
||||||
|
downloadSpeed: 0,
|
||||||
|
uploadSpeed: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
numPeers: 0,
|
||||||
|
state: 'COMPLETED',
|
||||||
|
savePath: '/test',
|
||||||
|
files: [
|
||||||
|
TorrentFileInfo(
|
||||||
|
path: 'small.mp4',
|
||||||
|
size: 1000000,
|
||||||
|
priority: FilePriority.NORMAL,
|
||||||
|
),
|
||||||
|
TorrentFileInfo(
|
||||||
|
path: 'large.mkv',
|
||||||
|
size: 5000000,
|
||||||
|
priority: FilePriority.NORMAL,
|
||||||
|
),
|
||||||
|
TorrentFileInfo(
|
||||||
|
path: 'medium.avi',
|
||||||
|
size: 3000000,
|
||||||
|
priority: FilePriority.NORMAL,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final mainFile = torrent.mainVideoFile;
|
||||||
|
expect(mainFile?.path, equals('large.mkv'));
|
||||||
|
expect(mainFile?.size, equals(5000000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formattedTotalSize formats bytes correctly', () {
|
||||||
|
final torrent = TorrentInfo(
|
||||||
|
infoHash: 'test',
|
||||||
|
name: 'test',
|
||||||
|
totalSize: 1073741824, // 1 GB
|
||||||
|
progress: 0.0,
|
||||||
|
downloadSpeed: 0,
|
||||||
|
uploadSpeed: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
numPeers: 0,
|
||||||
|
state: 'PAUSED',
|
||||||
|
savePath: '/test',
|
||||||
|
files: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(torrent.formattedTotalSize, equals('1.0GB'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FilePriority', () {
|
||||||
|
test('fromValue returns correct priority', () {
|
||||||
|
expect(FilePriority.fromValue(0), equals(FilePriority.DONT_DOWNLOAD));
|
||||||
|
expect(FilePriority.fromValue(4), equals(FilePriority.NORMAL));
|
||||||
|
expect(FilePriority.fromValue(7), equals(FilePriority.HIGH));
|
||||||
|
expect(FilePriority.fromValue(999), equals(FilePriority.NORMAL)); // Default
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comparison operators work correctly', () {
|
||||||
|
expect(FilePriority.HIGH > FilePriority.NORMAL, isTrue);
|
||||||
|
expect(FilePriority.NORMAL > FilePriority.DONT_DOWNLOAD, isTrue);
|
||||||
|
expect(FilePriority.DONT_DOWNLOAD < FilePriority.HIGH, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
41
test/providers/downloads_provider_test.dart
Normal file
41
test/providers/downloads_provider_test.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:neomovies_mobile/presentation/providers/downloads_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('DownloadsProvider', () {
|
||||||
|
late DownloadsProvider provider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
provider = DownloadsProvider();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial state is correct', () {
|
||||||
|
expect(provider.torrents, isEmpty);
|
||||||
|
expect(provider.isLoading, isFalse);
|
||||||
|
expect(provider.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSpeed formats bytes correctly', () {
|
||||||
|
expect(provider.formatSpeed(1024), equals('1.0KB/s'));
|
||||||
|
expect(provider.formatSpeed(1048576), equals('1.0MB/s'));
|
||||||
|
expect(provider.formatSpeed(512), equals('512B/s'));
|
||||||
|
expect(provider.formatSpeed(2048000), equals('2.0MB/s'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatDuration formats duration correctly', () {
|
||||||
|
expect(provider.formatDuration(Duration(seconds: 30)), equals('30с'));
|
||||||
|
expect(provider.formatDuration(Duration(minutes: 2, seconds: 30)), equals('2м 30с'));
|
||||||
|
expect(provider.formatDuration(Duration(hours: 1, minutes: 30, seconds: 45)), equals('1ч 30м 45с'));
|
||||||
|
expect(provider.formatDuration(Duration(hours: 2)), equals('2ч 0м 0с'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provider implements ChangeNotifier', () {
|
||||||
|
expect(provider, isA<ChangeNotifier>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
381
test/services/player_embed_service_test.dart
Normal file
381
test/services/player_embed_service_test.dart
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:neomovies_mobile/data/services/player_embed_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('PlayerEmbedService Tests', () {
|
||||||
|
group('Vibix Player', () {
|
||||||
|
test('should get embed URL from API server successfully', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
if (request.url.path == '/api/player/vibix/embed') {
|
||||||
|
final body = jsonDecode(request.body);
|
||||||
|
expect(body['videoUrl'], 'http://example.com/video.mp4');
|
||||||
|
expect(body['title'], 'Test Movie');
|
||||||
|
expect(body['autoplay'], true);
|
||||||
|
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'embedUrl': 'https://vibix.me/embed/custom?src=encoded&autoplay=1',
|
||||||
|
'success': true,
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('Not Found', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the http client (in real implementation, you'd inject this)
|
||||||
|
final embedUrl = await _testGetVibixEmbedUrl(
|
||||||
|
client: mockClient,
|
||||||
|
videoUrl: 'http://example.com/video.mp4',
|
||||||
|
title: 'Test Movie',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(embedUrl, 'https://vibix.me/embed/custom?src=encoded&autoplay=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fallback to direct URL when server fails', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response('Server Error', 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
final embedUrl = await _testGetVibixEmbedUrl(
|
||||||
|
client: mockClient,
|
||||||
|
videoUrl: 'http://example.com/video.mp4',
|
||||||
|
title: 'Test Movie',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(embedUrl, contains('vibix.me/embed'));
|
||||||
|
expect(embedUrl, contains('src=http%3A//example.com/video.mp4'));
|
||||||
|
expect(embedUrl, contains('title=Test%20Movie'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle network timeout gracefully', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
throw const SocketException('Connection timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
final embedUrl = await _testGetVibixEmbedUrl(
|
||||||
|
client: mockClient,
|
||||||
|
videoUrl: 'http://example.com/video.mp4',
|
||||||
|
title: 'Test Movie',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should fallback to direct URL
|
||||||
|
expect(embedUrl, contains('vibix.me/embed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include optional parameters in API request', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
if (request.url.path == '/api/player/vibix/embed') {
|
||||||
|
final body = jsonDecode(request.body);
|
||||||
|
expect(body['imdbId'], 'tt1234567');
|
||||||
|
expect(body['season'], '1');
|
||||||
|
expect(body['episode'], '5');
|
||||||
|
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'embedUrl': 'https://vibix.me/embed/tv'}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('Not Found', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
final embedUrl = await _testGetVibixEmbedUrl(
|
||||||
|
client: mockClient,
|
||||||
|
videoUrl: 'http://example.com/video.mp4',
|
||||||
|
title: 'Test TV Show',
|
||||||
|
imdbId: 'tt1234567',
|
||||||
|
season: '1',
|
||||||
|
episode: '5',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(embedUrl, 'https://vibix.me/embed/tv');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Alloha Player', () {
|
||||||
|
test('should get embed URL from API server successfully', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
if (request.url.path == '/api/player/alloha/embed') {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'embedUrl': 'https://alloha.tv/embed/custom?src=encoded',
|
||||||
|
'success': true,
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('Not Found', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
final embedUrl = await _testGetAllohaEmbedUrl(
|
||||||
|
client: mockClient,
|
||||||
|
videoUrl: 'http://example.com/video.mp4',
|
||||||
|
title: 'Test Movie',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(embedUrl, 'https://alloha.tv/embed/custom?src=encoded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fallback to direct URL when server fails', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response('Server Error', 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
final embedUrl = await _testGetAllohaEmbedUrl(
|
||||||
|
client: mockClient,
|
||||||
|
videoUrl: 'http://example.com/video.mp4',
|
||||||
|
title: 'Test Movie',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(embedUrl, contains('alloha.tv/embed'));
|
||||||
|
expect(embedUrl, contains('src=http%3A//example.com/video.mp4'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Player Configuration', () {
|
||||||
|
test('should get player config from server', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
if (request.url.path == '/api/player/vibix/config') {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'playerOptions': {
|
||||||
|
'autoplay': true,
|
||||||
|
'controls': true,
|
||||||
|
'volume': 0.8,
|
||||||
|
},
|
||||||
|
'theme': 'dark',
|
||||||
|
'language': 'ru',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('Not Found', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
final config = await _testGetPlayerConfig(
|
||||||
|
client: mockClient,
|
||||||
|
playerType: 'vibix',
|
||||||
|
imdbId: 'tt1234567',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config, isNotNull);
|
||||||
|
expect(config!['playerOptions']['autoplay'], true);
|
||||||
|
expect(config['theme'], 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return null when config not available', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response('Not Found', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
final config = await _testGetPlayerConfig(
|
||||||
|
client: mockClient,
|
||||||
|
playerType: 'nonexistent',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Server Health Check', () {
|
||||||
|
test('should return true when server is available', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
if (request.url.path == '/api/player/health') {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'version': '1.0.0'}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('Not Found', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
final isAvailable = await _testIsServerApiAvailable(mockClient);
|
||||||
|
expect(isAvailable, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false when server is unavailable', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response('Server Error', 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
final isAvailable = await _testIsServerApiAvailable(mockClient);
|
||||||
|
expect(isAvailable, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false on network timeout', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
throw const SocketException('Connection timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
final isAvailable = await _testIsServerApiAvailable(mockClient);
|
||||||
|
expect(isAvailable, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('URL Encoding', () {
|
||||||
|
test('should properly encode special characters in video URL', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response('Server Error', 500); // Force fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
final embedUrl = await _testGetVibixEmbedUrl(
|
||||||
|
client: mockClient,
|
||||||
|
videoUrl: 'http://example.com/path with spaces/movie&test.mp4',
|
||||||
|
title: 'Movie Title (2023)',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(embedUrl, contains('path%20with%20spaces'));
|
||||||
|
expect(embedUrl, contains('movie%26test.mp4'));
|
||||||
|
expect(embedUrl, contains('Movie%20Title%20%282023%29'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle non-ASCII characters in title', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response('Server Error', 500); // Force fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
final embedUrl = await _testGetVibixEmbedUrl(
|
||||||
|
client: mockClient,
|
||||||
|
videoUrl: 'http://example.com/video.mp4',
|
||||||
|
title: 'Тест Фильм Россия',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(embedUrl, contains('title=%D0%A2%D0%B5%D1%81%D1%82'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions to test with mocked http client
|
||||||
|
// Note: In a real implementation, you would inject the http client
|
||||||
|
|
||||||
|
Future<String> _testGetVibixEmbedUrl({
|
||||||
|
required http.Client client,
|
||||||
|
required String videoUrl,
|
||||||
|
required String title,
|
||||||
|
String? imdbId,
|
||||||
|
String? season,
|
||||||
|
String? episode,
|
||||||
|
}) async {
|
||||||
|
// This simulates the PlayerEmbedService.getVibixEmbedUrl behavior
|
||||||
|
// In real implementation, you'd need dependency injection for the http client
|
||||||
|
try {
|
||||||
|
final response = await client.post(
|
||||||
|
Uri.parse('https://neomovies.site/api/player/vibix/embed'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'videoUrl': videoUrl,
|
||||||
|
'title': title,
|
||||||
|
'imdbId': imdbId,
|
||||||
|
'season': season,
|
||||||
|
'episode': episode,
|
||||||
|
'autoplay': true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
return data['embedUrl'] as String;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to get Vibix embed URL: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to direct URL
|
||||||
|
final encodedVideoUrl = Uri.encodeComponent(videoUrl);
|
||||||
|
final encodedTitle = Uri.encodeComponent(title);
|
||||||
|
return 'https://vibix.me/embed/?src=$encodedVideoUrl&autoplay=1&title=$encodedTitle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _testGetAllohaEmbedUrl({
|
||||||
|
required http.Client client,
|
||||||
|
required String videoUrl,
|
||||||
|
required String title,
|
||||||
|
String? imdbId,
|
||||||
|
String? season,
|
||||||
|
String? episode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await client.post(
|
||||||
|
Uri.parse('https://neomovies.site/api/player/alloha/embed'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'videoUrl': videoUrl,
|
||||||
|
'title': title,
|
||||||
|
'imdbId': imdbId,
|
||||||
|
'season': season,
|
||||||
|
'episode': episode,
|
||||||
|
'autoplay': true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
return data['embedUrl'] as String;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to get Alloha embed URL: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to direct URL
|
||||||
|
final encodedVideoUrl = Uri.encodeComponent(videoUrl);
|
||||||
|
final encodedTitle = Uri.encodeComponent(title);
|
||||||
|
return 'https://alloha.tv/embed?src=$encodedVideoUrl&autoplay=1&title=$encodedTitle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> _testGetPlayerConfig({
|
||||||
|
required http.Client client,
|
||||||
|
required String playerType,
|
||||||
|
String? imdbId,
|
||||||
|
String? season,
|
||||||
|
String? episode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await client.get(
|
||||||
|
Uri.parse('https://neomovies.site/api/player/$playerType/config').replace(
|
||||||
|
queryParameters: {
|
||||||
|
if (imdbId != null) 'imdbId': imdbId,
|
||||||
|
if (season != null) 'season': season,
|
||||||
|
if (episode != null) 'episode': episode,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _testIsServerApiAvailable(http.Client client) async {
|
||||||
|
try {
|
||||||
|
final response = await client.get(
|
||||||
|
Uri.parse('https://neomovies.site/api/player/health'),
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
test/services/torrent_platform_service_simple_test.dart
Normal file
111
test/services/torrent_platform_service_simple_test.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:neomovies_mobile/data/services/torrent_platform_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('TorrentPlatformService Tests', () {
|
||||||
|
late List<MethodCall> methodCalls;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
methodCalls = [];
|
||||||
|
|
||||||
|
// Mock the platform channel
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('com.neo.neomovies_mobile/torrent'),
|
||||||
|
(MethodCall methodCall) async {
|
||||||
|
methodCalls.add(methodCall);
|
||||||
|
return _handleMethodCall(methodCall);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('com.neo.neomovies_mobile/torrent'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addTorrent should call platform method with correct parameters', () async {
|
||||||
|
const magnetUri = 'magnet:?xt=urn:btih:test123&dn=test.movie.mkv';
|
||||||
|
const savePath = '/storage/emulated/0/Download/Torrents';
|
||||||
|
|
||||||
|
final result = await TorrentPlatformService.addTorrent(
|
||||||
|
magnetUri: magnetUri,
|
||||||
|
savePath: savePath
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(methodCalls.length, 1);
|
||||||
|
expect(methodCalls.first.method, 'addTorrent');
|
||||||
|
expect(methodCalls.first.arguments, {
|
||||||
|
'magnetUri': magnetUri,
|
||||||
|
'savePath': savePath,
|
||||||
|
});
|
||||||
|
expect(result, 'test-hash-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseMagnetBasicInfo should parse magnet URI correctly', () async {
|
||||||
|
const magnetUri = 'magnet:?xt=urn:btih:abc123&dn=test%20movie&tr=http%3A//tracker.example.com%3A8080/announce';
|
||||||
|
|
||||||
|
final result = await TorrentPlatformService.parseMagnetBasicInfo(magnetUri);
|
||||||
|
|
||||||
|
expect(result.name, 'test movie');
|
||||||
|
expect(result.infoHash, 'abc123');
|
||||||
|
expect(result.trackers.length, 1);
|
||||||
|
expect(result.trackers.first, 'http://tracker.example.com:8080/announce');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mock method call handler for torrent platform channel
|
||||||
|
dynamic _handleMethodCall(MethodCall methodCall) {
|
||||||
|
switch (methodCall.method) {
|
||||||
|
case 'addTorrent':
|
||||||
|
return 'test-hash-123';
|
||||||
|
|
||||||
|
case 'getTorrents':
|
||||||
|
return jsonEncode([
|
||||||
|
{
|
||||||
|
'infoHash': 'test-hash-123',
|
||||||
|
'progress': 0.5,
|
||||||
|
'downloadSpeed': 1024000,
|
||||||
|
'uploadSpeed': 512000,
|
||||||
|
'numSeeds': 5,
|
||||||
|
'numPeers': 10,
|
||||||
|
'state': 'downloading',
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
case 'getTorrent':
|
||||||
|
return jsonEncode({
|
||||||
|
'name': 'Test Movie',
|
||||||
|
'infoHash': 'test-hash-123',
|
||||||
|
'totalSize': 1073741824,
|
||||||
|
'files': [
|
||||||
|
{
|
||||||
|
'path': 'Test Movie.mkv',
|
||||||
|
'size': 1073741824,
|
||||||
|
'priority': 4,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'downloadedSize': 536870912,
|
||||||
|
'downloadSpeed': 1024000,
|
||||||
|
'uploadSpeed': 512000,
|
||||||
|
'state': 'downloading',
|
||||||
|
'progress': 0.5,
|
||||||
|
'numSeeds': 5,
|
||||||
|
'numPeers': 10,
|
||||||
|
'addedTime': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'ratio': 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
test/widget_test.dart
Normal file
79
test/widget_test.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('App smoke test', (WidgetTester tester) async {
|
||||||
|
// Build a minimal app for testing
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('NeoMovies Test'),
|
||||||
|
),
|
||||||
|
body: const Center(
|
||||||
|
child: Text('Hello World'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that our app displays basic elements
|
||||||
|
expect(find.text('NeoMovies Test'), findsOneWidget);
|
||||||
|
expect(find.text('Hello World'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Download progress indicator test', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LinearProgressIndicator(value: 0.5),
|
||||||
|
Text('50%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify progress indicator and text
|
||||||
|
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||||
|
expect(find.text('50%'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('List tile with popup menu test', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: ListTile(
|
||||||
|
title: const Text('Test Torrent'),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'pause',
|
||||||
|
child: Text('Pause'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify list tile
|
||||||
|
expect(find.text('Test Torrent'), findsOneWidget);
|
||||||
|
expect(find.byType(PopupMenuButton<String>), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap the popup menu button
|
||||||
|
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify menu items appear
|
||||||
|
expect(find.text('Delete'), findsOneWidget);
|
||||||
|
expect(find.text('Pause'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
|
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
dynamic_color
|
dynamic_color
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
permission_handler_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user