Compare commits

...

33 Commits

Author SHA1 Message Date
factory-droid[bot]
86611976a7 Fix API auth flow and poster URLs
- Fix authorization issues by improving error handling for unverified accounts
- Enable auto-login after successful email verification
- Fix poster fetching to use NeoMovies API instead of TMDB directly
- Add missing video player models (VideoQuality, AudioTrack, Subtitle, PlayerSettings)
- Add video_player and chewie dependencies for native video playback
- Update Movie model to use API images endpoint for better CDN control

Resolves authentication and image loading issues.
2025-10-03 06:00:37 +00:00
root
e70c477238 fix auto mirror 2025-10-03 05:26:42 +00:00
root
7b8f64842a add auto mirror from gh to gl 2025-10-03 05:20:54 +00:00
root
b167c73699 ed readme 2025-10-03 04:12:18 +00:00
root
23a3068b37 ed readme 2025-10-03 04:07:50 +00:00
factory-droid[bot]
fd296d800f fix api bugs 2025-10-02 21:40:20 +00:00
root
c30b1b2464 fix v2 github actions 2025-10-02 21:10:39 +00:00
root
13de6a5417 add github actions deploy 2025-10-02 20:21:50 +00:00
factory-droid[bot]
7201d2e7dc v0.0.3 2025-10-02 19:54:32 +00:00
factory-droid[bot]
2ba77aee3a fix 2025-10-02 18:47:08 +00:00
factory-droid[bot]
ca409fabdd better 2025-10-02 18:28:52 +00:00
root
90113d80b0 better 2025-10-02 18:05:23 +00:00
root
1e4b2f00ba Fix 2025-10-02 17:49:43 +00:00
root
82850b4556 fix gitlab ci 2025-10-02 17:43:34 +00:00
root
a48f947d65 better 2025-10-02 17:15:01 +00:00
root
545b5e0d68 v0.0.2 2025-10-02 17:09:36 +00:00
54a533f267 Merge branch 'feature/torrent-engine-integration' into 'main'
fix(build): resolve Gradle and manifest issues for TorrentEngine

See merge request foxixus/neomovies_mobile!2
2025-10-02 14:30:46 +00:00
factory-droid[bot]
e4e56d76af Add automatic GitLab Releases with versioning
- Build release APKs for all branches (dev, main, feature/*, tags)
- Auto-create GitLab Releases with version v0.0.{PIPELINE_ID}
- Support semantic versioning via git tags (e.g., v0.0.3)
- Include all APK variants (arm64, arm32, x86_64) and torrentengine AAR
- Release triggers automatically on dev/main branches after successful build
- Full release description with commit info and download links
- Artifacts expire in 90 days for releases, 30 days for builds
- Use GitLab Release API with fallback for updates
2025-10-02 14:17:17 +00:00
factory-droid[bot]
4306a9038a Simplify GitLab CI/CD configuration
- Removed complex before_script logic and manual Flutter installation
- Use ghcr.io/cirruslabs/flutter:stable image for Flutter builds
- Simplified job rules using modern GitLab syntax
- Increased JVM heap to 2048m for better performance
- Removed manual local.properties creation (handled by Gradle)
- Cleaner artifact naming and job structure
- Kept all essential jobs: torrent-engine, apk builds, tests, deploy
2025-10-02 14:01:32 +00:00
factory-droid[bot]
275c8122a2 Complete LibTorrent4j 2.1.x API migration - Full refactor
- Migrated from deprecated SessionManager API to SessionParams
- Replaced popAlerts() polling with AlertListener callbacks
- Fixed Priority mapping (IGNORE, LOW, DEFAULT, TOP_PRIORITY)
- Updated AddTorrentParams to use async_add_torrent via swig
- Converted properties (.message, .best) from method calls
- Fixed when/if expression exhaustiveness for Kotlin strictness
- Added explicit Unit returns for control flow clarity

BUILD SUCCESSFUL: TorrentEngine AAR compiles cleanly
2025-10-02 13:31:21 +00:00
factory-droid[bot]
2f191dd302 fix(build): resolve Gradle and manifest issues for TorrentEngine
- Remove deprecated android.enableBuildCache from gradle.properties
- Downgrade Kotlin from 2.1.0 to 1.9.24 for Room compatibility
- Add tools namespace to AndroidManifest.xml
- Restore LibTorrent4j to 2.1.0-28 (verified available version)

Known issue: TorrentEngine.kt needs API updates for LibTorrent4j 2.1.x
See compilation errors related to SessionParams, popAlerts, TorrentInfo constructor
2025-10-02 12:27:20 +00:00
143a5cf8a5 Merge branch 'feature/torrent-engine-integration' into 'main'
better

See merge request foxixus/neomovies_mobile!1
2025-10-02 12:18:39 +00:00
factory-droid[bot]
18295e1bc4 fix(ci): create local.properties file before Gradle builds
- Add before_script to create local.properties dynamically
- Set flutter.sdk from FLUTTER_ROOT environment variable
- Set sdk.dir from ANDROID_SDK_ROOT environment variable
- Add ANDROID_HOME as fallback for SDK location
- Auto-detect Android SDK path in CI
- Fixes: Flutter plugin loader requiring local.properties
2025-10-02 12:08:39 +00:00
factory-droid[bot]
ab91ce7e46 fix(ci): handle missing local.properties in CI environment
- Check if local.properties exists before reading
- Fallback to FLUTTER_ROOT environment variable
- Add FLUTTER_ROOT to CI variables
- Set default Flutter path to /opt/flutter for CI
- Fixes: 'local.properties (No such file or directory)' error
2025-10-02 11:58:59 +00:00
factory-droid[bot]
5040ee731a fix(ci): add Gradle wrapper files for CI/CD
- Remove gradlew and gradlew.bat from .gitignore
- Remove gradle-wrapper.jar from .gitignore
- Add all Gradle wrapper files to repository
- Required for GitLab CI/CD automated builds
2025-10-02 11:37:23 +00:00
factory-droid[bot]
db192b3c76 ci: configure for GitLab Instance Runners
- Use saas-linux-medium-amd64 tag for TorrentEngine build (4GB RAM, 2 cores)
- Update documentation with Instance Runner setup guide
- Add comparison table for different runner sizes
- Keep docker tag for other jobs as fallback
2025-10-02 11:26:05 +00:00
factory-droid[bot]
83842efb68 ci: optimize RAM usage and add CI/CD pipelines
- Reduce Gradle RAM from 4GB to 2GB with optimizations
- Add GitLab CI/CD with separate jobs for TorrentEngine and APK
- Add GitHub Actions workflow as alternative
- Enable parallel builds and caching
- Configure automated artifact uploads
- Add comprehensive CI/CD documentation
2025-10-02 11:14:54 +00:00
factory-droid[bot]
81bbaa62e2 docs: Add Merge Request description 2025-10-02 10:57:59 +00:00
factory-droid[bot]
1b28c5da45 feat: Add TorrentEngine library and new API client
- Created complete TorrentEngine library module with LibTorrent4j
  - Full torrent management (add, pause, resume, remove)
  - Magnet link metadata extraction
  - File priority management (even during download)
  - Foreground service with persistent notification
  - Room database for state persistence
  - Reactive Flow API for UI updates

- Integrated TorrentEngine with MainActivity via MethodChannel
  - addTorrent, getTorrents, pauseTorrent, resumeTorrent, removeTorrent
  - setFilePriority for dynamic file selection
  - Full JSON serialization for Flutter communication

- Created new NeoMoviesApiClient for Go-based backend
  - Email verification flow (register, verify, resendCode)
  - Google OAuth support
  - Torrent search via RedAPI
  - Multiple player support (Alloha, Lumex, Vibix)
  - Enhanced reactions system (likes/dislikes)
  - All movies/TV shows endpoints

- Updated dependencies and build configuration
  - Java 17 compatibility
  - Updated Kotlin coroutines to 1.9.0
  - Fixed build_runner version conflict
  - Added torrentengine module to settings.gradle.kts

- Added comprehensive documentation
  - TorrentEngine README with usage examples
  - DEVELOPMENT_SUMMARY with full implementation details
  - ProGuard rules for library

This is a complete rewrite of torrent functionality as a reusable library.
2025-10-02 10:56:22 +00:00
6a8e226a72 torrent metadata extractor finally work 2025-08-05 13:49:09 +03:00
f4b497fb3f Рецепт плова:
1. Обжариваем лук до золотистого цвета.
2. Добавляем морковь — жарим до мягкости.
3. Всыпаем нарезанное мясо, жарим до румяной корочки.
4. Добавляем специи: зиру, барбарис, соль.
5. Засыпаем промытый рис, сверху — головка чеснока.
6. Заливаем кипятком на 1-2 см выше риса.
7. Готовим под крышкой на слабом огне до испарения воды.
2025-08-03 18:24:12 +03:00
de26fd3fc9 torrent downloads 2025-07-19 20:50:26 +03:00
4ea75db105 add torrent api(magnet links) 2025-07-19 18:13:13 +03:00
69 changed files with 7506 additions and 1279 deletions

2
.env
View File

@@ -1 +1 @@
API_URL=https://neomovies-api.vercel.app
API_URL=https://api.neomovies.ru

View File

@@ -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/**

76
.github/workflows/gitlab-mirror.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
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: |
# Если нет ветки main на GitLab, пушим всегда
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 }}\"
}"

211
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,211 @@
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: 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
- 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/') }}
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: 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 "" >> $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

136
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,136 @@
name: Test and Analyze
on:
push:
branches:
- main
- dev
- 'feature/**'
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.35.5'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Run Flutter Analyze
run: flutter analyze
- 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: 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

View File

@@ -1,133 +1,220 @@
stages:
- test
- build
- deploy
variables:
FLUTTER_VERSION: "3.16.0"
ANDROID_SDK_VERSION: "34"
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
FLUTTER_VERSION: "stable"
# Кэш для оптимизации сборки
cache:
key: flutter-cache
paths:
- .pub-cache/
- android/.gradle/
- build/
# Тестирование
test:
stage: test
image: cirrusci/flutter:${FLUTTER_VERSION}
before_script:
- flutter --version
- flutter pub get
script:
- flutter analyze
- flutter test
artifacts:
reports:
junit: test-results.xml
paths:
- coverage/
expire_in: 1 week
# Сборка Android APK
build_android:
build:apk:arm64:
stage: build
image: cirrusci/flutter:${FLUTTER_VERSION}
before_script:
- flutter --version
- flutter pub get
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
script:
- flutter build apk --release
- flutter build appbundle --release
- flutter pub get
- flutter build apk --release --target-platform android-arm64 --split-per-abi
artifacts:
paths:
- build/app/outputs/flutter-apk/app-release.apk
- build/app/outputs/bundle/release/app-release.aab
expire_in: 1 month
- build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
expire_in: 30 days
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_linux:
build:apk:arm:
stage: build
image: ubuntu:22.04
before_script:
- apt-get update -y
- 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
- apt-get install -y libblkid-dev liblzma-dev
# Установка Flutter
- git clone https://github.com/flutter/flutter.git -b stable --depth 1
- export PATH="$PATH:`pwd`/flutter/bin"
- flutter --version
- flutter config --enable-linux-desktop
- flutter pub get
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
script:
- flutter build linux --release
- flutter pub get
- flutter build apk --release --target-platform android-arm --split-per-abi
artifacts:
paths:
- build/linux/x64/release/bundle/
expire_in: 1 month
- build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
expire_in: 30 days
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 (опционально)
deploy_android:
stage: deploy
image: ruby:3.0
before_script:
- gem install fastlane
build:apk:x64:
stage: build
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
script:
- cd android
- fastlane supply --aab ../build/app/outputs/bundle/release/app-release.aab
dependencies:
- build_android
- flutter pub get
- flutter build apk --release --target-platform android-x64 --split-per-abi
artifacts:
paths:
- build/app/outputs/flutter-apk/app-x86_64-release.apk
expire_in: 30 days
rules:
- if: '$CI_COMMIT_TAG'
when: manual
- if: $CI_COMMIT_TAG
when: always
- if: $CI_COMMIT_BRANCH =~ /^dev|main/
when: on_success
# Деплой Linux приложения в GitLab Package Registry
deploy_linux:
deploy:release:
stage: deploy
image: ubuntu:22.04
image: alpine:latest
needs:
- build:apk:arm64
- build:apk:arm
- build:apk:x64
before_script:
- apt-get update -y
- 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
- apk add --no-cache curl jq coreutils
script:
- |
release-cli create \
--name "Release $CI_COMMIT_TAG" \
--tag-name $CI_COMMIT_TAG \
--description "Release $CI_COMMIT_TAG" \
--assets-link "{\"name\":\"Android APK\",\"url\":\"${CI_PROJECT_URL}/-/jobs/artifacts/$CI_COMMIT_TAG/download?job=build_android\"}" \
--assets-link "{\"name\":\"Linux App\",\"url\":\"${CI_PROJECT_URL}/-/jobs/artifacts/$CI_COMMIT_TAG/download?job=build_linux\"}"
dependencies:
- build_android
- build_linux
if [ -n "$CI_COMMIT_TAG" ]; then
VERSION="$CI_COMMIT_TAG"
else
VERSION="v0.0.${CI_PIPELINE_ID}"
fi
echo "Creating GitLab Release: $VERSION"
echo "Commit: ${CI_COMMIT_SHORT_SHA}"
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:
- if: '$CI_COMMIT_TAG'
when: manual
- if: $CI_COMMIT_TAG
when: always
- if: $CI_COMMIT_BRANCH =~ /^dev|main/
when: on_success

View File

@@ -2,6 +2,8 @@
Мобильное приложение для просмотра фильмов и сериалов, созданное на Flutter.
[![Download](https://img.shields.io/github/v/release/Neo-Open-Source/neomovies-mobile?label=Download&style=for-the-badge&logo=github)](https://github.com/Neo-Open-Source/neomovies-mobile/releases/latest)
## Возможности
- 📱 Кроссплатформенное приложение (Android/iOS(пока не реализовано))

3
android/.gitignore vendored
View File

@@ -1,8 +1,5 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/

View File

@@ -6,22 +6,22 @@ plugins {
}
android {
namespace = "com.example.neomovies_mobile"
namespace = "com.neo.neomovies_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
jvmTarget = "17"
}
defaultConfig {
// 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.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
@@ -42,3 +42,18 @@ android {
flutter {
source = "../.."
}
dependencies {
// TorrentEngine library module
implementation(project(":torrentengine"))
// Kotlin Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// 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")
}

View File

@@ -1,5 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Разрешения для работы с торрентами -->
<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>
<intent>

View File

@@ -1,5 +0,0 @@
package com.example.neomovies_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -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

View File

@@ -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.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

Binary file not shown.

160
android/gradlew vendored Executable file
View 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
View 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

View File

@@ -1,9 +1,17 @@
pluginManagement {
val flutterSdkPath = run {
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")
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
}
@@ -19,7 +27,9 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.android.library") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
include(":app")
include(":torrentengine")

View 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.

View 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

View File

@@ -0,0 +1,76 @@
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"
}
}
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.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// 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
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// 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")
}

View 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 { *; }

View 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

View 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>

View File

@@ -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
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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
}
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}

View File

@@ -1,332 +1,145 @@
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/favorite.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/api/neomovies_api_client.dart'; // новый клиент
import 'package:neomovies_mobile/data/exceptions/auth_exceptions.dart';
class ApiClient {
final http.Client _client;
final String _baseUrl = dotenv.env['API_URL']!;
final NeoMoviesApiClient _neoClient;
ApiClient(this._client);
ApiClient(http.Client client)
: _neoClient = NeoMoviesApiClient(client);
Future<List<Movie>> getPopularMovies({int page = 1}) async {
return _fetchMovies('/movies/popular', page: page);
// ---- Movies ----
Future<List<Movie>> getPopularMovies({int page = 1}) {
return _neoClient.getPopularMovies(page: page);
}
Future<List<Movie>> getTopRatedMovies({int page = 1}) async {
return _fetchMovies('/movies/top-rated', page: page);
Future<List<Movie>> getTopRatedMovies({int page = 1}) {
return _neoClient.getTopRatedMovies(page: page);
}
Future<List<Movie>> getUpcomingMovies({int page = 1}) async {
return _fetchMovies('/movies/upcoming', page: page);
Future<List<Movie>> getUpcomingMovies({int page = 1}) {
return _neoClient.getUpcomingMovies(page: page);
}
Future<Movie> getMovieById(String id) async {
return _fetchMovieDetail('/movies/$id');
Future<Movie> getMovieById(String id) {
return _neoClient.getMovieById(id);
}
Future<Movie> getTvById(String id) async {
return _fetchMovieDetail('/tv/$id');
Future<Movie> getTvById(String id) {
return _neoClient.getTvShowById(id);
}
// Получение IMDB ID для фильмов
Future<String?> getMovieImdbId(int movieId) async {
// ---- Search ----
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 {
final uri = Uri.parse('$_baseUrl/movies/$movieId/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 movie IMDB ID: ${response.statusCode}');
return null;
}
return reactions.firstWhere(
(r) => r.mediaType == mediaType && r.mediaId == mediaId,
);
} catch (e) {
print('Error getting movie IMDB ID: $e');
return null;
return null; // No reaction found
}
}
// Получение IMDB ID для сериалов
Future<String?> getTvImdbId(int showId) async {
try {
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;
}
// ---- External IDs (IMDb) ----
Future<String?> getImdbId(String mediaId, String mediaType) async {
// This would need to be implemented in NeoMoviesApiClient
// For now, return null or implement a stub
// TODO: Add getExternalIds endpoint to backend
return null;
}
// Универсальный метод получения IMDB ID
Future<String?> getImdbId(int mediaId, String mediaType) async {
if (mediaType == 'tv') {
return getTvImdbId(mediaId);
} else {
return getMovieImdbId(mediaId);
}
}
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}');
}
// ---- Auth ----
Future<void> register(String name, String email, String password) {
return _neoClient.register(
name: name,
email: email,
password: password,
).then((_) {}); // старый код ничего не возвращал
}
Future<AuthResponse> login(String email, String password) async {
final uri = Uri.parse('$_baseUrl/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('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 [];
try {
return await _neoClient.login(email: email, password: password);
} catch (e) {
final errorMessage = e.toString();
if (errorMessage.contains('Account not activated') ||
errorMessage.contains('not verified') ||
errorMessage.contains('Please verify your email')) {
throw UnverifiedAccountException(email, message: errorMessage);
}
return data.map((json) => Movie.fromJson(json)).toList();
} else {
throw Exception('Failed to load movies from $endpoint');
rethrow;
}
}
}
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();
}
}

View File

@@ -0,0 +1,506 @@
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');
final response = await _client.get(uri);
if (response.statusCode == 200) {
final apiResponse = json.decode(response.body);
// API returns: {"success": true, "data": {...}}
final movieData = (apiResponse is Map && apiResponse['data'] != null)
? apiResponse['data']
: apiResponse;
return Movie.fromJson(movieData);
} else {
throw Exception('Failed to load movie: ${response.statusCode}');
}
}
/// 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');
final response = await _client.get(uri);
if (response.statusCode == 200) {
final apiResponse = json.decode(response.body);
// API returns: {"success": true, "data": {...}}
final tvData = (apiResponse is Map && apiResponse['data'] != null)
? apiResponse['data']
: apiResponse;
return Movie.fromJson(tvData);
} else {
throw Exception('Failed to load TV show: ${response.statusCode}');
}
}
/// 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);
}
// ============================================
// 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}');
}
}
}

View File

@@ -1,11 +1,12 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
class Favorite {
final int id;
final String id; // MongoDB ObjectID as string
final String mediaId;
final String mediaType;
final String mediaType; // "movie" or "tv"
final String title;
final String posterPath;
final DateTime? createdAt;
Favorite({
required this.id,
@@ -13,24 +14,29 @@ class Favorite {
required this.mediaType,
required this.title,
required this.posterPath,
this.createdAt,
});
factory Favorite.fromJson(Map<String, dynamic> json) {
return Favorite(
id: json['id'] as int? ?? 0,
id: json['id'] as String? ?? '',
mediaId: json['mediaId'] as String? ?? '',
mediaType: json['mediaType'] as String? ?? '',
mediaType: json['mediaType'] as String? ?? 'movie',
title: json['title'] as String? ?? '',
posterPath: json['posterPath'] as String? ?? '',
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'] as String)
: null,
);
}
String get fullPosterUrl {
final baseUrl = dotenv.env['API_URL']!;
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;
return '$baseUrl/images/w500/$cleanPath';
return '$tmdbBaseUrl/w500/$cleanPath';
}
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:hive/hive.dart';
import 'package:json_annotation/json_annotation.dart';
part 'movie.g.dart';
@HiveType(typeId: 0)
@JsonSerializable()
class Movie extends HiveObject {
@HiveField(0)
final String id;
@@ -14,6 +16,8 @@ class Movie extends HiveObject {
@HiveField(2)
final String? posterPath;
final String? backdropPath;
@HiveField(3)
final String? overview;
@@ -49,6 +53,7 @@ class Movie extends HiveObject {
required this.id,
required this.title,
this.posterPath,
this.backdropPath,
this.overview,
this.releaseDate,
this.genres,
@@ -66,6 +71,7 @@ class Movie extends HiveObject {
id: (json['id'] as num).toString(), // Ensure id is a string
title: (json['title'] ?? json['name'] ?? '') as String,
posterPath: json['poster_path'] as String?,
backdropPath: json['backdrop_path'] as String?,
overview: json['overview'] as String?,
releaseDate: json['release_date'] != null && json['release_date'].isNotEmpty
? DateTime.tryParse(json['release_date'] as String)
@@ -87,14 +93,29 @@ class Movie extends HiveObject {
);
}
Map<String, dynamic> toJson() => _$MovieToJson(this);
String get fullPosterUrl {
final baseUrl = dotenv.env['API_URL']!;
if (posterPath == null || posterPath!.isEmpty) {
// Use the placeholder from our own backend
return '$baseUrl/images/w500/placeholder.jpg';
// Use API placeholder
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!;
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';
}
}

View File

@@ -24,13 +24,18 @@ class MovieAdapter extends TypeAdapter<Movie> {
releaseDate: fields[4] as DateTime?,
genres: (fields[5] as List?)?.cast<String>(),
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
void write(BinaryWriter writer, Movie obj) {
writer
..writeByte(7)
..writeByte(12)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -44,7 +49,17 @@ class MovieAdapter extends TypeAdapter<Movie> {
..writeByte(5)
..write(obj.genres)
..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
@@ -57,3 +72,44 @@ class MovieAdapter extends TypeAdapter<Movie> {
runtimeType == other.runtimeType &&
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,
};

View File

@@ -1,8 +1,10 @@
import 'package:hive/hive.dart';
import 'package:json_annotation/json_annotation.dart';
part 'movie_preview.g.dart';
@HiveType(typeId: 1) // Use a new typeId to avoid conflicts with Movie
@JsonSerializable()
class MoviePreview extends HiveObject {
@HiveField(0)
final String id;
@@ -18,4 +20,7 @@ class MoviePreview extends HiveObject {
required this.title,
this.posterPath,
});
factory MoviePreview.fromJson(Map<String, dynamic> json) => _$MoviePreviewFromJson(json);
Map<String, dynamic> toJson() => _$MoviePreviewToJson(this);
}

View File

@@ -45,3 +45,20 @@ class MoviePreviewAdapter extends TypeAdapter<MoviePreview> {
runtimeType == other.runtimeType &&
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,
};

View 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;
}

View 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);
}

View 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,
};

View 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,
};
}
}

View 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;
}

View 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;
}

View File

@@ -14,12 +14,20 @@ class Reaction {
class UserReaction {
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) {
return UserReaction(
reactionType: json['type'] as String?,
mediaType: json['mediaType'] as String?,
mediaId: json['mediaId'] as String?,
);
}
}

View 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);
}

View 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;
}

View 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,
};

View 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);
}

View 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,
};

View File

@@ -33,8 +33,13 @@ class AuthRepository {
}
Future<void> verifyEmail(String email, String code) async {
await _apiClient.verify(email, code);
// After successful verification, the user should log in.
final response = await _apiClient.verify(email, code);
// 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 {

View File

@@ -10,7 +10,7 @@ class ReactionsRepository {
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);
}

View File

@@ -0,0 +1,493 @@
import 'dart:convert';
import 'package:flutter/services.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');
/// Получить базовую информацию из magnet-ссылки
static Future<MagnetBasicInfo> parseMagnetBasicInfo(String magnetUri) async {
try {
final String result = await _channel.invokeMethod('parseMagnetBasicInfo', {
'magnetUri': magnetUri,
});
final Map<String, dynamic> json = jsonDecode(result);
return MagnetBasicInfo.fromJson(json);
} on PlatformException catch (e) {
throw Exception('Failed to parse magnet URI: ${e.message}');
} catch (e) {
throw Exception('Failed to parse magnet basic info: $e');
}
}
/// Получить полные метаданные торрента
static Future<TorrentMetadataFull> fetchFullMetadata(String magnetUri) async {
try {
final String result = await _channel.invokeMethod('fetchFullMetadata', {
'magnetUri': magnetUri,
});
final Map<String, dynamic> json = jsonDecode(result);
return TorrentMetadataFull.fromJson(json);
} on PlatformException catch (e) {
throw Exception('Failed to fetch torrent metadata: ${e.message}');
} catch (e) {
throw Exception('Failed to parse torrent metadata: $e');
}
}
/// Тестирование торрент-сервиса
static Future<String> testTorrentService() async {
try {
final String result = await _channel.invokeMethod('testTorrentService');
return result;
} on PlatformException catch (e) {
throw Exception('Torrent service test failed: ${e.message}');
}
}
/// Get torrent metadata from magnet link (legacy method)
static Future<TorrentMetadata> getTorrentMetadata(String magnetLink) async {
try {
final String result = await _channel.invokeMethod('getTorrentMetadata', {
'magnetLink': magnetLink,
});
final Map<String, dynamic> json = jsonDecode(result);
return TorrentMetadata.fromJson(json);
} on PlatformException catch (e) {
throw Exception('Failed to get torrent metadata: ${e.message}');
} catch (e) {
throw Exception('Failed to parse torrent metadata: $e');
}
}
/// Start downloading selected files from torrent
static Future<String> startDownload({
required String magnetLink,
required List<int> selectedFiles,
String? downloadPath,
}) async {
try {
final String infoHash = await _channel.invokeMethod('startDownload', {
'magnetLink': magnetLink,
'selectedFiles': selectedFiles,
'downloadPath': downloadPath,
});
return infoHash;
} on PlatformException catch (e) {
throw Exception('Failed to start download: ${e.message}');
}
}
/// Get download progress for a torrent
static Future<DownloadProgress?> getDownloadProgress(String infoHash) async {
try {
final String? result = await _channel.invokeMethod('getDownloadProgress', {
'infoHash': infoHash,
});
if (result == null) return null;
final Map<String, dynamic> json = jsonDecode(result);
return DownloadProgress.fromJson(json);
} on PlatformException catch (e) {
if (e.code == 'NOT_FOUND') return null;
throw Exception('Failed to get download progress: ${e.message}');
} catch (e) {
throw Exception('Failed to parse download progress: $e');
}
}
/// Pause download
static Future<bool> pauseDownload(String infoHash) async {
try {
final bool result = await _channel.invokeMethod('pauseDownload', {
'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('resumeDownload', {
'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('cancelDownload', {
'infoHash': infoHash,
});
return result;
} on PlatformException catch (e) {
throw Exception('Failed to cancel download: ${e.message}');
}
}
/// Get all active downloads
static Future<List<DownloadProgress>> getAllDownloads() async {
try {
final String result = await _channel.invokeMethod('getAllDownloads');
final List<dynamic> jsonList = jsonDecode(result);
return jsonList
.map((json) => DownloadProgress.fromJson(json as Map<String, dynamic>))
.toList();
} on PlatformException catch (e) {
throw Exception('Failed to get all downloads: ${e.message}');
} catch (e) {
throw Exception('Failed to parse downloads: $e');
}
}
}

View 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');
}
}
}

View 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());
}
}

View 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;
}

View 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;
}

View File

@@ -93,9 +93,9 @@ class AuthProvider extends ChangeNotifier {
notifyListeners();
try {
await _authRepository.verifyEmail(email, code);
// After verification, user should log in.
// For a better UX, we could auto-login them, but for now, we'll just go to the unauthenticated state.
_state = AuthState.unauthenticated;
// Auto-login after successful verification
_user = await _authRepository.getCurrentUser();
_state = AuthState.authenticated;
} catch (e) {
_error = e.toString();
_state = AuthState.error;

View File

@@ -33,21 +33,32 @@ class MovieDetailProvider with ChangeNotifier {
notifyListeners();
try {
// Load movie/TV details
if (mediaType == 'movie') {
_movie = await _movieRepository.getMovieById(mediaId.toString());
} else {
_movie = await _movieRepository.getTvById(mediaId.toString());
}
_isLoading = false;
notifyListeners();
// Try to load IMDb ID (non-blocking)
if (_movie != null) {
_imdbId = await _apiClient.getImdbId(mediaId, mediaType);
try {
_imdbId = await _apiClient.getImdbId(mediaId.toString(), mediaType);
} catch (e) {
// IMDb ID loading failed, but don't fail the whole screen
print('Failed to load IMDb ID: $e');
_imdbId = null;
}
}
} catch (e) {
print('Error loading media: $e');
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
} finally {
_isImdbLoading = false;
notifyListeners();
}

View File

@@ -48,7 +48,7 @@ class ReactionsProvider with ChangeNotifier {
if (_authProvider.isAuthenticated) {
final userReactionResult = await _repository.getMyReaction(mediaType, mediaId);
_userReaction = userReactionResult.reactionType;
_userReaction = userReactionResult?.reactionType;
} else {
_userReaction = null;
}

View File

@@ -61,16 +61,7 @@ class _VerifyScreenState extends State<VerifyScreen> {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
Provider.of<AuthProvider>(context, listen: false)
.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.')),
);
}
});
.verifyEmail(widget.email, _code);
}
}
@@ -82,6 +73,16 @@ class _VerifyScreenState extends State<VerifyScreen> {
),
body: Consumer<AuthProvider>(
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(
key: _formKey,
child: Padding(

View File

@@ -6,6 +6,7 @@ import 'package:neomovies_mobile/presentation/providers/favorites_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/screens/player/video_player_screen.dart';
import 'package:neomovies_mobile/presentation/screens/torrent_selector/torrent_selector_screen.dart';
import 'package:provider/provider.dart';
class MovieDetailScreen extends StatefulWidget {
@@ -29,6 +30,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) {
if (imdbId == null || imdbId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -205,9 +228,9 @@ class _MovieDetailScreenState extends State<MovieDetailScreen> {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
).copyWith(
// Устанавливаем цвет для неактивного состояния
backgroundColor: MaterialStateProperty.resolveWith<Color?>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
backgroundColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.disabled)) {
return Colors.grey;
}
return Theme.of(context).colorScheme.primary;
@@ -262,6 +285,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: 'Скачать торрент',
);
},
),
],
),
],

View File

@@ -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;
}
}
}

View 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;
});
}
});
}
}
}

View File

@@ -1,7 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:neomovies_mobile/data/models/player/video_source.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
class WebPlayerWidget extends StatefulWidget {
final VideoSource source;
@@ -17,14 +21,29 @@ class WebPlayerWidget extends StatefulWidget {
State<WebPlayerWidget> createState() => _WebPlayerWidgetState();
}
class _WebPlayerWidgetState extends State<WebPlayerWidget> {
class _WebPlayerWidgetState extends State<WebPlayerWidget>
with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
late final WebViewController _controller;
bool _isLoading = true;
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
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initializeWebView();
}

View 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,
);
}
}

View File

@@ -41,7 +41,8 @@ endif()
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
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_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()

View File

@@ -12,6 +12,7 @@ import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
import video_player_avfoundation
import wakelock_plus
import webview_flutter_wkwebview
@@ -23,6 +24,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}

View File

@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
url: "https://pub.dev"
source: hosted
version: "85.0.0"
version: "67.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de617bfdc64f3d8b00835ec2957441ceca0a29cdf7881f7ab231bc14f71159c0
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev"
source: hosted
version: "7.5.6"
version: "6.4.1"
archive:
dependency: transitive
description:
@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
bloc:
dependency: transitive
description:
name: bloc
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
url: "https://pub.dev"
source: hosted
version: "8.1.4"
boolean_selector:
dependency: transitive
description:
@@ -53,10 +61,10 @@ packages:
dependency: transitive
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
version: "2.4.1"
build_config:
dependency: transitive
description:
@@ -77,26 +85,26 @@ packages:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
version: "2.4.2"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
version: "2.4.13"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
url: "https://pub.dev"
source: hosted
version: "9.1.2"
version: "7.3.2"
built_collection:
dependency: transitive
description:
@@ -153,6 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
chewie:
dependency: "direct main"
description:
name: chewie
sha256: "19b93a1e60e4ba640a792208a6543f1c7d5b124d011ce0199e2f18802199d984"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
cli_util:
dependency: transitive
description:
@@ -201,6 +217,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.6"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -213,10 +237,10 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af"
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "2.3.6"
dbus:
dependency: transitive
description:
@@ -278,6 +302,14 @@ packages:
description: flutter
source: sdk
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:
dependency: transitive
description:
@@ -368,6 +400,22 @@ packages:
description: flutter
source: sdk
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:
dependency: transitive
description:
@@ -416,6 +464,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -473,37 +537,45 @@ packages:
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
dependency: "direct main"
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -788,15 +860,31 @@ packages:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "2.0.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
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:
dependency: transitive
description:
@@ -905,10 +993,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.6"
timing:
dependency: transitive
description:
@@ -1001,10 +1089,50 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
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:
dependency: transitive
description:
@@ -1127,4 +1255,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.8.1 <4.0.0"
flutter: ">=3.27.0"
flutter: ">=3.29.0"

View File

@@ -34,6 +34,9 @@ dependencies:
# Core
http: ^1.2.1
provider: ^6.1.2
flutter_bloc: ^8.1.3
freezed_annotation: ^2.4.1
json_annotation: ^4.9.0
flutter_dotenv: ^5.1.0
# UI & Theming
cupertino_icons: ^1.0.2
@@ -49,15 +52,21 @@ dependencies:
# Video Player (WebView only)
webview_flutter: ^4.7.0
wakelock_plus: ^1.2.1
# Video Player with native controls
video_player: ^2.9.2
chewie: ^1.8.5
# Utils
equatable: ^2.0.5
url_launcher: ^6.3.2
dev_dependencies:
freezed: ^2.4.5
json_serializable: ^6.7.1
hive_generator: ^2.0.1
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
build_runner: ^2.5.4
build_runner: ^2.4.13
flutter_launcher_icons: ^0.13.1
flutter_launcher_icons: