From 3fdcf6d5b7052c3e595a936b507b9544b3f6c0a8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 1 Jan 2026 19:28:15 +0700 Subject: [PATCH] Initial commit: SpotiFLAC Android/iOS app --- .github/workflows/android-build.yml | 77 + .github/workflows/ios-build.yml | 74 + .github/workflows/release.yml | 202 +++ .gitignore | 14 + README.md | 116 ++ analysis_options.yaml | 28 + android/.gitignore | 14 + android/app/build.gradle | 71 + android/app/build.gradle.kts | 58 + android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 87 ++ .../com/example/temp_project/MainActivity.kt | 5 + .../com/zarz/spotiflac/DownloadService.kt | 87 ++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 138 ++ .../drawable-hdpi/ic_launcher_foreground.png | Bin 0 -> 9826 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 0 -> 6627 bytes .../res/drawable-v21/launch_background.xml | 12 + .../drawable-xhdpi/ic_launcher_foreground.png | Bin 0 -> 12876 bytes .../ic_launcher_foreground.png | Bin 0 -> 18816 bytes .../ic_launcher_foreground.png | Bin 0 -> 24714 bytes .../main/res/drawable/launch_background.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4455 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2934 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 5929 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 8733 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 11557 bytes .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/styles.xml | 18 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 43 + android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/settings.gradle.kts | 26 + assets/images/logo.png | Bin 0 -> 19642 bytes go_backend/amazon.go | 363 +++++ go_backend/cover.go | 101 ++ go_backend/duplicate.go | 63 + go_backend/exports.go | 339 +++++ go_backend/filename.go | 106 ++ go_backend/go.mod | 18 + go_backend/go.sum | 14 + go_backend/httputil.go | 213 +++ go_backend/lyrics.go | 299 ++++ go_backend/metadata.go | 337 +++++ go_backend/progress.go | 137 ++ go_backend/qobuz.go | 411 +++++ go_backend/ratelimit.go | 111 ++ go_backend/romaji.go | 276 ++++ go_backend/songlink.go | 153 ++ go_backend/spotify.go | 616 ++++++++ go_backend/tidal.go | 925 ++++++++++++ icon.png | Bin 0 -> 19642 bytes ios/.gitignore | 34 + ios/Flutter/AppFrameworkInfo.plist | 26 + ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 51 + ios/Runner.xcodeproj/project.pbxproj | 616 ++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 101 ++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + ios/Runner/AppDelegate.swift | 155 ++ .../AppIcon.appiconset/Contents.json | 122 ++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + ios/Runner/Base.lproj/LaunchScreen.storyboard | 43 + ios/Runner/Base.lproj/Main.storyboard | 26 + ios/Runner/Info.plist | 68 + ios/Runner/Runner-Bridging-Header.h | 1 + ios/RunnerTests/RunnerTests.swift | 12 + lib/app.dart | 49 + lib/main.dart | 12 + lib/models/download_item.dart | 62 + lib/models/download_item.g.dart | 58 + lib/models/settings.dart | 52 + lib/models/settings.g.dart | 30 + lib/models/theme_settings.dart | 76 + lib/models/track.dart | 61 + lib/models/track.g.dart | 61 + lib/providers/download_queue_provider.dart | 520 +++++++ lib/providers/settings_provider.dart | 71 + lib/providers/theme_provider.dart | 83 ++ lib/providers/track_provider.dart | 190 +++ lib/screens/history_screen.dart | 372 +++++ lib/screens/history_tab.dart | 388 +++++ lib/screens/home_screen.dart | 335 +++++ lib/screens/home_tab.dart | 457 ++++++ lib/screens/main_shell.dart | 105 ++ lib/screens/queue_screen.dart | 232 +++ lib/screens/queue_tab.dart | 251 ++++ lib/screens/search_screen.dart | 179 +++ lib/screens/settings_screen.dart | 426 ++++++ lib/screens/settings_tab.dart | 395 +++++ lib/screens/setup_screen.dart | 549 +++++++ lib/services/ffmpeg_service.dart | 122 ++ lib/services/platform_bridge.dart | 198 +++ lib/theme/app_theme.dart | 237 +++ lib/theme/dynamic_color_wrapper.dart | 53 + pubspec.lock | 1322 +++++++++++++++++ pubspec.yaml | 78 + scripts/build_ios.sh | 78 + test/widget_test.dart | 30 + 126 files changed, 14079 insertions(+) create mode 100644 .github/workflows/android-build.yml create mode 100644 .github/workflows/ios-build.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/example/temp_project/MainActivity.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts create mode 100644 assets/images/logo.png create mode 100644 go_backend/amazon.go create mode 100644 go_backend/cover.go create mode 100644 go_backend/duplicate.go create mode 100644 go_backend/exports.go create mode 100644 go_backend/filename.go create mode 100644 go_backend/go.mod create mode 100644 go_backend/go.sum create mode 100644 go_backend/httputil.go create mode 100644 go_backend/lyrics.go create mode 100644 go_backend/metadata.go create mode 100644 go_backend/progress.go create mode 100644 go_backend/qobuz.go create mode 100644 go_backend/ratelimit.go create mode 100644 go_backend/romaji.go create mode 100644 go_backend/songlink.go create mode 100644 go_backend/spotify.go create mode 100644 go_backend/tidal.go create mode 100644 icon.png create mode 100644 ios/.gitignore create mode 100644 ios/Flutter/AppFrameworkInfo.plist create mode 100644 ios/Flutter/Debug.xcconfig create mode 100644 ios/Flutter/Release.xcconfig create mode 100644 ios/Podfile create mode 100644 ios/Runner.xcodeproj/project.pbxproj create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner/AppDelegate.swift create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/Runner/Base.lproj/Main.storyboard create mode 100644 ios/Runner/Info.plist create mode 100644 ios/Runner/Runner-Bridging-Header.h create mode 100644 ios/RunnerTests/RunnerTests.swift create mode 100644 lib/app.dart create mode 100644 lib/main.dart create mode 100644 lib/models/download_item.dart create mode 100644 lib/models/download_item.g.dart create mode 100644 lib/models/settings.dart create mode 100644 lib/models/settings.g.dart create mode 100644 lib/models/theme_settings.dart create mode 100644 lib/models/track.dart create mode 100644 lib/models/track.g.dart create mode 100644 lib/providers/download_queue_provider.dart create mode 100644 lib/providers/settings_provider.dart create mode 100644 lib/providers/theme_provider.dart create mode 100644 lib/providers/track_provider.dart create mode 100644 lib/screens/history_screen.dart create mode 100644 lib/screens/history_tab.dart create mode 100644 lib/screens/home_screen.dart create mode 100644 lib/screens/home_tab.dart create mode 100644 lib/screens/main_shell.dart create mode 100644 lib/screens/queue_screen.dart create mode 100644 lib/screens/queue_tab.dart create mode 100644 lib/screens/search_screen.dart create mode 100644 lib/screens/settings_screen.dart create mode 100644 lib/screens/settings_tab.dart create mode 100644 lib/screens/setup_screen.dart create mode 100644 lib/services/ffmpeg_service.dart create mode 100644 lib/services/platform_bridge.dart create mode 100644 lib/theme/app_theme.dart create mode 100644 lib/theme/dynamic_color_wrapper.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 scripts/build_ios.sh create mode 100644 test/widget_test.dart diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 00000000..ceca5612 --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,77 @@ +name: Android Build + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build-android: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache-dependency-path: go_backend/go.sum + + - name: Install Android SDK & NDK + uses: android-actions/setup-android@v3 + + - name: Install gomobile + run: | + go install golang.org/x/mobile/cmd/gomobile@latest + gomobile init + + - name: Build Go backend for Android + working-directory: go_backend + run: | + mkdir -p ../android/app/libs + gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar . + env: + CGO_ENABLED: 1 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.24.0' + channel: 'stable' + cache: true + + - name: Get Flutter dependencies + run: flutter pub get + + - name: Generate app icons + run: dart run flutter_launcher_icons + + - name: Build APK (Release) + run: flutter build apk --release + + - name: Build App Bundle (Release) + run: flutter build appbundle --release + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: SpotiFLAC-Android-APK + path: build/app/outputs/flutter-apk/app-release.apk + retention-days: 30 + + - name: Upload AAB artifact + uses: actions/upload-artifact@v4 + with: + name: SpotiFLAC-Android-AAB + path: build/app/outputs/bundle/release/app-release.aab + retention-days: 30 diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml new file mode 100644 index 00000000..c192c25d --- /dev/null +++ b/.github/workflows/ios-build.yml @@ -0,0 +1,74 @@ +name: iOS Build + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build-ios: + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache-dependency-path: go_backend/go.sum + + - name: Install gomobile + run: | + go install golang.org/x/mobile/cmd/gomobile@latest + gomobile init + + - name: Build Go backend for iOS (XCFramework) + working-directory: go_backend + run: | + mkdir -p ../ios/Frameworks + gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework . + env: + CGO_ENABLED: 1 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.24.0' + channel: 'stable' + cache: true + + - name: Get Flutter dependencies + run: flutter pub get + + - name: Generate app icons + run: dart run flutter_launcher_icons + + - name: Build iOS (no codesign) + run: flutter build ios --release --no-codesign + + - name: Create IPA (unsigned) + run: | + mkdir -p build/ios/ipa + cd build/ios/iphoneos + mkdir Payload + cp -r Runner.app Payload/ + zip -r ../ipa/SpotiFLAC-unsigned.ipa Payload + rm -rf Payload + + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + with: + name: SpotiFLAC-iOS-unsigned + path: build/ios/ipa/SpotiFLAC-unsigned.ipa + retention-days: 30 + + - name: Upload XCFramework artifact + uses: actions/upload-artifact@v4 + with: + name: Gobackend-XCFramework + path: ios/Frameworks/Gobackend.xcframework + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5fa9fae4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,202 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g., v1.0.0)' + required: true + default: 'v1.0.0' + +jobs: + build-android: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.get_version.outputs.version }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Get version + id: get_version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + fi + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache-dependency-path: go_backend/go.sum + + - name: Install Android SDK & NDK + uses: android-actions/setup-android@v3 + + - name: Install gomobile + run: | + go install golang.org/x/mobile/cmd/gomobile@latest + gomobile init + + - name: Build Go backend for Android + working-directory: go_backend + run: | + mkdir -p ../android/app/libs + gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar . + env: + CGO_ENABLED: 1 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.24.0' + channel: 'stable' + cache: true + + - name: Get Flutter dependencies + run: flutter pub get + + - name: Generate app icons + run: dart run flutter_launcher_icons + + - name: Build APK (Release) + run: flutter build apk --release + + - name: Rename APK + run: | + VERSION=${{ steps.get_version.outputs.version }} + mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SpotiFLAC-${VERSION}-android.apk + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk + + build-ios: + runs-on: macos-latest + needs: build-android + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache-dependency-path: go_backend/go.sum + + - name: Install gomobile + run: | + go install golang.org/x/mobile/cmd/gomobile@latest + gomobile init + + - name: Build Go backend for iOS + working-directory: go_backend + run: | + mkdir -p ../ios/Frameworks + gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework . + env: + CGO_ENABLED: 1 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.24.0' + channel: 'stable' + cache: true + + - name: Get Flutter dependencies + run: flutter pub get + + - name: Generate app icons + run: dart run flutter_launcher_icons + + - name: Build iOS (unsigned) + run: flutter build ios --release --no-codesign + + - name: Create IPA + run: | + VERSION=${{ needs.build-android.outputs.version }} + mkdir -p build/ios/ipa + cd build/ios/iphoneos + mkdir Payload + cp -r Runner.app Payload/ + zip -r ../ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload + rm -rf Payload + + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + with: + name: ios-ipa + path: build/ios/ipa/SpotiFLAC-*.ipa + + create-release: + runs-on: ubuntu-latest + needs: [build-android, build-ios] + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download Android APK + uses: actions/download-artifact@v4 + with: + name: android-apk + path: ./release + + - name: Download iOS IPA + uses: actions/download-artifact@v4 + with: + name: ios-ipa + path: ./release + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ needs.build-android.outputs.version }} + name: SpotiFLAC ${{ needs.build-android.outputs.version }} + body: | + ## SpotiFLAC ${{ needs.build-android.outputs.version }} + + Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music. + + ### Downloads + - **Android**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-android.apk` + - **iOS**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-ios-unsigned.ipa` (requires sideloading) + + ### Features + - Search Spotify tracks, albums, and playlists + - Download in FLAC quality from multiple sources + - Automatic fallback to available services + - Embedded metadata and cover art + - Lyrics support (synced and plain) + - Material 3 Expressive UI with dynamic colors + + ### Installation + **Android**: Enable "Install from unknown sources" and install the APK + **iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA + + --- + *Note: iOS IPA is unsigned and requires sideloading* + files: | + ./release/* + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..8c961f5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# OS files +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.iml + +# Kiro specs (optional - remove if you want to track specs) +# .kiro/ + +# Reference folder (if you don't want to include it) +# referensi/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..04349415 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# SpotiFLAC + +Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music. + +![Android Build](https://github.com/zarzet/SpotiFLAC-Android/actions/workflows/android-build.yml/badge.svg) +![iOS Build](https://github.com/zarzet/SpotiFLAC-Android/actions/workflows/ios-build.yml/badge.svg) + +## Features + +- 🔍 Search Spotify tracks, albums, and playlists +- 📥 Download in FLAC quality from multiple sources (Tidal, Qobuz, Amazon Music) +- 🔄 Automatic fallback to available services +- 🎵 Embedded metadata and cover art +- 📝 Lyrics support (synced and plain) +- 🎨 Material 3 Expressive UI with dynamic colors +- 📱 Cross-platform: Android & iOS + +## Download + +### Latest Release +Download the latest version from [Releases](https://github.com/zarzet/SpotiFLAC-Android/releases) + +- **Android**: Download `SpotiFLAC-vX.X.X-android.apk` +- **iOS**: Download `SpotiFLAC-vX.X.X-ios-unsigned.ipa` (requires sideloading) + +### Requirements + +**Android** +- Android 7.0 (API 24) or higher +- Storage permission for saving music files + +**iOS** +- iOS 14.0 or higher +- Sideloading tool (AltStore, Sideloadly, etc.) + +## Building from Source + +### Prerequisites +- Flutter 3.24.0 or higher +- Go 1.21 or higher +- gomobile (`go install golang.org/x/mobile/cmd/gomobile@latest`) + +### Android Build + +```bash +# Build Go backend +cd go_backend +gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar . +cd .. + +# Build APK +flutter build apk --release +``` + +### iOS Build + +#### Option 1: Using GitHub Actions (Recommended - No Mac Required) +Push to the repository and GitHub Actions will automatically build the iOS app. +Download the unsigned IPA from the Actions artifacts. + +#### Option 2: Local Build (Requires macOS) + +```bash +# Build Go backend for iOS +cd go_backend +gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework . +cd .. + +# Build iOS (unsigned) +flutter build ios --release --no-codesign +``` + +## Project Structure + +``` +SpotiFLAC-Android/ +├── lib/ # Flutter/Dart code +│ ├── models/ # Data models +│ ├── providers/ # Riverpod state management +│ ├── screens/ # UI screens +│ ├── services/ # Platform bridge & FFmpeg +│ └── theme/ # Material 3 theming +├── go_backend/ # Go backend (Tidal, Qobuz, Amazon APIs) +├── android/ # Android platform code +├── ios/ # iOS platform code +└── .github/workflows/ # CI/CD workflows +``` + +## Creating a Release + +Releases are automated via GitHub Actions. To create a new release: + +1. Create and push a tag: + ```bash + git tag v1.0.0 + git push origin v1.0.0 + ``` + +2. GitHub Actions will automatically: + - Build Android APK + - Build iOS IPA (unsigned) + - Create a GitHub Release with both artifacts + +## Known Limitations + +- iOS IPA is unsigned and requires sideloading +- TestFlight distribution requires Apple Developer account ($99/year) +- Some streaming services may have regional restrictions + +## License + +Private project - not for public distribution. + +## Disclaimer + +This project is for educational purposes only. Please respect copyright laws and the terms of service of streaming platforms. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..f5ed5e6b --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,71 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "com.zarz.spotiflac" + compileSdk flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "com.zarz.spotiflac" + minSdkVersion flutter.minSdkVersion + targetSdk flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + signingConfig signingConfigs.debug + minifyEnabled false + shrinkResources false + } + } +} + +flutter { + source '../..' +} + +dependencies { + // Go backend library (gomobile generated) + implementation fileTree(dir: 'libs', include: ['*.aar']) + + // Kotlin coroutines for async Go backend calls + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' +} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..e1dd5970 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.zarz.spotiflac" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + + defaultConfig { + applicationId = "com.zarz.spotiflac" + minSdk = flutter.minSdkVersion + targetSdk = 34 + versionCode = flutter.versionCode + versionName = flutter.versionName + multiDexEnabled = true + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = false + isShrinkResources = false + } + } +} + +flutter { + source = "../.." +} + +repositories { + flatDir { + dirs("libs") + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + implementation(files("libs/gobackend.aar")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..2b3d15cd --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/temp_project/MainActivity.kt b/android/app/src/main/kotlin/com/example/temp_project/MainActivity.kt new file mode 100644 index 00000000..3aebb9bc --- /dev/null +++ b/android/app/src/main/kotlin/com/example/temp_project/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.temp_project + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt new file mode 100644 index 00000000..0ae44b0b --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -0,0 +1,87 @@ +package com.zarz.spotiflac + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat + +class DownloadService : Service() { + companion object { + const val CHANNEL_ID = "spotiflac_download_channel" + const val NOTIFICATION_ID = 1 + const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD" + const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD" + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> startForegroundService() + ACTION_STOP -> stopSelf() + } + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Download Progress", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows download progress for SpotiFLAC" + setShowBadge(false) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun startForegroundService() { + val notification = createNotification("Downloading...", 0) + startForeground(NOTIFICATION_ID, notification) + } + + fun updateProgress(trackName: String, progress: Int) { + val notification = createNotification(trackName, progress) + val manager = getSystemService(NotificationManager::class.java) + manager.notify(NOTIFICATION_ID, notification) + } + + private fun createNotification(title: String, progress: Int): Notification { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val stopIntent = Intent(this, DownloadService::class.java).apply { + action = ACTION_STOP + } + val stopPendingIntent = PendingIntent.getService( + this, 0, stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("SpotiFLAC") + .setContentText(title) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setProgress(100, progress, progress == 0) + .setOngoing(true) + .setContentIntent(pendingIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent) + .build() + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt new file mode 100644 index 00000000..076d74a3 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -0,0 +1,138 @@ +package com.zarz.spotiflac + +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import gobackend.Gobackend +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MainActivity: FlutterActivity() { + private val CHANNEL = "com.zarz.spotiflac/backend" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> + scope.launch { + try { + when (call.method) { + "parseSpotifyUrl" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.parseSpotifyURL(url) + } + result.success(response) + } + "getSpotifyMetadata" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getSpotifyMetadata(url) + } + result.success(response) + } + "searchSpotify" -> { + val query = call.argument("query") ?: "" + val limit = call.argument("limit") ?: 10 + val response = withContext(Dispatchers.IO) { + Gobackend.searchSpotify(query, limit.toLong()) + } + result.success(response) + } + "checkAvailability" -> { + val spotifyId = call.argument("spotify_id") ?: "" + val isrc = call.argument("isrc") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.checkAvailability(spotifyId, isrc) + } + result.success(response) + } + "downloadTrack" -> { + val requestJson = call.arguments as String + val response = withContext(Dispatchers.IO) { + Gobackend.downloadTrack(requestJson) + } + result.success(response) + } + "downloadWithFallback" -> { + val requestJson = call.arguments as String + val response = withContext(Dispatchers.IO) { + Gobackend.downloadWithFallback(requestJson) + } + result.success(response) + } + "getDownloadProgress" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getDownloadProgress() + } + result.success(response) + } + "setDownloadDirectory" -> { + val path = call.argument("path") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setDownloadDirectory(path) + } + result.success(null) + } + "checkDuplicate" -> { + val outputDir = call.argument("output_dir") ?: "" + val isrc = call.argument("isrc") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.checkDuplicate(outputDir, isrc) + } + result.success(response) + } + "buildFilename" -> { + val template = call.argument("template") ?: "" + val metadata = call.argument("metadata") ?: "{}" + val response = withContext(Dispatchers.IO) { + Gobackend.buildFilename(template, metadata) + } + result.success(response) + } + "sanitizeFilename" -> { + val filename = call.argument("filename") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.sanitizeFilename(filename) + } + result.success(response) + } + "fetchLyrics" -> { + val spotifyId = call.argument("spotify_id") ?: "" + val trackName = call.argument("track_name") ?: "" + val artistName = call.argument("artist_name") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.fetchLyrics(spotifyId, trackName, artistName) + } + result.success(response) + } + "getLyricsLRC" -> { + val spotifyId = call.argument("spotify_id") ?: "" + val trackName = call.argument("track_name") ?: "" + val artistName = call.argument("artist_name") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getLyricsLRC(spotifyId, trackName, artistName) + } + result.success(response) + } + "embedLyricsToFile" -> { + val filePath = call.argument("file_path") ?: "" + val lyrics = call.argument("lyrics") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.embedLyricsToFile(filePath, lyrics) + } + result.success(response) + } + else -> result.notImplemented() + } + } catch (e: Exception) { + result.error("ERROR", e.message, null) + } + } + } + } +} diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..a8723f0a68b56ae2ec62b1292b53c716778073a3 GIT binary patch literal 9826 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 + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..dbc85fc98fbb0fdd99462bfaac86ce23d4b5980e GIT binary patch literal 12876 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

n9KF=PAFQBK1|&7d?X1m zh~fJrdy$pJ)32KLm<<$TN+8~k?jEugI8xl$UGqSfxeJFf%npEnoZ;P>>HF{RFysI9 zE4wm!b8Tyr*EJ&84mUY!@2Z@IurdCI^}=zmk@6m>wc+io>LOfyRigXPMoRa4-?kgZLbNxiOV_7 z{W0Uh9^Za>iexHoenp~^b!3X0mSYm8&xgL(5;;O??k{r{bL4cVL%85k&dTKE<#d{h z2aFLy!f6AM>A1Qq2boP-N){IG$yl^##(xTh0((@5Zx>ioUO~R?;X_w)d76Kc9h8YM zMmzT({f%sdG&R{6?CWU|E@)v_>KtJqx(-(s?rYI9=@8qMKa7?2QIKJ-Eh?(giB&}c zdz|AmzGYdDNxQxGg>BwAd>ed@Z46D$*1OoQ$?yGy%{!bh^WV&*+*WJBF$Rpi1n@6K zdTp%!+1nJ57N*m}odJ+ltK2Ip4uw@l9i6njS;7qGDa@Cw*-fjtOF42<_j*xb77q;5 z3~(fDl5wW4&RMXlg_yXa@y&%oB@-Gjd$tLV?55?ElSgpme# z#A}SqYja!97*6Dazb+#hj*pDcwZt+uVM>Sb?WQM)Vy(DeNjCS3K&YF3+l4=XcuWdQ z1{KEd1$~QiEo1Zc!J*%BYKs-*-n?g6Z7Xmfa{RrHRH^jzWGb1NZMYrv|Cy=;RoHa1 zsJM2BNOPQ5tAjXlZs1P&nR_2SS}JNYz@GT)A6r|qS^r>&iAES3t&Sldwpy@to|g8V z_Jos^azvx2RPQlLsag4>xQdd=$f#1pAgD&bN*(sVb{!r=R(PF z5yD14ZODcFwEX#${d|}sA_ZuAb+-sc>@$(8^jRn+s1sswXsKQmrA zX_&l%j_K!sZ3A7aAz_KFZsHj;H0p81-jEubb!# zgd^t)0iK_Eo6F?a=_g}inK-H+*1POppVcaO2-V1mAhIrCFRGGO1cyov+c*6vp$*sl+{ z=m%l@z@9)~ckA78aMB0qNv4>e@mijH4w=X8UMx)sV_`#}_wJ)*pMlrHu$75(lxdw= z&3vnTNbzqCmyL#v&YjDKJ>!`ciPPKbyc>t#>z?Wp=bYtYfAEF}kun48QH_l|4vFQ` zO23WV6kk2D4K%$C{2}wowVe1^0zctsdkXS}5<-G$rBkoDOflB0L1^McxoWCNhKsC! ze24&#|Cvl{?Y^DI`j_9z)&p`{lpKV|>&L%+^_>QuwL!^|JdW0B2vFMndZs%X6fu-f zhL9V6Af<|!%fv7v!+I@IGtDfmEZ8Ga|4qIPQ}!Z#Us(W`0M5vJE0;_$+~L0kSDs6D zh$8}UVK>`u5G!NRkL{)brQ`53-tlmHY3r_LDh(%vLwCM8l7|Ra$D4QXE)0Jez=g=- zZ@uvK16<);7hl>kMMF0D5iEVP$nS*e8gW3fOMl=%Mn@_fqQ19jK7VJu&f6s52BvTC zcI!~q9BFdKTkPq3LBgH*+>HE;vv?6V03~)2T-0pJETvwC>%$i_NOgjCd66R%9r0gI+HN3Y^U!A%Od3oi?nE$*n*BZ*LhZ7WSWKsIswwOP% z4RAfYywbwU!pDp-Py!MR?TzViV$lA9uq6NyZ@M^F(y%??Seu&lpsiqFk+`ocFJAA* zZ`Zw?`Hp9Wwkc}BkQZ?A2U|oRP2@)8Z`DY3UYU}kn)^iErh(ql7BfRVsG4s?uIO~Z zkOuKDK^HBuXE?i`Xs>k}zMJ%+=aaI6!NHw4(a(=fK(K>NiQ)0gwUu_3a@hrf6$6M) z;$PI_uQ+nR5|X}#?1UfWrWYk;AO6ouU#P6xa8ttxjt-qcV>dL;FLJYUUXa zI~A$ltoFqR0Iv8Pxg=XXzttXW&nbY3esV#Ob1e)@Fgnz7kKX0{kZ^^d{wr-=sB)=` z@?ttvUcP+7%k%Sx zL8cLGPN}LP~pcD#`|HoY^VgyP_?3 zw&^yU3;3>Gw3F%D4;K-lZTtCQ0Wk3)Mmz^8X`UEa+Eh(Zw~j<1>cJ;JGNL|~L7DA{ zDgmp4O+7FS8?9q<$ z`lwGISv|#!R&oD^K$U{si%{^gt^tj?=%2r6_|12~`|PfSQ@_jqVNyLjA%{WML13Z^ls`b$sZgH zw86lD^hkW%jboUuN%x=pD{-O^y#)w(NtcnluhU0S7e2hC30GxT)!qZiZ%ZJ%9}3Gi zpXn8^^53kP*>E8!*Z3g?Wnbz;p>XAQgHNzy`8cy*1`7u4!mO>+Y&_TP8%5WB>iCZj zz{g93g)d(Xk7ats?MXh1eY`Y1A#c^W52xnQN0JI=X*1_Y3WLHR$ZaBX9T(+zADIjShou1qf&iew^Ty zitAU?Iu(64dw`PM2iPpZKu3nF79r3C-57Lg4N;U5sXszI()^u`Pa`|EBc7HlHxM36w* zjL`m_)e*{M=UMg{;E|My{NXoP%Rr>Ky`$-t|!v**mILnTTJlIboqfHzQel<2RADiH% z52FM$4rXDE9d@$1XOFjBh@#9ZIL++u18@RvY0xmhbQ5o^v2&tjDi+CstwkJ@pqtjw z>6{WQ&*2_TIjw?Pxr$cAuocXR!_6sS3V)v^H{Ar^HC>0BYwh7_3Si7x;bvl3`8gr& zb6a}?45;fqnQ)IGBqh;A07aJjjFZCs0BkV&Md2E)HW{h2i@NhH>!G3YbJ#5CI>;X) z6&z19vyOd3@f#z8Op$y$I;zZnZ{Ju-<7M!{&wNado1-fWtkX=hyX-giD*vRsauOhX z!lKDtHVfbgux%gnRfShB8yiUgbc3i5(JNm)`$R!rT0S#(=0?+cnG z0q?1^>lP@Bgxid-J^-G+@b?gx5NqbQ?{^ruaT(0P0NTtaLN(#DhoV!Y;rg=gUK!2| zM#uEzHjD=62vZLvpJLl8V~8Rw3@@eHo|-}|E$wU+f=}*((PX@-n_^z&Jq*X6FXG=d zMBqRaI@aSwgKC|@^`S!smm>TJ@_s(@68>G|91ndc-1A3qzRKQ*)p2qeJ{QM~K z(u%68+N$OVR;Xb!vANk)(2#KCQ7H^#lxukGm3wQBDu$~Tz8FAm*l|%w^ssS5egr{m z{h1g(_!z#B>N5(4Br>t zV8ejHdHds|tTrc2#n0IiEU}A#5^e{6;+<;;% zTKQ+M@LtYWHEi8WkwfoN=s&M-Rw;(CSsCGHOr?Vc|(b zZ_0Ohv}H-cBY1jokU>pAlWA6ohlr zkC>{7o%9SRbRIZkk(LABzc7Bej97;dmQ&E#(mtq~^+Sm`-!G<@TQ7Kun6^9w%TS5W zXrNBOy3>eN6~#5YDnegVPopv?1c$&4eHj)-@V+Mv`|-TW`b4S7jokXNiEuT>oUc+k zcA1AS*KgkTm0yJ9{;!lSH_#o2gCu+K9pb+~ZRH4}q$;|TVObF_6#j25c=Zb~-w(n5 zJuA|5qjwY7`HqpLZTU(`TB9tv>?Z)erJErj(7UJugi_f#$ z+6&dQ7?azHMk&AI{=_BWGcRcRz?_SIgbCl;(-D9|EzZwPO&Su~SnLkTpq9?*_{5(f z6_Dupmmk4nTwC!G?p>)c(y4WB_)$bRmMz<<$6bTz2Es2OO{E>sK*;O^!y@H>dU#KQgY_ zFgpeA7mt~q2or;i+3X~#J%+zELE2fQH+q7X|0Z|+Et6mJ@*K)C$$Z)KkYHuThs>w! zo218tv>{OS!N-2`(4O0sQEpM`OlEEsI`ihx+F@G0b{sd|I)uePH+2N5-yPxJBXuBL z%*8gS_rTlFJH<;peuqWr#HR~0c&O^nJql)WyT8^1m9!l%8d06&dldDx& zON^f51)3n3Tx)`M4gG4Qw@2XZc)l1>>H{`uPUt6`OBgI4o%LM0%A})n@gTH?V(cKr zWu{z^aTo_(eirN0$-)(61;fI_-mOwhA#0S+6_sQc7abl5Bzdf*i0C>+)&F8r z(H=MNqjRS`k5CN)(_*u_EKaS@O%S(vU;NuL!|_4RJ!#t4>^|Mv@^_v-ei z1uQ?lb`jhg`|#jnTk66g@f=gI1!n00wE}*BD`)N-!-ZHe zL5cRRTArlhI0&of?^TlAs^8R^ny7@YglpO+P|k%>>OK7G7vIvfnyChV{_|*tA2BZ9 zyX~=+jr&zCFlOOn`lX+HJloAuD z^GhS~J1{lx*w(obwy<-L+T-FxZ(2)kcqob_y6l7bqx&mA=e&{A{LFv2_}%)R{&1p- zQV_KrKO_%XjU~1rO8(q}c?ay5+tizHi_6sOWI{Ki$=t+JJf^l;?2yQUQn?&wdnSp$ zURrouLc>iK5Ds_9-Xw7V)Qmu+CRW*6|DEEZ+uQ=36rCeT7|_GWAMOamCQ7Z^gi&46Z4!KRo^3$?UZ;XpWa!ahO(Hgymiz z;R`Zue(`n-@B_BBGEy|)I_0F1C9llEz|cq#ZMFg}l%`{k_ftjFfQF7!-)B8#{O`@^ zPL`eRSx+r&cQUUUgoR%GoP26d^M$|WRedoe-!EV8E6zL6a@CLIRRnJ4Y%CER<_KwlDH&jQ|pi@Afnf zd2RbF=oG-j;FNpNHZ41PQ=A0o#K18;-P6(grJWm?2<(9tuL%Bl5Y|Nbye1^iOQ`Wc z8eS?AgieWY-&<*2Z*u>AN)`}_3(*FK`w?!|a2PgREd)N1+_>0D5m-CgR>ICQ=y`( zi<4%0F#w%aKFuGL)USW1)Wbu9o7;{Rt3*|x6SXGqn_A9O{bfbL==s{J-G5h}168!#)ElQZI(zW9!d~ zsSWpTExML1-wyRY(qKHU5@qv3p_TRJ42TKp8TpjKIji7^q3pOt)-c<+tN}}eelMAN zRl$ZP)K8f?*X%1^qfpB{n2w$IPfBsGh&m%u+rcN}KM4iqlL-grYh=~(vh#vmDFV>N z=kl~qAqJwqc$GiQyqEPWnx)@$2NT)j9%bGi%p30)G4p<4Qhe=A#tPU1I;M9-aZ6yl zg4XXZ=+cDCpXRFsB#P)Hp6`O;jfDnmX=u)OCR!mzNg65i^m>M4qKDzMQ1p^^FzD$nH4y@u){@z3En7}UPD zsG{o;3jgMzQ@^k?SK^Cbs+G^9CH&!MECf${4vvUjfchSQ5;#S%4!|Pm3apJ;0caj2UK+&eOx(pN_@>7e@68=6tsQL0yUJE*HVld76cN9 z*Nc}Nv|V{4%O6oP+L(MP7w*=BMgP!20mfuN^J?2Q9@cQG7krIex%s8`x8TVg=uq-aQy>? zC8-HQI2*0o8dqJC4l7%aVF{4av8#v`0qzOe)?HkraRk(-Ok1U>tl!ho0FlY8a)A@Q z7ym>`YnkIJ-?jVawkm~FY%P-zH=TSs3XOvk!r&8w(y3d6U zZT^y}Tf@cWvNdnFrK9o3uoB*k&~$#z8RIdz)}pnH@9_w%uY;1P@$WkQFp51UJIrHq z?}_r0#y4N$PWv`Xl=YMn({LPMtmmIs^`)bqwzAjpt#`t=3*J8K>R+F)*>SONt47{g zW0Soa6Peq2<-S!Vz=$Ud#E7|!ViQBBMHLwF-xy(s;_C<3E;@D=&>a$jBHr|w;pPeE z>DKkWli${d8;aWK zpS@d$iN@XYlpJyjr0Q~Xa3=KuAz7MMGqhFEuK%HlZV>%O^|%dV%HsE_Ym1l}i*5D| zQd2yKi!PHzSK>Y#2iJ?!Vy7W^vu8!qOFx29J*$ysf611WJJ6GP>_opU#Y)yae>6Mu;2cLhzSsrTOhYX}C`JWYbrL>Vo&`s30n zY6ra~lF6#z57dz*?~tfZ!bY5EnT{i5)V$ER|n!1Vn4@$tAaXjHs}ql_Hl(mdTqb0)yro zO>PN)^AufiyWnx~;LgRPx&#MG~USf`015*Owk&7b2=^`t+1}XdtiS>N77X=`mh^gJ@^dP(h=3T!X!I zj&z^_K6j6!C7d`pC~k(DX02RfjdyFm?t7IPyEL$wB!}7d-e127$2Lgyz$+QJvJ;N4 z#phvO=lgD89%ZC_DnYkuzZGipw&&lS`p#u__4Os)y~{VmY6PbWbSpU% zK1_|vqiGGD*DH6Z-n$+hcdI*9xa$ZUX_CZ_VpBhf8(T4i7*omEvp2laZG7bIc2CSVoK?7u(!EF;6A4buO3B+@3dh;1 z{v>5^CVr@egV7U|d&us-T_AB87nkRO=*|e!nDq5+9!granQKOgTaL|>a+ws;raDZq zsgT#{^|}*Zwi-mSu)Q!U6NQ^o@eyd8B={A{dP*i8t}EqsT-4Esr+wkCVu`Gc)F)NA zhQo{L&>Fv$3R>Q(YQCSFH64?8`zTE2aENK5yT!`5;!}cE!+(NM{*c1_c*1H$ z%9o^ZY7IfAMqrNKzS#sK7@;ifTB>n&i5MP->=~UIG`P%gu5;i-`CeSbZWsV>@)U)i zS^I^u-rwXnY9u|GyYHGY&?|amOwUb~ z;!FFwCiY|!;vQd)>Crf2pw~}~>907RNQ{lC!S}+|qqWOPwVm{8U@ZreJmPx-&?*W#6dq;DeI5A_V6An52B9 zzvRX#(x}PYHgQCEJ@a|-;_0V>@jUL}kvI=_v{2K7YnB_HI>Nm(Z3S7YCNC(UMxPt!nZ4O1Jg_SaQ%pOHqUMnI$u`cQI)Gwzxu_|7fj9&6&%L8B$U z&01X;WWVatqwO=)4)1+?+!nC|Q)keVo;Q{OJ}y$|DOsL=Q^%Ttu};UPQ_1JW&2y#Y6a9Hu+W(=Y3ghO8IXvx?Dhim05M5}{r}P3K z%Xw0kf2euH=-rfa(5932(7=w~a}2m4g8Z3w^Ze?ZSHX11XXN0CNL>n7RLM5@7~s-D zQHe$5+*m}@rtO3iEZ z?>)|36mZvCNAD%3@ZZZPJteiCT}A0N$NvZW)CC*jyfh9hAq7QIBes4U+SV$*n=bD( z`ibY;yvEO%qOP5KYq000%f9wyC#QRhy=!qbwqIcNRMDBJl{zUy60($a(->7(@N0Gg z3$F3u2QdA3y+=>ZeU9;M%NZPrpqGtA{t42nJTG zpBRed>6TodeQjHip3o0l(ngr44IlYz;l}9^mT_8jLk<1Nf*pS(H~#*3W;-m$sWR)7 z_^Ktwg_p_EQ+(#8E%)%Q6*_r0=%r5WofD-)(8>b z`KoS!nz2Hu7(vN2LW$?6*_=n4izk&oa6zCk_Y_vTc7xxrhQz_{wRBABk3?Z57^I5E z>O-aPsyW>oh`c$`OSYzwQI{23_Q7$U6N8er*H`$@@0Z@q$`|ccvyte5qvrp`ThyZI&);@vYo zGxVbNoGuK+t}kg``A}EDm=Cf~>b1UA(ICgf0fDbM3T@W=2Xzg&o$iIB^H=H~shu5T zACO);HF1e=;E>il;{dbKKExsJ{z&y{0y&?~aINpmWShxN?{9TIg*ioeqZIh>YOPs3 zV&EhfZ?((7zSe&NIx@vxhHu{=orzFvCJ`??VxF9c-^i|&*;c;eOvmH%am^jLJ{tL%6$3I;k zDn$oRX(p`67r==TOXZ4o!-JGYJB9m(Q{uEMgsxgO_=R2FY?lSu83W`Go+A)1ft@V@ zHq@*_*kp4WP)?J=5H=co`huiAFSGIFowIV4X+75Lfe#C3uI`Q|A(Z!C7UEw_!2Wx>ayb@$o5FP{C+Onm4Ulr7v|%c8t>m zLmMXADTCL?d_pa7uB?Dmd^$1S>q-W(Q1WS1#R9_?GMBP=O)3$t#)^PloqIT0d7U{01Q;bDgMlB%9bdAaFbL z7+QAmfI4T{C*SuR1*-Mi4;aqlC8X6HoCV0c`NGmW)vE zTuJA{KQI{q+-gEIh&-wB3v2yL_twvLl*)2X5F8i|nQu8gO`sR1V@3kC(8&44{K+wd zgXQZ@p?44~8%yyLOb;Ziz5T*Ft-=`;xjQ-+oF z(DG`o=+};wJiF^Ei&>-F1(~sE!;mfs-I5KI8p5kXBgt8Jzx1T z#>?qT`@mb&5%{_b*RMsSI+xh_i4X$@)+at>$ z=S^)wJpEhJe`owQ-Hb)NA7kh&LI)j;5q;h`6kZeYIosdy!Rikr{>Kj8#J*p*Gx(mk zPc2_C41$fRpV4cfE!KhbpPY{m6;bH7ZtUGvA4~u(gml_|E>ZO~l#rs^?jqGFI1*R}TrfA47GIFW7)ja?6ELfz8CB$|Y2D@`2EDRaD zQ9Kj6b|{39zvS-EZYT2#(18FwAbq2{z$P@?a1OQ7~v z=PfphCrM;``Qznmu>RZIXHZg5z0`}MFF|u(#lKPTar}V*_7P#y@qRo^f&}l)024t9 za35L@Nxy_MG2GO@>_)Y_-!<%1WF7orb&2LIad4pc(1FuP+AFB6`1hM5hFR_rXd9Dt z^>GjGs{e9nR|z(0v>5IjG`bYZv3Di)9jy_dqocaxEB%u%*Di~AX(VJ~ch$u|%1`>H zPCY3~iPdK-Djs2zXO{Z}Dr0A{L0cB_Zj9T1`va4=O4H!M&6UV6)xLxF4{t9;^Lp0u zRH!?;QFByG`rBBoDw1PE22=J$up9ly+p4HdZX+J31`s^*%oFS;rGxAXtITfVQgcd4 z#pfnsi{63P#MIO>ALFE9fjwMyGJmX0+-ArUcQx(DKDbItp!LH4 z9z&XWwr9XDrK0GuXkVr#Bozd<+X~*ypLP5Gj7?wtpM8^{oWk48r)jte6LSKH-@W;Q zyp!%MS-|>253EEbeQzSJNPv*G1U~l4#$FT_w?aeu7U`JUD%B`7ECTmG3eX&W?d6_& zsnRmoBCmm^=%}S94`v#d+le4zX5tX4pBUlc-bT*5I#L1uX-jDj$nMqaeSn@?SF!tF z1(-h~T<9A#wzJc%uJ&7K_eXCMK%!wf!Y#9*%NL`}$HskuChCs}v4WdFZL9gM=D2^^ zL~$h0qVn%9;@6fA!BmepLix52X)yA&y(eT8AEr*0Hb(5C=Zcc?ub40aq{lz?m0LA=N14{oH=aZLt7Ht+cC zoA&4GHy2QTZ_J9zZlIfd&wxd?+pJv{cgYRUYsNNK2#{Tj{* zX%&XM&@XlHrQ-xPSn9Bt*WSpXDyCG1lD~s^v5CeIU)h6nW*k}xf1BkgC*e`_N3WzlsSf%5jK&(5YE?h0dMqKf@ zynjt^>en9YgXg+5LgoBu6Qm}v;wU_74kQ0eI9s1*&cO1X`Jml1Sco_XOT~jzP|~7eX)4?emoQ2nah=oE3QGJ zZ7jMbT~dmARxaORwgfacf7XLyQM~WKg8o2zUHs0<+7T2fQKo0s^NKK};j>nu2Z;Zv z&EfO|9e*c6;UaO8`DKcVuYqaC*=$j1wR*7rHT zs>Ij5nehO^;5g|1>d)Vu49D>Je&A&V_~$%1k&9v`*zncOIwfG2guVxZqBDUs5{Mj} zrF8eEGbGP@+rtoO$6C3OfZO2zdo(WfSn?LSwc_wS!XLg8SXHz zd1fwZA=$Qh+n+A#zW&QSv)+pmJm^;O$ zJ+esn4NvJ~6+qLDV_IGJwq*&g&hp8ipJ?%DGix}OCV)mVqO-z58Mpg*A(Hr?-YTaf zj4yE>8UhvqyYYl5y5bd5?T+4O?f#bWLZ~#g?jjL2-IAc;>aW+IE}E(rxuc|Nc|1%x ziw+Bm7#Otq@}p#)2+=3!^=T~!L#(DYd)0#-j?&8*JR3mxEo(J`B14mIT)XU9jy2uD z^{Z3uueX-?>;sI$?&t{DrxQdTUZ)B-mvbtEL0s-OFD34uKLyslCK^+D%ZZEkvnQ~( znPZIB@L1Q6iHoC_*PD>f-=^UFk;rIE9I*WZPrm)$=Syq70L~{Jb}OIJ>_siK)!E?i zamoAbsepCXo%GL(qI2V-pAc;~Xad_mIO2z7d6~Xza|=Lw`~I}oj%$BRyb&jR?NE=+ z3R@?K{mMF*{qA5?3yZ}}hki`!4F6!khTEL(#Z1Ygo37##0WMz8x)opeq|f)r9Zw{J zUCqMOp}{H{!TM?n5VErh-G`x+Gm54kCnyrvRh9abwZ|IM^2mmQqoa%7KT`!R`i3PF zc!kYSAR(iq6@!wr;!}^GgW)NO-F8Xvc1JD2Ur`piP@JhFqtUX^DDky*^k<*B=e|F? zDL7W8e=4S9nF|r;!p{E}Zb!7+_EP@hlmuDqY?cT(+ANn)-Apqi|*QU1be5_>Sb{Zi)nalEoxKor74F$hX9{Ms>& zQUFZm0#TOdR2jX%%AQo}H9y&Kbsa@6pFGX7B9nxW!ukb}axO>r#M8%LsqsrJo>bVqWq|ZFfewD!k~Ax zWQ!bBB7?NEgKWk6spIVI-m>OO_5#6r;wy44?KGD6Km2q>&yRFA2)eFco+i@}P`=)E zwcn-|#IVj;MJq@Pc!IV6#lw$eo;>xnO!`@Pe@kBPKfH_7tS#AM5=8lk(MeaNr6JbK zO%f7kvtaE#JF z;j^1dR}?vN#QTFY$JPr8iqb~OQ4CcOcZK@kVUKcp zb2R+R>xbDQ+7V*=*cnGT^VMS(wC!Ezl_gD z;sIX}zS|*$MD1c^p6_a-b`mhVpP_{iz>0HMwk#8BYp@9dZ3jxu2&u8^(AmuUjtcn$ zFI4YC!=ClaEGsT7L^>4?1^$~)ugl^(%Bci=!Z?St?h|blt#vj|WS7U;U(pnzPi3gp zrVj!_^j{7sV11z%tvI5OQrc)hiudIHXf286S^s`{J=JE_)xS6(WV%j9G}iM zlY~GzumVad=5DLt7ARL)DLs?3-9A&SS@EgfD^-V*?gB4GWW)2X>JRF^iHf}P`*xEm zsBE`AD|Zevtg@#CjsZt*MK5Kms@tn~LEpTR=qs_5vq z7m@th9_Wd8POAd_!J=>Zl6F_><$N|ghCLjj^rpiui*_-7#KF#I>c7Ym>l`n6#jTqw zfs_=!Go-1V=-zEoXLW=9EP(q|#s+b969R)X3hQzPf4_~UqtpNl0oRrQ6XtIXCUxxh zwG&~KAx^Ikqp8h^^7nJ?cuH&kBCf`5Ctg~H&1u@x?RTecBw8NBZre&#I8%r(FFlDT zLW$lE%5+y=lz!Q7Jrj${TZI=Fho0a1;BuT{TH_TfBy+Zskns^u%on{-%J&Q>`Ew7^o#Y%Zr^vJfCMk}tf+KPx5 zag$Bc)59m}t(LhVEZ^Rzd~Bm%-waPP!0ditzmP&UOuv72pqnpwmk2=6eR84Z;vx-j zJip>?gI$4`7UgV@ipc1CskhdaQOe|DC~GIZ7XBs$hGg7+A@nxxLZPJ9-y=dENtp`K zZEaGlx#ooxGzZLfM+*4c(stAroDDHrx}0ZdpSCq$l(tCbS1yuG*^p67vzJ&dXPP<@gM_Cx1$9`EhD;^^pg?7pDW%Uey1;^WIw2IX`&y&{na z!xGrGmm4!a^v;jKgg9IyyVQRsNqoUO_tUPhLSgx$AN$%Wo+Yw*+QAI(%hb*%heJE7 z(;oJr6cXCi4q5`WH*bTkl~>ADw-JgWYQ#q^HIlj8RP zQ@NH7YHQ?h{YcQeFG2NQJs;A*wI7`^v?b!^(&DiGVgAc}djP=JT!U)cmN2N*uGwQ$XN#zxC>*o3_v>b$dA)3+>xNpa^87n!`$wa*(j+Vuh8!f^t@;ybN znM7E?@$PIbbrR?wq#^m{4K(U|HSdRNgB7m^1D7eS0e zE=zI)Mm8k+mN{AW5D_LxlvZV%bq@kBk+ePE#@RBdspUHg5I@*(wR#}zrJrC?P*vO< zuS>1d)Z8sk5$o{cq9y52x3NdrKJ{vJobR}2yy#w#AYGlB1LcomYhaboX7}a=QxlDY z-5Uv?hJ&le9@o-j!?S*QAgOEM!BEirU-6}AM%zH!W%CYkqQhtXUHQ$aFp111Um{(O zjq355JB8!5ifp@3lKOG$=@*JvcWMMB+^`DwgN5pswaRt#la89%_{~huFdmf7Bt24J zVstRn3~T-I$kX*cmZGS1YE@at4%As7C-E)wy@y^rtX!AzU@?M3ZvNFRdR<^IN>#0Y z;Cp^yhqjx`%7SlU7N5ah(xP@)i%(*O+iwJx|Nel$G19hC^zwasfqHIX)GyhEZ=8#^ zecF?vk4ktHu!4%3+k{K$CC|?W?cR?EXEXnK!c#escJV9gtExYUDsS^%FkWe|SS79M z7UAT70saR8_-oR)mK%b*bW0tRfhg(T$dP^|7#W&dI==6#^AP&Sa3CVsjnEt%c;Z{9 zX;Cf%Q8O8clJ4;pF?_GX=3^ayY$)bf>K&g9f6X;b*ttu=5e++T@7bdS`(P<{yI4a; z2BH)dTF+bVIP6-YtRckLg7X0nr3+zoUT#ZpHA_eM5w3goDHxhACu(;@2BH)d6paI` zA~u`o3VvwR+p7|=Z=VuuxeXuQy02qr4$2xo-j6rm()O`@i!d389g~45#gxvj{f)FQ zzEFZwVK$W*g8L_;?dSx)Ci}6!E0{95CAi_Mmdz^oMzmIy1^Z1qvTL`zU)Dkfq9jGo zzg|lWE}J|R-v7D!{e)mEk?(rb5q_(E&PYFsi#vW9*By!dzSU5Wfhb84)U07cobB5U zngS7RJBMo#%3Y3PnG8fpil7z^Q-`XmwTGA1UBi#-V8^9sAOlg7bS4~AhZ2oC?_AXh z8Hkb;K?Y;T(}#SW*o=su39W@C3_GUBA{mI16hT^!3-xLYIc}H6jUG}{Wgu!H15uJ9 zh#%w75k8T+riPH*!3MlMIQQpdsfB1_oPZlr7;ALI8iHskR+&k_Iq&h`mNK5T%&n4^cyC!-lTEG~RwY0q39Bf@#y5 zzj3C?kRjS{T|fEMh%a|37%{>hJk*S@Ug7fzE6NSQeGausA1asZX(adf-5Yq@(~29@G|Sm6b~H(H&=xko|{R$v~8%g4ToR`96VqQM|$U zjX>mna8(nYc)|pKApEuRU4qUzM+Tx+G7zPxkf3pc=)*c%vs+!QpuV2av**|Q4f%P= z!$)WvTxES(YS8+%v}inUxwiK7PiRbt z{9L?a$8pL57Jjbv7hf1UbqIZD@omMXOwq7-TKb9%#CG^0=le8#oYog7$9FE-v$x|u zppF*lYrW>6PVA=_F1>nc|N5NVdODr-A%1M#+Hs4jw)XU2>%oH}`?cYp+kn#?XTV#D zcP?ne@)e0VawPOybMc=Se&%&&_E(f-Abw5yP=D}1o-;8R`AKlvf5`ZmxSy;uqN8Kd z26phAT1N`cn@n7`N?p6qtlN*KK%Hirj&E0o4h`Vun|#4h>F$muqisZ)A3~Kb zcgOA$8Hnxhxn5UZ(S+w;ICbJppWcjd8uzJd@5(EiaIjVh9`dv%WBjM^FHNl?g555t2 zkG8%_2lNl%oO1$QzMZ?~8XwBG7;)eL;pkBc&O78^Oa5kd&e?vPeRjvs%l8oEZ!X7= z^Yy@rruD^IC<+S>pmg<3Y3jFHkdxhlFLoNRV}}xK#q#ya{O5%qp4He$!7c$Fc>HIE zA0d#E90;}s$B%E3qsbv41Mw7izt+^G(B69T7YPr)u7>Ifyrp=eMWJ_ms!S+8=l1w* zZ}!B{d~bMw^y;OrTTA}=jUD3;K2CP%5Cxa*saA#r`N|E8@O)VrS~@fJLXxD8^*_ys zbsP!%yH{8Okpa799!ioFN^3TuF5K4$OCTD6Vtw6+NRkxo*0^yX+}8+8Am0BlKKu1O zsN}OC!twl)B*mq&a9xNpYuv8sQH2MZz{#^}!~*HZ4mA zq9jQn@#doE5ca7RVGG1XPvZI26YxC{W`^q;k|f22h6f(U{gJp(*aC5F30jo%aBW~6 z?0iL0xUM5fQfy_tyuOC+up*6E!@2&^uIW11cR=5va}c`ZqjVo(5&8Ykl)k-A*i z=eu8&kN*Td!NZL3({Nu)lBC!{3@(lq4x? zdDd*gZ$7BOUl%>s(Z+e96_G$(y9qwS1U$CzHmqED3zjqByPextS(2nU@vhPnSAN@p z<038|(Iw)G9>>8&kK?k1b1-4yjreZBjBG-1&?SklYnLG@NpYot0f!0LNerd;KZ*C( zZp3SA-4M2^r;t4}7Cnhg{2}7nk|ar|BR3I}BuQ!`15uJBsf`RoNs^>CG7u$6lG?~X elq5-N^Zx;UKtzBFO}j$?0000rBk{E1Vn0)1`&Zpqt&Qo1_?RvKwx=@O7wLZzGc z^8G#UA3P6m&z&>poH=u6KJ%G~S1@HFd^&s(2t=gvLh&^S1g^gS!NmstGXN8O1OmY| zRTSm4y)yTf@cguYw_yL>k`gvxj>D@JNDEP~m3?CFScY)kH8i5!#ZC)SQimKEmQ9bF zv{$lgmdGMojY}#JGxMKj7<$>6)66yG}t;l6$1w!9#W_lYkdXb|wr1nq11{huoMYPc*R94f(hEFsnGJc2ACB+2YpoFOD# z2?fXy5_$auid0~@Y666OjhjrmrNq;(YAJ&7fI!}iXfY+?QaJ8~EGtqBbklCIAMNJYi)u@Q7G>4T~7gP4Ta#j%=C{WssTW2TaY3*oALkb42@2Fp9Img(1v zBm^%k=T~*|(t0-T{^~p-^}oW%bMC-n@}h_)8ApJinO_{3WPQO;A;lQ)Et{fc+jdXc zr*hxUIgvP97CK^9T242L9eeKeEn4oYa)l_V1V{Wmf(0*0)Xzr*1yCWWL7%bV=ea0z zLFn49m`ls8e>N^sXr9gag$1LF|+@U%s$V3bA2<3e1Wmkw18mBWIW>()P;EI z*!TVXQJw%rv3wL~d=2k-fj@5J@(7yaMJ&@QJe}5<{NBkt?mdn$vNuw(WC$aLM}chU zp3=- z7@|Lh>CY&i9Rv#R^k!%!^J!(Ng|o#&$ba-MLMt`Y5*^9F`DE|!wu%CRmez7<0a0x_ame* z=oo*Q$OJ}^u5q^-eZEn+4SINC(C2@N*HRfPt8ZstBhzHNn7E)5MiH-!uNLw-|Fn<2 zf3!7YwIcJ=svn=ZCS6b>Qk(~VCbN3nEHgdm;i>-Py{9$~dAtHJJj4y*0ezbODXVR( ze#DyWSGU#Yw7%e`%rARlNPRkn-+`_-+eP%i=wIsiYVH}!Gc9XZ!>&uX%s8?M=x2Eg zb6_Y_$dUhTu|F*KDycO9&r|d`&?^fKXMF}(&*Mqo({nHNnSNKf`OhI)LTSu5Tz&QY zsFn~isVF&Syat4f`rl?5jzW#yPtpQd6Q3^oFa@XtCUtNqrn#^H5iIi-g8IJ8JA(m9;Y{T(4OlT`6pLO2M-ZzTr${AS&p+JmC$qExa&61q+9v>zX+six5x_Ga~d=FYSe=k^q zf8nxXQJG^uhE|av5pa}B$TnF!xD}~s6c}vrPr}W?LDE@k{{16VuA@C#&^HHiPN=Yn2EscHQd~(*xJWTaytOq^27TK5`5CpsIMaj z6Q@0vT;7XfdS8>TO%Tiz{sqXc)XCj1_|I7|Ftn{nS$h&_y}koBVtJPTTTfJ4mvg4C zD~>|Hxdq2%$_f@fHr7fWm0cFY7GmfgkS$TEQQuOutkLOz_O-RE)({n;I1>*cVC#Og zm>(gS9W)6@>{5LFunkSkJk}s1J%(_0_W#4AS0r#!Ld+49O`%Ui^UVG|6{5S@NG^4u zF*%cD&84PK;**r&;@J9;@R%bv-%1`dPFxyT4>LM6s!jHp9)g&qJSaFS>6)~pkgVH;GCDWgjHR|pg$)lYZs9+aA8@o)M#Pb3X$X7h)b^^lvD`7D{Qnu_;LJ*@Q4Qf@J51Iiv`9A zH~VH9U|poN;soHw>g6uh&M;nYO$42*`)fF-=vy6FUPrbWGduy!C$E_5#d^K8gILkK zarn)U+NO5uEXcwmDBOxeBe=#@x9@RF9wd%(4GDjR*|$GxCZ4!!XW~Zp>4!%#F?)wP z{_xvAFYDvw{!3O6b=fFuh@hR?m6CkNZc&V%-ofTn0vXD+N2~y;#%{Y`KXn6M65k5Q z17;0(-jPG*v1@odM>YOx`gIKHM9a$K4?oRZ-b)k*<$t>@0c43k!Cu;as4d|!w&zOi zC*qt$*r2C|KaBbCB5eom)8<&C$r93XQ@qtc?sRUqTkzm_8h6{t1e|7z1 z%bB&?La1QYc#2d=_p}<*e8FxNSzHM;l4PxUHoRqaXHcaCrqW8Pol{0kp;Ca9cQ{X{ zO#_<*Vj!7O1|cLSAGg@Od2ycpE;MPVYi7-Xsu#pT2y)M_%UQrCcVa;uz|pX@QemX@ zE`C12{=`dFHcG|=h#82_@NW8?Lh?)1J|bPRPjdZ_{c9@G(or%z;2|=83px>>?M;t7 zSbaYuAMTP=AB(YU{$=m3g!ggao3U@ToT%WgGzxt&)p8fJ=OG-RJmBichd_uXf^akj z5F_t~)6Vnx^Rvq}v$%~_f)Q**{Wq$hSbA%!|Hc`A8u=3_5a6+S9_szOO(zUb`89m0 zCw?oPa20PrLV<FXrff}Y3Q_=nBp-YBCf9MhKt=dg##Jg;-tj>rx{$LlPfhcAK=q!yACg10QdVT#jXKXEWzZRof#DB63( zcTzHHfp`r!fzP5ddEfyIvYEhP;<{PDUk*2{zk8*B8G`21<-+pFTssoRv7_t6&S|G>Z#sTIGYa z`uH0s*O4iVTl)Lm(*LPlRP^qJ|Cimu%e>m(rQQ1R6#CYJ+&?mEUOd1o9MTqGQ7j^b zz1wJ$j>^vRp4j}{D^L<+A@UlYwR*fs0eWTvTysVJYSQ(c0@S2N$xE}>rO>D(V5zV2 zxVGK}{m})~AcYlE+aKFyFAh&F_5MC7oz(wGp^wwE)slew?L~4pnY{al@Bd~jM;1+t zZ*$Phx-3}Ju$bMxUg)7P^?;%!i=wBwdp z0WL0lfJ)f=@1M*vH`5B3nRYHTat=5uITv4wHKvkLAeH!DB1tDB<8U;kA`+o`RzhsS z`5GQZqc6=rTKJC)+u{DADQ39OlUrv#AJ${&Q7b?alhRj=Sgb0dBtl5m;FuenK6NVz zN`aj$SRQfgu~)3v@3gl_EvceMl(xUgh$Y#_D~?f32U;w)D4(gGRl3*s$KQM@U@Bgjt9zI z`_nnZZNrKfGV$lXS%|}T{4LOyh>u@mi;44I_fYF!kL;v;53{+~E{|=1+Ft_1Q~lz3 z$F{1rHJ}wNHFIM`BL9))M4&c~CdWGhzN@GFJ2AgJpB$a(-0K8mwhEAT?WWXoV!Rm6 zDD5sLslnR(CUv1}bz1@KC$8(m%LD;*5@2*(dS3;2bQn0NSZk39-zGza*PZ^`lJod1 z$9j@K6lz^NSOF+od7eZ3D7ihL+mdVv}cm zg@%#HyK`XQhypI8>pvG_jh7Vus5-=0Pk=MQ9}z!z}`t8;GzuUI#WUJqH{ zP9?UiTA&PCRrRS;YsNWtjSKEq8}cysZh0GN-+shJXwPRnQ2>Q`;;ul}OGHo6u{3wo zA4yo{PgqW~@mCQjxmJZ)s?Bn5b@u81ot8ZtT{4FEGGT$#UoOm;oKGhqj$xu-C(K2|hkT5G3`T_Sz za@`yz94EL*aKb}y`iV_`-)xE80%~fl?0=!F+(KDLak*RI-z>=0Ko1euIpX*?h=Xj> zhSL#bSma6g<}WVfIfHhpygMl?k=J4i^r{L! z%|Wh9>Lx;yM5l>Bj%Ms0+~u{p-hQG&fz^R<;SL*)W9ZBXa%R{WdhU?=`|lrT+8^oC zY8LG8b`gs;AR=Mx=|+YTya`RZr%$NdniUaol@uOWg<|V;L2Ei&xC(8!`eVaP z6dJ^U#J0M9kkj66h+N0rJF0}4`D`O`%-`7?YfMAL9p-Y(e+y?dZX*ZbFD=$6*7?IV z9Ow&JS;w%tb+^*=UlMMeJr$DG;F>@Shp0E5hnBIFBJ zx;fG60<*uZ5xA1+g#sQMtpWOG#*JEt5$PrTN@egi2;uU02W}H<6M^ zNr|_xkh=MP)icaM4MS(Su$an+fVY0kDf)<@Jo`Z(7U=bF?YGT2LjT;114{)Sw;3W5 z)+wp5k~apWQ}Ap{-MDKhW%1@98amMdKM3#{z42U)=L%=+yWq7=NN*rYhF+U*Eh#`uLH*lCtkd+x@=Kd~el z<@1B)_Rel$pK)uRmTmPxCgD#Qn{Y^%N6BZ= zD{uf))CI~mGNqK5P4OgxK#VT`7R)Ck4J&Q76HL6p2c=*PB(>)NLneHG3Dy7k#YggtC; zxQH_!3S0htiyf8pJah%~(McsrXl^Rn^yz zRziwAq*^aMP(mTNUkleX{B?S>Z!v;gKiuOYxNfqaEi#v0X9>I^;pEcUh^l&NbligG z&NAPDU>uk6KNIQI$6B8UGN|f>HQDeOJ0p&pbRe`?qNTLyh!bhWjjEe(tS4Ox|K5q| zl7)VlWj+U?-^}^~E{rI3b0-tFINf{-#G=Hfzf&*z67&h8GHM!2q(;T)SKB}d-(+!TAB{j1R57J1y!l^ucK`}qDx*OVqo+0lh$oNGFp&7yY)V9JDI^UogJ)voEIBt zAI$I1+X%kaNEMb4$+?P>u(qZl2rV9^;q*BhProH(Hbcb4)V>Xtuu|{9M?TQm+QyV| zzl|4jlhb#6#HHvte|7#NxpCj}5iy@PWp)ON&Aibb8eWCD{?=d<-Ta&As5!e>h2H=b zQzb&AN<{ZpI_S2uJ_p1iT>&S_!?9q;rV3%Kx$^9nFfHcO9dUpe)j&F-Uon3TY3{~pN2nOg*`D? z>SpkQ$u2^)QlL_sp`e{l2hOz-vy0QmhmwFsh67=tT)4}O=($CWtLlK~$*xe%owue| z=$C#ZJ0wA8OSmp|@WUAP4wWuD4) zMHy`arimyOPQzz0L4?FqYX@H-R`knak}Pv{5!ft>Wx%)W4noZ?*`Bloeuk4%r)~WN z0}{#!M5;+897&~h^-qr6~%hC ztfb~%8DA!$x%=OwLDoSB^{p;PWHBGtKZa9j?1_LfMd)pYkxm*(0V=s$=bOW|M@QNezhJoiJ zjj$+H({)(ML#;I(hK)A;JS5WZ3mVr1{>m>v_fKfo&*vz)5E6#T#*raM6-qs+g2eq9 z%qN@ibJ|;ISb3uBv?~5yvmmbNwLT)MQg*oS5h&Sk-vIYtbZLF36PBPake9`Zs0)Ax zuE%n-dRE;)*Rkk_E4{}u*FdcOhLcb;E1$PVjn1W%LO;XzXYq{Qgw%^IM#H}l38fDt z1l2@QHrPs}B;B%vv~7oEdC2c8Gqc?S#cokTFE`_qtC`F()BcGFg)Le;ph(5H*ZpzY zofkWq;7lLVqf5du&uM0rkpb9#%|D6szd{0mv8uJ3`1&q#7|K~YBE(3wjt=P8#-ZMU6OKRttMXhY}(?Nqv{upLpU z_J;2MBgw}cMmpA%{r&od_();ki!ipD1@T>5Ej1%Mx(WJSHNyly>elxlm?Okpkv3n|;7h4o=fMTDeF{Z{3nlLL-^$(NlPMBIm;Z5(O-YHV(a%^8c z0uZ$%%l`5WAhxnvttn^_wiC~}BkD8wAc#atlnVP%YfD?zmmSyiE{eG4=Zx=y%f^(}%r?=QHmIa8w)(`nzgkWqpQ;GJy+r7$ z@sRi=fyA59BOVKvjk!x*1*I_K^TTSC@T7=(_JhAWrAH41*0D-za4Z^B#MVR*z!!Tf zPU-9xO7}kCrWz%oYX6hkKMy+OsbZLv1!?n;&WW`iKld+>}a+olRN+RyT>$tgj|(Ak1j{=sFv#BP^}cJm{l_xyChH6?NE{2jG~n- z3T*o{Qz`V7d;Yi+6&QY@3(!b;JM%Pvt%h~0vNcR^)H$;=Pe~_X**mQd^oc#lxP7y^ z)&a^T9H+*-?s&IKIZ%CdVPt9sq7HT;4PKoY;1;n6QV~hzO|xlJpKLk^aQ`hD6us-P zi(B7OS*$j_M)-9aLG{ONqb(Ww&O}wVdao~?u%oblL+-)8Q3wBcjKBk)Nh*E$irt?s z?c>hZC*jT2W9sVx(i?@t*Oi+bG_-wXH>+Il*Ee6Ff22pnC*{jM zoj<=T+NFDVQR(b2=htB`7W5iB2@SsXADUHSdZbD(hb9;j4Ze7%958K2AfPPr$nG%b z*HyB!Q5AF`SIr2{oEai0>F-6;mv)uYImsnT7C$gX0Nsxm+fxM-Vco35v3B@y-A8J-e8 ztE+KN2k?w*Sws*?+PJUBG+SHb(2~i8dYEG~tCD?82USR2nowv5BM*EeP$qAP2y2@R z^4Q$w?*)BE7UP*JGam}#RrRF(Z3a5%R{`|v+mgwQ8w){`3aF|z$v_FTJ3h4*dAVT) zWpyZ;+Uxn02@djD*sRAgb3e_Xu}{H*xD%7!VM=F`W?u6uUeOe>6ulSHnE_cV#f0Su zut}z9e`$W(f_C}EJipo5GRJgcVaJeZ!2=*cF4NRWB{8lvT2%T$+zCH;!=to(aWnVJ zT{;z!*QLI^D>h@Fh%34mPVB?us&-%GxzIQAOfkbOUp>OvHZ1Bl`Hp9T5*IbK9lwnd6ii%RdVd9=J&`uFwT$6A57FT_#{i%$yio5k^UvcHyBub z=EDa}-={>kn{{FQH(ZJ@Xga-km|=L^(&C489k(HDVn>c7n|}z7{rs(c{xN>#8L@N1 zvX{y5%V&%%8@{~uWyg#nsML|&q%*Ys!31Z9tA4J*me(IOfM=oHiS12$5#IoXr$ke>(kp0LbaMW zcWpJ*i?jclAaqH}KbhnFnH;Oo|89ayE!+v$mHVUv`Vh}hn#6)8!C5Yjn`g1rJ2@n* z-wJGV>OUlZ`^nIfrZIngy5ex3Up7p%W;^#zV-Hc%uy}78P(__K4Ylq!I|lb zpzldoH=;x&tg-g^g~@o|-C}iVHR|6g|NfhBQ~CjcgU#3CkP_|CcUkfpuB^~QTU+;; zB>vN0MdGDvOb62m%z3n`OpA)L42DACPG`0a!t;^0h0uO*A+I2|W$`F;h#(eDYGfv* z#05y85o?db+|4JxyhWL2M-X*XSAx%HzLj*NXf#+~av6NfR59x1M?ZH;k#$r2>y`aOnc41goBH z4C!)gw1oCA$Iv%@ZH+GZp07W&Uh>CamZ`Q6mC;F^Y!WejEjD}sSi9ht@~EzHvF zPI*Os_~fWmD|z^VPEe1?fl9%;x&?(3>HK zpY^0wM`xa;u1 z@2T<^-XwQE;l{L`(SE$IAS?~Gm%$9Ovb80Z_4t%glhD4G1d@g;KXqC6-ny`XqY0lT zz!uU@OgvojWmcWbN4*MFHLxV~OeeU;^R10PAN-*lZwP?5GuciNM2QP>krX!(dnAOu z6}-gwZnRQ4%q-vKMJgXLVM@T^p{#qyrU%WG&jzzUkQJ zA!uAdtzc64FY~XYJRmqVvSCx>=6zb5u@XY!v|;ao#94UVv+Y~N*o;OPVn#AW6D+dqF17qhHcl^k=> z8OAoI@XbOLg`(go&yaQBE>W+gGwJ$=*1TGeJ39cjCqvHj{HrtBB0nnWgNL~==%_oy zY}spFv#Bg^#GuZ)DZ&cY!lT&obwCwXn`iDn@0^DkN$I1xoKWA(bAILG2ini!IIp}m za}OC3FwWtqn2YOi(6HW?;T)d>J#TcBU~Qd&vktp#v{=zF^=!52VqANMZi0`b-#02- zJ5ANg@^y=17Q}+I(y`Sx;N}enNo1A1h`6UHFgzJz|8L=Hj(O}O=Nm#FFX|9I*?D|^ z!#h6XGs`C&kH*V_)NR_^-z+9|_}IC#`NUwGL&8UKqWG1IQ_#hv%nUiltH#@KuATAs z&JEKS6N`r0q+U1Djn)SR+W=CK^yO4cDOhROXC(+t67NX6U3-8UefRsW;UJ6o)n zm`mRNXuG$scwmLTDdZbp7%`*Ma8p#;Gj3jw!yLY##D(dGM0?x3?(ko)vc5mW>U;5; z;rrS$ws}B(#qW`m{OsgBvak}~M-^h5t60uTUjJ6dB=oF{aBxCN#xQN_TR(TbDL}s0 zm-)4e@>lewO?*$i@Pc>lK3_%)cD0sHQsdx+9ghzAJ!J{J9>euxM9P3)P|Z-SD8X3r z5Mg5-?^FugqawQJnH;6gg=ps=x4yQ}yy)o5JG2HrvyXbGcB%~{CaL6FeS(?`;ZX@@ z5y$xQJIPd1a5XnMIZ@Q}-({fu!%+O=AJe^tGO$C~m56>@c&-WI-!oS8j}}YJuzNn;K0j-Vt%7FTH=HzPbr=-Ien7`&PIs10aoo`1Y+ebP9G0&+BVn zzSxM+VL(2_D$EVblzRq+FqXgMZ!rL1{Q2i~9fm*#jSjxTt{B>3+TR*NHZz3e+L3=6 z|HX~M8`gwJ(V$4x9Mp4WmfN($NY-o`qfP6{k>5hTm2>>_PMdjuZWr{tWR#945WNUW|^=Lx*%niZ@)LsWXHcY9G}LYHgmSjY?LkA{Jyo-3x?He5b_9`^@9 z`5BG%=RG%f#jDcE1T)9}89Z~J=-4FM4>HNQ#SUpT?BQ$z`6+D9evG&?pjzr2$`cKZ z$kGRo5AtFTA$6Lv7v{wh|8y-L#{l??d)Q@Ok}o<^cbVYJr3}n@u>~{+-d0Pquwnpb zsCx%;KLDvGLL^z|^P#~cOEJmVrNJrSZ_kDk{l8+TJBQ-cuFd+zLCBTA2l0_}u)b7B zuN8%rr=1jORPpw`Js13-FOgYKw;KQ~X%w+x`Gs&b zj5#XFvTWEqyGkz-O$hAD??DYnvxH7ceE3RV5TOtisqwXJRquq*%4bvM| z!;cFfN6vs>b$r%jKjJ@sfnOf2vH9Mg9{d5R<=Cg>1e7ENBh_sQN%Z=*jvB#N2TKc*_u=^(|+qeup$6U9T z>-h&q5MfxA6&y^ol~^>OZ-n6YE~%NTk(!uw(kTTmqUq@8(+)FzgAUVYrt zhHn4l!p2$lEU-c9sHA=A2!n({Gs*Pu!PXDnSd}!qn`{&oZ%Q_{r$h%WIq_Xv0Z@VH^w)CMoP;#{ zTuuHD_TOiVclI1>`kkEU!Cl755>L`ahXkl@>5f{F(H>An!G>CGV6m( zmvSH7bHv^|C&l*cbFXg;d<^~!pQFv1IrW4`!)N@T57H`ooPhp-Y?S}{7y7W?mR6c| z=^0l$2$R|&zeNUDJzdf@I-A{k_jX2wUU^iE>a3YdTHt6$x`n(p?O0eg)h2nXNsw~} zIX_^xtE?#HD0*rAx>u(}$2M@O6gmRH5Xz)jk6F63gM?lF@<7cNjB4Q=2q_B}IhmlS ztiH+jXCydR^O{CD8*Ri_82{X!Op_ihs2NJIRM)EfX|S{QsZq1~6BL?v*g&N8+mr6k z0~5~^L8Hh!7>O0AR^Ll39%9c1c$O{D9QAvGQG%17uk)u(Xjgu|kxU%)5Ct4DL}kTK z*RC`&+A442`?`}wm|lg7r*5;D&@X<$<@bgBD*)K2iejmFGqlZR4>C^K$nwY@ZKTE< zc$LL-T@Ao!CZ!muv>4Ws?HK`PMAuNv2hRs757bN)I>usma;mD-BF2Bp(x@O2%*Hs= zzmDI|2--*lQJ?NgnI*8*Ta>-@w$lQ59hm?(styFx%;NeMcPMOK$Snx*7}VOR8E1%j zmQs^+g=2D)b+uJE=d+<9{Q-~qC*8I7Ox!FgmZcR!ls~P*^BA2gf|c;>0HyXVQc`C@v(EAU)CqL$q;5C} zMS(}G+zbaJ@12XCqw$ak;f?7!T^(9Y_xWph1#Z0o0y{FbwywxoE6@5e5;!F1+DiZN zWxXZ53j>TaZ?)qBIj&w9qdOVt>uYJVC-IjiI!Y;Q?wP1B>a=5lk> z3v19cy;ys%?wokw$3uJS()#vuUiaZqpCN$9gF}*8%Ifw?@*}-Am!MLxkWHP#vqe}) zE++q2e2QiH^ax&^@CqBlgOjf18QmfQDM2a{u3<*%$=y~J8O-A&x#scIgs-B_xHVoN z+Mdt920Ybewr_9n#G;If=P*9%a7q8xOw}j>@_GB+t)xvtGvRe}lbO3-*+5iUd8ph8 zaQpI*ApevoyIqR)Fb{lt@vo$=!HTiI1XyO2wA~Bdm^nj#&0UN2SvTGxlXs7s%RrZ0 znybB~LRYr}WGVa$Pp!zdY7meqMW~--e}+?TWeYbv!9> zWh%4GCA|6ZRi#+C0LSW2|KR{@rPF?&$eWSAPCs3kS{eG1L)-kI_OGOgJeJSVS{-#i zaEL`{ZmNkr-laT0#ylQ;2OxAax&$QAoMu$sv$^a&NV3Y6a9|!AmRNtnab{Mj=R9?b z_)YVm#(MUq9s5K))gDF**Z)T2Dhb`$3%RRxzu_?h6n3@f(BNu-2G$a+Zzel(>~5*6 zl7-eb36`_hv92bbFMxuFwc&)N3FbKR`dHRNnk@ZmV1R8!rk`PU(@C(>XN+orR^^%< zjfPAQwmkWMp`QwTR|W4tEBL;^EoymiBmE}<5ZgGb{+q?$mj z5g3j&t6LaP_?5bAen4MIhGBRo#xJFYdZs&OvHU`GnsM=SW6n!Ns|Ab8gER$ihsN#V zCn#EYA*4*j>~GTKdgX(IK#mYx>YBc{JD`i`B$->4ai-#zCWrCpSIS>C1q@IJsUA)H z?<+EG@d+yka`o={azGSRhUNLEBQ@6qK%?%eIs$i4CpSMvn%+p~$D(nm;Ge(`1&xA9 z^T~jQ0L4_v-WuUuDdms5jp+UE?CQANxV68IF%%h z4%I8AQ;gtcbpjwndt~x(y6-y!{RU*VCT(+IP8;iaR8yMEe-xrPTBl*I$O#XF$p>gtx$QDHU7~J=V}qM?ti_SqSX7itS0>q=6SFRlUjp1t#TUKt z(bgOrZC2pfbeE9=8GE6pGTOZBvBolvvZ{%WFmA@7H9w5g=Xco#9?YUT!4&}5Ikz)t ziZQN#PjC7`h&{=68L*gEKkaXeis1O;afWf}e=5P8e1nV$25T%-P8Y&UAqu;A+d6~* zrJn_R8+<)!@av07R{C{)%^N?@XW^3>{$zClZN|4y(Kn`Fq21pBw207=5}8s4FrZ$= zMtSFkaKM~@@BiJnE`=f{cug*D$Y(5LL$Yt3`nKeSS)+AJM_mLbZ!{(`t_4OyWlDk9 z1e4r@%shM+cWJM6{U?5K(c!B~-n^v~wtck{92t0Me?F+s&z@_aA1aV_99VY$I;H-| zY{FP3W0O#3W7s{3Zjx3hM-$IGt#dCi+uItIx`6}ru@fp;EB&r^I>Qpl={{9CVv+1u z3GKO8*wKTg)4EgvSL?}R=0DgZi$uvF8H|dBW#K;YX%AodIKahDs(t>s8QjN3u!S& zq7tlx?X-|?PL8nPCqTr=azeyXLP zIKi?^(re-yr*DnKSb@)--e(2gV^2O7Ff_I%F4aRD^OUHZf%9~E@_ zRei=&*O94Vl28@Tk@l+RppmW30-c>Ts6N+v&2=rrl3sw$2&Uh#gyiYrlJy>?jMi11}He7_w@g9M9P`j+#yIAGw!X z`EF|YMzf7HE|B9Kcp8g6PIfdjd$WG|I9jl^ax60{*as;}QV{FV<{b2o4z|A{8}4iY zUr({voGBf3<<_F22@=r833rjCxvuTIE)6XnTc{(=V zqg`ON7*S_&dUe|TG@2{_q<(hp-OJahK|I-sBL?tv(+a43NQq^iD)YA@g*z8jG}#=b zEUATr2q?RLX|s3B>SWvPrg>6-hVujb^ZbLiQw6vp*cpcsbDyzv1G|10;?&yxPM7mE zmyxvx`!JOUR{NS>L;@L=NHL6qc;+6Q&TXDbtvmMlOZ<2|^NYF+HWt_F7Zzn}Rth?T zp|8k!$HzfK1G6625R_(D6m?X8y3sL~Q%6-&l(Yp*yHg8-bcdjUACaY4{=|i zUvRZB>-c2v_&52gb;n)?12rJ{xG&KSnY7aP+Gvn7yUp(Irxkz<+7FTNc&$Vx zk0=D869aA+o6lC;>j>MY4PQJyn}qhdny1>6u3!Mw8WfLjwx`efC(yBGRxc|}PDs@+Sz>6m?S5a^QnF_nl+D>1_zf%|k-kWnO5wJ^khBzhamx zdm+Ez_)pLy0DWFl)ln9baSDowES*$UYZxyS3~rG{EpmJXMJ+fd0-V9!Ru3G~k*A@m zB{5vXv2xDl2T!zMcw)}MSkxPJSrb%hE)c_}6wSBU6KKzE=#4O$D76B3FbEy% zXBOq)ixi+re;*iRVm)uQ@aXfJwtYydo=_3ZN{w;XWO0#wqg z+NNc9ON6GaeuXgil&xvU(#$tMUl%fjzvi6$y7WB}Ncr|05>y2l*FroeHKaTCzOR)| zQqKkzrp9{oT}1s_Ax0UfSp_unC-4pz0St@XK<628J=1@@pFXv`#xYZ`C4F0PUJ9_* zcN$+wr0x(n+^4${jAxyA%`XsC!V@%wQUd&48Vk04^6Q)B104?)`>wc(0jPrXm)ZA; zRZgo*h{E5&bTbQ9o@(d4{RN8lyw?bP()iLzDjCUYzj;-miPJKW6jM$O&_dvZJZ?qf z_P>dM*w^%4PgY(r3OADKW%W{|mPWH_p@bAS?2JAQ&pz73+e)Y9QkdBS`3cEBY2tfU zzukITizc~$Q}{I3)n~Dko^y*B(Kr>UtjCBzeq$voBft!qX7?O87wtd%92? zx(DE9nQ$LjWxl7!V66v12`8HSh8j$M^;(&bU$mnXTDMH0=VUC?CFQSK=rdP`FO6I*Xov2_Oc56g(n3= zt%M_aSjKA|q)It^psf3C9iZ(4R7P6Cb1LO8yA1$z5}g~c*K9AG=O9WapJUEU2mmqM z{62>{3fwInR2v2)WVv(a%tGd?E!wOE4y_bkXfv8 zJgvyMdU0BKZg0v zyG3{i=yi&01XL+gvj%APr`T2JF*zix+DwZD9!d#OBoc)=ewqJpdJO%DoB{cPWtA_M z@M0U$A>2Vx_KA)&TzHC@%D1GVDx;pL4}ZT0s=83_a$7X|>n3)<{RhZ-fwFW*ZAbC7 z)w=&Z@sDKu54Kk;VYl88)2gSo^)@cBFO^-d&5Q#!3E*k~2cCkaQqj4}R}7=cy}!xX z{|#Oy*0{m!hdS`V(q&pWe@W#UXx$jJMMg_Kg5d&0ShDAmG)%MCr@tM`dX+PRdxQGCp8%3oWS-q9EarG3h*qGCrZCfjv~)s!BVy^D4tnyLo7j5(kA3X;omJ}tMXs_@@(A4P z7BnG)c?)DA)AaBQ5XOVz3Zan(KmnJrT!2p|zb=1c%~`FEYdGDHSau5|iTa5Je5Qzk z;qBg#NM>54yU(H|$dtJ7Ozzs_#7!qV0}-~ku9MWd!3L6X*Bus*d=VH#`c0ZsK6p@z z>6?{3Lo%;meaB1vft)+dtZqe^>S1ekE~>faNqfOknLD9j%A`!8(j5;u(DQiX6?zfj zB*J{Y_THuA-$J&#!6DbBd6!*D2s<{9FU}X!RAzT!*_GDMfI4KP zT(d3!fGGBeiBTop^WF0K4B(+??XAGf6acuhGKyZ{lv10LcY^& zUIG1j24q0hac|!|Zme?#jL&pzb&US>n5TEmvTBAp**_P$STl9T-#(<7fv{bWAI&>T z4W475ZE+Ayek_QYQowU#rW}wA5EB`!Gh1jBa)e?`5h-kAh-|j86EHddUy-Y9=LN)^ zJNha`Px4tM5opkOe7(-x%`W2ib4JGDL##eUph<*Tkw(blfjAH6{3-BpG1V5U+Vdn* zvMgLkOOcbbj80weslIkA`b|)tTkX)!Hl_oMlQ5TeXR&DSqbEBQ7I=}m-Ru-JvW zKE!`7$*OOtPmy|;tD~Cd^zcjzR6F;TWXS^a!yjesDW-%+wRZ-3#Xi4~j7^IuE@lExsUjFzx~P?e%}d$FD2=lPSyn8+ zed=t2Hn118os&%jy^=OfwXIiH9&@;KFXGq=VBq7!ABJa6zc}7_X6_)62OuA$8|)wf zfNAjnU;q_{y$SoPxog#bejTl{bR%va`RbeN#-L2d*=KHdeg0o?`2Fnjyj~$#k(A@8hUUMo*R?GoXo25%b;q zzsasovHKPexNA2=4k%2vBrWq%qvo5+jRpL3U!lr+cx4db?biJOb%I6iy5o;;h80Z~QL#)x-v+7i&{L;4H$ zGBwL!+8Q=p*S3|^F__+1?z+5{WvDnCpDoe*Yw(f{*V^5G_ytm3bG*=L!0_!{1tNxh z4}Tbl_jkHN{89i@`=QBXHL5<~`5Q7j!zHk;>Bw|zoTl@$lIthHoZX8tNMzz}Kh zIwh*oFAv|!^ydue0F80sB!;hr|F5L04v6aMqI8#t#8N+`yF)sa?(UH8QaYu>pg{yy z=?>|JrMtT&mXumzf$!n>@7p)?X71dXd+wPzvo?bsvoy_G3nb}XuAiGncVXnEv;EJ4 zPJ-3XFs{X*a$&iTanVT2Ja>bj!hYv?c{cUzXK4|6_+#B0>&-OafH0uo;R;`KJPlm> z(MTRo`QNkXF50DTkT?!qCki7Xnz<^oH~$_@JNv=MY5G5deLaj9siZ}YOl)G60L1Qq zvgHwQquQLUb{vN;v`W=XR1&;qdvOqV7&jzV;`vEY_LaVgg@JRxkHhh&z&wx7&wR$@ zEEH?lOg)<>`E^MrPTN&mu+z*I2jZ7!rcl}|Psd&S9zEOeWF7pvq7z_juW*gBd9|i* zWDqi;qCja>FC>!|??)^X?=R}HjYu+X<5hAw@n<{6N9bv!M!3aefWk2wBH5Pnv%p#9 zemDwHN*c-%r(0M-!6Zp6D|%d;I?sd=xE6zJ=+Juhjdt_`60dfCO6%@rV+R74EyJ`(u1kskxV$-ng z=5zLsRO(Pl(*sF~7_Y@Vrb4eg5*LM}1j3|=K$x4^8;W~7RE(D`JNKA0vKW9ax$oYV zYW};f)$c@;|48W@BS^D$IYRQWCIRqBZCRr_fa>;@JFwJxD(dl#!d%2CZlr_enmrc@9)^9ik0X z364^2ObdOE6@Xrf%+u<05_6HAdJ9@ay|G?`OqpPh6)({#pM3_~2(1Q$Q?uXryNyCW z|Gi&tPxRj%)1lmR12zsGF}A%o_z{qweGua>KW_Z`iYJHeXm(sjF`g&WAJpp{%kQPM zH{l0>gb)yR^;9QXKHwfrj`fk3)KVzhhyJ~&LM7Co0~DqN1#>x+KBG_E1xp?1(@u-v zy3R}4ofluqc*22#A80H^yK**7GVKy?Er*_4hCq3fUD8hicz{Kqw?q*BhT*JKZcV2b zt{`Hx-qj6o1&VMeceq4X8y{+x_B#zt^U!gw7wnBv5G!n&PeL3TehApA*`IjVA7&O3 zl_}{_--@q5KBYgop0Mla$1XOt5LV{ugYGU8I9nl>6{nw=51UA9K!cOCcyJWZQ@JZ8!GeqxGqpmD}>a_LXYs@1JK0u zsUuGx>2QVF13`9>80UgNhUJTQ8VK|6 ziwkma;G;_dVPR2voz7hk^Ef*4Q-~CF#UrdXH%Ir^epFdTb8ztbE>b8pZ9+thVA3LN zaDN-0Pn&{*H@`3Bt=V%h4RRQ_lg~$3o+23cwAh!l$cvlOL~pwnn(!4DpAqT|s1Ixj z%~zsbXwi%ElnP3|^5Sv_M`+Np+ZhB@19y+@ zgG%SqeeRDj2xBT6(9&?~xi1pw&TB}Mr93G&hQoy&)Eukm&)TMXWrkeKB(B1)qy9c4+FWo83$ElEqQ?nMTdkCIHD65&&O;5}> zB*}Xs7vY_+q$$kBm+A&PpTyTMACHmIy<7PtVuQ>sX ze-qzn`9QXHcydb5Edz>C~*Z$j~LJzKK!+)WT~#?z#RE{xp> zcV^!9v^v`lAv?}kTX%`ny*Q!$!AbVD!TnzqzwHDJ-7=@`IKCdf5Y~Rf#?3pr5ga3G zs6{!vw|9x3VeH4nqo4v^=&HZh->bFz7uq`cZrixbf8F}KLy<=q?_q~NffjYMr^`*# zou^L({SPp~++X_Qy@?c)XM2oWTbM2UmF&^rU%wDnTMC2QnuRw~^eh*eNA3RJGs*6a zC0H!+Ve6@=(Ks0~B~s6yt@#rf+bpSFo2qlddoghx=hC<0K;s-H6qB@^oSs}BzYS;lK2@U@1wwdS)CoN5p)4j#|$c`tmu#;6voDRO)O+|^xiKSLwcM6dcGpMFKl}JHcWkOa6gu1D5 z?*!%UYM4#zU|kZQ&-q0+PrVC&wL?ny-)(laN~22)lwdO=`JP z@iPZ3Ts`r>vJ~@XIo-b$&hh!E1Bo;kTE~oP=afY{_btMxGE>Jd$Rp^p_ia%xuDW?>F8nfQ)EGONj-cc_K|$%?h4T6ygu#CkH)iZ{1=o`= z;eOC^C$-v!qe7V|(E7Urw)5MK@aW1-@3ojD&|F!9`Oy2@DD=4xz-)8djfeG#AGBpR z?_aG|`tCaURVsY6)4bj*-7+?r_;}_$oR+o!<<#=d%Qb=dVND0Vs#O{LmcbCaN+k=I z?UpHBj&UbR4M#b1AWlTP7jYMPc$uri+*W9cf!8=$k(W>F8~)FlN^C`zeAAy#T~GXD z^iwsbUW1~Nx1q$BJA1$3Z^Z)id*HVkW~q0Adpio07e5j$c1M0KTzAfNA@=Ee5;>Qq z208g*RsBc)lzMr(mz$gW=hWag1GqOFr5rEFH;Aa#mNBgT)dxg8u0Cm-r?kj@omWIm znacK=`m=s%0qdsTea9aHT%yISUOl`r!ktcZ58)=}NyIICnCY!6rXf#ilZ3h8aga@U zaDGdzo0i5;nJ}%6eO$rTLPaOFqLIBvDOAN=?>oCFP<4J-v;Q0Ep!8(V&sctTJj1-w zwr>2~N{mS=erWdPQL#SuIu;99#P?f-#!thXp5;cG+TEC;YFyF{bIJUK)&RZB{X~j6 zxi;Cwhrqggi(z8qmkjb{BbEmJKrN}OURE`WLu9})vDC!rHOUeg-;EGB0hx|b(^V=C z%&5+COa?dlE$^YTx*98?GfuM4biQQA<}j!C(IV~NmdTLK9=3Q&$2?cF!a={dx+3cf z8)R(L&#~$k4iLr!@~WPt`%!ud^LT3mg&5Y7?+rVe8ei!memknPHs>B#4wNs}k9L+f z46NG$DDisL!~}8jR2%SjIe6C!qK=L9+B<7)~* zj;}^YpmMe_`^|xM>nG>QeV|)|L7wrW*aczUIQy|XwT1_sHqr(EaN2g0WBbH9+bfy335&hEi8ZXpM zj*t0EQuiEOpDU7-tyyTE&#UB;SDX`1HknhEX*A7%W~)wf0G?EU(DTLrmuqQKOpm<4 z$o=K-YRmkF0xr-tM>^JX;gCGt!463XeUH>N2DmF6q{rz;mdL(Cf2jzVA)c9@IL$xX zS!paAVxs)RDMl-2TB@h5#xs?z1t@H#$h^-a$l-S#59cqgd3t`MrDYeM6{@)F)r=} zt&=-Px?aquNIkQJ*g%WzK99+6<37Z`bfmHD3o7%v;9{%(6&x<;S8@hw z=xzRcHk>KXqw#5pcMZ9$?%hcw37JqQu{YMZ;wO=IP-@`rBI)Dt-qpbP_}wu#svGcX z$aa65;8Y5-hkqW@sM*!=q4}`x?NHN9ZGMk5qsmrZuW>IhCp+iLdDXSHonN}}LAPRu zJv%ho;U26%5oqh`#EA>n({RH}2lj~Yxa2fY5i$zTFTSq?35}CI>DyX92An!>GU^(% z&usrZJM)`SiVBEM7%*wE5r@eRZ?dvFA1%@6h-&|W{hqg6s2l8qoho$h=yKYg+Wt`w=_|#m#QYl2-QAyeez;izWZ?6rqb(FegZbj5J8U7< zc?X|O2dv zU=p4ERi7}2Lj^q&A6u_S3H5`-9aETNUi{~K(m)d z2jg%iu$U%qHYc=kWRipP>gy%jnmT~4>~j;lw9>?+!-+nhvamQAe{{w&X^E(uG-;mc zO`ptQ7tK>y_M~#?>=pzsw7#j0BKHe^c*J+-P7`tm)9)VY-vDN`E;F*-I{vs6^kLOK zbhcDQU3M(}tJB4ybu5d^N{|s@8Xo@m#3qDIN#;tNQcf=8fgB6Vc!i%el<`Nr1?*%* zI)3P5Alx0LR8P(gwu)K3^fdw-om8u@`RdVS%AjMdcT|uu#Z-5-cIJi5j|{dj^@!v5 zm3k_SOtv8NJ^F8*(`x-^NjWn=jWGNhjx)NcP3G1phBjnP$Y$Iq#mj!S8jl+KdR1)) z!stB;)+_{~4p>+zx#0~CIv+B*hS7*QW6XCy&YXU&4c-FGZddACnL0CD?7xPVA_Xpi z@{Jgf0tk5;VaVUsH9GM+YSm8(pc={^_(Vn*B5@h|;;Sb*ax(Gxjd}Gs%L>Lky?y;k zDo3kni(kOo2x?-ldT!i_3sis0;-?q0zo$k`9WG(ex5Q5|<7k_?&Zp2SC}JZ`(F9kj z=?eR7bfHcD#0;13h2RH$k<1Ygp86#1w4iPj@bITNy{P^xrlHgM-x`d17hXtgKRn=S zo%)yzFfQ6F7nAyx-mkRI>)1b8aOREKJ2;t{?mK3xol9xPhBPT7hDQYpm)Kf4KJ?%+ zi#|5e_-ST*`{{hJE_qE5Ct2 zIH-IZ5l>smyCSa&nArc=ECfy1m^*y3TW~wuIEy4{H!N=o3Xhr8`&9ZX*{n!;;Q_|j zyH=q*?AN5a9VB%UQ5=ID&m5zRvF2r^7!Wa!H60AE@W;1B^GFcy@P{=b3HeKNz?>kF+ z2fC59g#go$IJbmFiQ-f$%dTz7$k{Cfrv{3|b=%KGm+39r+F2v3@$D)O7o zDQ>$j3Dgfoh@2gSkZLQBw)qHG*|Py!f!8Y@ETK<0@ysFhcmqNK3!R-f0<8^#f%n2b zXQ3!T7t4)8_#>s6Aj;*2h@XUt{+-WNzjr*zev@@&H0bLkI+`0QAmIAd@v*3r2R$A1 zFMc}d;AWVjgcq4}lACK|M%0U)Stf4$gC>g)a>ZxI7rNaWQi(+|2i|`e+l33u+Ywu1 zj1ixqPlyBGke&R_2f>h%ZY>DmlJa+D0b<8Qkgi$pDk%HVMVUj!ezFJ-G*A;K>;L`+mx=9?%F;_&`7^NX9(2$~ z{th+8;Orj)TCAERpaIbEZ_rvGXIRF%f}%;OHcw}b`x@ym#Xt08IxObAVz<>tu4!pH z%({4tEl>&8L;C+udb*nFN6UqAJ#1~Uip0fxRR2t7rKq5Ajie1$z+C)W~ecTrY zgbY$_F&2j-Mwz|4B$j{R3=;2$kG^Q7`5rbosHGBUuU$p)eAJ? z1*2#Msw$kF*8zFk6Esb0dQzcrq7|~0D1deKYu9{uSpsKR0J91vU_&b1Lat@SWCejJ+`6DVE}+9a6F79$O+0^We5X3Dn%_e zK3vPjvEHKLRpmPnRRFX?;yA8`eb9tCxg5!ePPSNS%+u+1egP8-o8yLL1#LdobNZ!R z;2`(K?j$DiE>`LF&7AirK5Q~(FfMK2>f1g}HEQKMNN}NHls4^GV&Ctj)4%YlTEEy! zmTaLrl0Unns<0vaD=nMTLb}0Axh^@gMIL8E#b!9s zu=q2^B=`!ad(Pz14C_n$)c3zAW>((aj+19T{8(b}5b6oc@SW>n+n4bH1sw~*V;2KJ|@ z4(9bJO7RQ2G%YEst9DE?R3iXLH#4K%wd8JTl4m%RuH^j)ubvR6u)?RDtEYQO!}AY8g`#^-F~G8J7*MkJsy;p(uhEwC>Ecb-7GIW&l7 zNmAs7@N~GW7^dW(widt&z5T5VO1n>6PVzabZmnyd=j3%!kW$-bH#CH9JW?}MIc3BE zTuf~KO;85kBq3to%rid#63!BsHQJu(2)04gom}sWeRc5U%cxc}<)A#4CBE8Spi&?- zCPoP{S(zB6NltFuYX6)g|J1czqmil)+!ry#O`KX8z40mP@T(Wrg67}FWfvvO$Kh{+ zY1Wq^x*7-UDvYXi>W0uQNE%qkv%tCkVxQOl;YvYUC*n|S89>6f@4J1Jv@vt7$IJ8n zG$V@!HFCafV}?pmyqUVYvb!m92x}-*Zth>Uq5FHzS}qtp)6%$|D66YTcpI}TS~M99 zEly{Wm;pf{rk=my(|&YVBaQqqg*mFHetQXI*`~Dqw||JgDVT3&O~wme+9=lR$;jk= z7F`MPRqOxdNLF8<*F>9o24nkUAv5b9a{GxPtk~Uu6OyXdeYiT@WwNgHK46Qr?GYVD zf3~u2SybV28!qnbR{~jbA8p#gQc|wyXs^1ppv-*Ne9@wCc|tkX7Xli|_(*p)RLcG9 z+h{QL=htiiV?XY`HZg91C+$Qspk@V?omWy(XDIaGVK04qp2^97}Tg#~YVPrxdlzSFE zGwo!-+$5_lE&H>>FOndpk>`fe)R*Ygb_8lAv-jQ_mQmNM6Ic*9UP6x*5u2FbK-BO; zHSv)D;!QwQs8-=s-&<2h(dzov6=Ltx?jXP8ffoX3cx63noH*5c&|XYR-4ge9R*7*r zjjybV)ZSxUtz+c8eS}i=o@t@s==?L;(_d=%UWgVP#G(pcisxCqvrC^Y#eB(T{AN!2 z*d90L)9x^f9Pid^Dw(;p<;z^R`7#s`2!vlN>Zje`C6jqG4&*{tSCTx%F*Ghv?*fhm z9F53kW zy_cah7`29r}qR43F!4Kk4Rz^Q5Q83&CXq)=g?vrXX^O26vng zbg$k(cA`>+LrsnU?E6ql8ce`?NUe?z>-;*Yp}ivp(8A(K+; z_(0=ARq=g~Ci8um_sQ*J2@&7caW6a#qjQg`m_s>Fd(;q(=Y9BI1Mg95d|jJ0L^*|@ ztLdLnUsSQ~lRG5xAikx_W)L-`-~Ods z9_AnBSLP&BeO6*2)Xk@siLw=0fS@3!DqA6K9`--N#JR`- literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..c79c58a3 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..dda12f92692148ab7348ebd5aad63977ffa02d94 GIT binary patch literal 4455 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0693c329d61f34999ec1d386dc42943a23471815 GIT binary patch literal 2934 zcmV-+3yJiJP)cSReH{CX3Lx{6hHGYLJgJ;IU=B)Rak54T0D^!4x2UC(N#&XEgvW+06&c>{ z-E(Q3>(jIEGZnTUepUhaKwV1Q>*L;4<|Y*f=v@O41!hnerY=wyrq1`5H*IKtvuqK2 zzP-B7T>4~9ps4Pt?5A~)WiLZ1T-VpmA^@TC&Bf+*cNRKV{qVBm?rU zCi6CLTU6U<4-pC@5fm;E%|PS+Fy{Tn{Tp)SmRDH;>X~W9 zgjIgU;a&b(=4i_S`ef@7w#t5nt94%Fo4uEXcAA#&og`2r)sL`h2jZ#vbVYhxwjw(r zpBQYA{Kqsv!~oqWP4P|lyZ?RR>zD*+AIJ=wbN<7P|7l--bTND8%8#*h+6keZW`%aT zj;nIi0bJhKB}}hSlKJLw$_dFclv4-H#Eev0G(XxWvR`tqyYMyicn4tu6j|{%DKd-` zd)BwTe)t)#+Ht8bBjYtKUCQjkS!?%pLNia*A~>f+A#3dm=?ULbCB6 zYAA~;Kv`g&DK}?PHK7ixi>Fk?Kz%x9QdtlUp+f+uPM@L(wCLv@!WC~b*XXI`>RgS$ zN5~LF0Ew7X@Im?|hDEt=s3uz<>B&P`V7o2_d$z68Ut!x%mpAR2C%5M1koP>2WtpMLjcZp`?kec2sz` z)qL&QT(y>~cGh>dcdtG7Sx*7@0R0F=ryxZq)3soP2*6XcW|YE>*T~T)cv*+&W*NXy z3^P(10MJEQLJ?x1K3D%#POkpRoVR>O>Nhx69Dj{D-f{|pqdhjP0RjY(rW?#0YdtM5 zz@iz@8%t3T{mi-6L-diBgY5aX6Ku8PJm2W4<(s{(o+|{RCrR9_vf%x7gB6*^;fkU0 zdE|)1iKw2mgc<^-%ul|LTAO?i^FzxX;00DO!ZcN`f6Sz|0z?o16c%;#>EpKTEem(w zE40&|-h>9kI%t}8(ACmG#kaF|XOIB`s>Z(~V2U ze5rjnbFr_qZj-X0e{r(~ryBMOi}pRgTJ6{JQKz?^G4;9PThx&Qly?}Qegy*Hr& zUunwXb0x;HoUsn&zQ3L%Sim;nF&#vQ7H)JG#Z zvAvwCIyIHBJZ^@FK7$TFy(w_0j27ShB{XeR#+d zntRiqK{X0fOoIS{d}&9|x-bFWU9}}~OU8H+9979VQO`+VAb40OIodplnrNA($c)d$ zjFdIZmk7MzW*XRwjtk7$w&V1%=I{LbFYgxGX*XZ*ZnnRDbeVJI$ral9S-;cG8@2>N z<@@XAkoRt@`KDI^!WF;MTUxhPx;2v(Fn!Q(r4qff;O>fQ6;?7QQ7Q%6IT_E3f=JUx zZQI@7T==KAq`E|~(;mlPD&9gVl+r#lY-u!izOsh(Tvd2FFS~KMJ~_QYU6?vgnhw1Z z?E|DdpUOAiB^B=3QD^P%RleviZ``fONgNlop$LNWgOkhT9?1$|j<=lg?ygy_nv(kX zb>bf0u|<=a@TWr@mN0Q>;L1_0-cVciF4U=wpS z=^h_2oex6VyyKy4{7kP@txw+v6wA-!x@iekkmnI#STXPhqvu88lbRn>1D-nnhGVF5kItaK{kfih$1avU~oBH*=GeUP2SLsb<9?d=Gd z&A*}`hK`Z$L`5YIHMJsW)FNngL2-Nb5>Q)<0*Z>D*T?*oH8ueR0g#v=fYBIy?b4c` z5HALT&R`HgC)1&1uQvqiQ<8v zW9AYd%&btg8V;j g{DKexc)4`?zwW+mw9ZFOqW}N^07*qoM6N<$f_GelBme*a literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..56c9d37cdc67d930e31994713f1314abec5d2e23 GIT binary patch literal 5929 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 LNkvXXu0mjf{RT*w literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..cd349198519331fadb673039c27c6eebf9e7c2eb GIT binary patch literal 8733 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 Hu0mjfNp-^L literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..8159a20a774d9a48984bf8111f4fff2665838481 GIT binary patch literal 11557 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 + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..19d9c82e --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1a1a2e + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..4dda8a4c --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,43 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +subprojects { + afterEvaluate { + if (project.hasProperty("android")) { + project.extensions.configure("android") { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + } + + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..fbee1d8c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e4ef43fb --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..ca7fe065 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ee37f15091ce76f2d23c9eb428ca3550bec8faa1 GIT binary patch literal 19642 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 maxWait { + lastError = fmt.Errorf("download timeout") + fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError) + continue + } + + if lastError != nil { + fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError) + } + } + + return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) +} + + +// DownloadFile downloads a file from URL with User-Agent and progress tracking +func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error { + // Set current file being downloaded + SetCurrentFile(filepath.Base(outputPath)) + SetDownloading(true) + defer SetDownloading(false) + + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := a.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + } + + // Set total bytes if available + if resp.ContentLength > 0 { + SetBytesTotal(resp.ContentLength) + } + + out, err := os.Create(outputPath) + if err != nil { + return err + } + defer out.Close() + + // Track download progress + pw := NewProgressWriter(out) + _, err = io.Copy(pw, resp.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) + return nil +} + +// downloadFromAmazon downloads a track using the request parameters +// Uses DoubleDouble service (same as PC version) +func downloadFromAmazon(req DownloadRequest) (string, error) { + downloader := NewAmazonDownloader() + + // Check for existing file first + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return "EXISTS:" + existingFile, nil + } + + // Get Amazon URL from SongLink + songlink := NewSongLinkClient() + availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) + if err != nil { + return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) + } + + if !availability.Amazon || availability.AmazonURL == "" { + return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") + } + + // Create output directory if needed + if req.OutputDir != "." { + if err := os.MkdirAll(req.OutputDir, 0755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + } + + // Download using DoubleDouble service (same as PC) + downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir) + if err != nil { + return "", fmt.Errorf("failed to get download URL: %w", err) + } + + // Build filename using Spotify metadata (more accurate) + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "track": req.TrackNumber, + "year": extractYear(req.ReleaseDate), + "disc": req.DiscNumber, + }) + filename = sanitizeFilename(filename) + ".flac" + outputPath := filepath.Join(req.OutputDir, filename) + + // Check if file already exists + if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { + return "EXISTS:" + outputPath, nil + } + + // Download file + if err := downloader.DownloadFile(downloadURL, outputPath); err != nil { + return "", fmt.Errorf("download failed: %w", err) + } + + // Log track info from DoubleDouble (for debugging) + if trackName != "" && artistName != "" { + fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName) + } + + // Embed metadata using Spotify data (more accurate than DoubleDouble) + metadata := Metadata{ + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + AlbumArtist: req.AlbumArtist, + Date: req.ReleaseDate, + TrackNumber: req.TrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + } + + // Download cover to memory (avoids file permission issues on Android) + var coverData []byte + if req.CoverURL != "" { + fmt.Println("[Amazon] Downloading cover to memory...") + data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover) + if err == nil { + coverData = data + fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData)) + } else { + fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err) + } + } + + if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { + fmt.Printf("Warning: failed to embed metadata: %v\n", err) + } + + // Embed lyrics if enabled + if req.EmbedLyrics { + fmt.Println("[Amazon] Fetching lyrics...") + lyricsClient := NewLyricsClient() + lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName) + if lyricsErr != nil { + fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr) + } else if lyrics == nil || len(lyrics.Lines) == 0 { + fmt.Println("[Amazon] No lyrics found for this track") + } else { + fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) + lrcContent := convertToLRC(lyrics) + if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil { + fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) + } else { + fmt.Println("[Amazon] Lyrics embedded successfully") + } + } + } + + fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music") + return outputPath, nil +} diff --git a/go_backend/cover.go b/go_backend/cover.go new file mode 100644 index 00000000..36c83a90 --- /dev/null +++ b/go_backend/cover.go @@ -0,0 +1,101 @@ +package gobackend + +import ( + "fmt" + "io" + "net/http" + "strings" +) + +// Spotify image size codes (same as PC version) +const ( + spotifySize640 = "ab67616d0000b273" // 640x640 + spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000) +) + +// downloadCoverToMemory downloads cover art and returns as bytes (no file creation) +// This avoids file permission issues on Android +func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { + if coverURL == "" { + return nil, fmt.Errorf("no cover URL provided") + } + + fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL) + + // Upgrade to max quality if requested + downloadURL := coverURL + if maxQuality { + downloadURL = upgradeToMaxQuality(coverURL) + if downloadURL != coverURL { + fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL) + } + } + + client := NewHTTPClientWithTimeout(DefaultTimeout) + + // Create request with User-Agent (required by Spotify CDN) + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := DoRequestWithUserAgent(client, req) + if err != nil { + return nil, fmt.Errorf("failed to download cover: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("cover download failed: HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read cover data: %w", err) + } + + fmt.Printf("[Cover] Downloaded %d bytes\n", len(data)) + return data, nil +} + +// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality +// Uses same logic as PC version - replaces 640x640 size code with max resolution +func upgradeToMaxQuality(coverURL string) string { + // Spotify image URLs can be upgraded by changing the size parameter + // Format: https://i.scdn.co/image/ab67616d0000b273... + // ab67616d0000b273 = 640x640 + // ab67616d000082c1 = Max resolution (~2000x2000) + + if strings.Contains(coverURL, spotifySize640) { + // Try max resolution first + maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) + + // Verify max resolution URL is available + client := NewHTTPClientWithTimeout(DefaultTimeout) + req, err := http.NewRequest("HEAD", maxURL, nil) + if err == nil { + resp, err := DoRequestWithUserAgent(client, req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return maxURL + } + } + } + } + + return coverURL +} + +// GetCoverFromSpotify gets cover URL from Spotify metadata +func GetCoverFromSpotify(imageURL string, maxQuality bool) string { + if imageURL == "" { + return "" + } + + if maxQuality { + return upgradeToMaxQuality(imageURL) + } + + return imageURL +} diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go new file mode 100644 index 00000000..8ec8741c --- /dev/null +++ b/go_backend/duplicate.go @@ -0,0 +1,63 @@ +package gobackend + +import ( + "os" + "path/filepath" + "strings" +) + +// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use) +func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { + if isrc == "" || outputDir == "" { + return "", false + } + + // Walk through directory looking for FLAC files + var foundFile string + filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + // Only check FLAC files + if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".flac") { + return nil + } + + // Read metadata from file + metadata, err := ReadMetadata(path) + if err != nil { + return nil + } + + // Check if ISRC matches + if metadata.ISRC == isrc { + foundFile = path + return filepath.SkipAll // Stop walking + } + + return nil + }) + + if foundFile != "" { + return foundFile, true + } + + return "", false +} + +// CheckISRCExists is the exported version for gomobile (returns string, error) +// Returns the filepath if exists, empty string if not +func CheckISRCExists(outputDir, isrc string) (string, error) { + filepath, _ := checkISRCExistsInternal(outputDir, isrc) + return filepath, nil +} + +// CheckFileExists checks if a file with the given name exists +func CheckFileExists(filePath string) bool { + info, err := os.Stat(filePath) + if err != nil { + return false + } + return !info.IsDir() && info.Size() > 0 +} diff --git a/go_backend/exports.go b/go_backend/exports.go new file mode 100644 index 00000000..caf6986b --- /dev/null +++ b/go_backend/exports.go @@ -0,0 +1,339 @@ +// Package gobackend provides exported functions for gomobile binding +// These functions are the bridge between Flutter and Go backend +package gobackend + +import ( + "context" + "encoding/json" + "time" +) + +// ParseSpotifyURL parses and validates a Spotify URL +// Returns JSON with type (track/album/playlist) and ID +func ParseSpotifyURL(url string) (string, error) { + parsed, err := parseSpotifyURI(url) + if err != nil { + return "", err + } + + result := map[string]string{ + "type": parsed.Type, + "id": parsed.ID, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetSpotifyMetadata fetches metadata from Spotify URL +// Returns JSON with track/album/playlist data +func GetSpotifyMetadata(spotifyURL string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := NewSpotifyMetadataClient() + data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SearchSpotify searches for tracks on Spotify +// Returns JSON array of track results +func SearchSpotify(query string, limit int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client := NewSpotifyMetadataClient() + results, err := client.SearchTracks(ctx, query, limit) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(results) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// CheckAvailability checks track availability on streaming services +// Returns JSON with availability info for Tidal, Qobuz, Amazon +func CheckAvailability(spotifyID, isrc string) (string, error) { + client := NewSongLinkClient() + availability, err := client.CheckTrackAvailability(spotifyID, isrc) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(availability) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// DownloadRequest represents a download request from Flutter +type DownloadRequest struct { + ISRC string `json:"isrc"` + Service string `json:"service"` + SpotifyID string `json:"spotify_id"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist"` + CoverURL string `json:"cover_url"` + OutputDir string `json:"output_dir"` + FilenameFormat string `json:"filename_format"` + EmbedLyrics bool `json:"embed_lyrics"` + EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` + TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` + TotalTracks int `json:"total_tracks"` + ReleaseDate string `json:"release_date"` +} + +// DownloadResponse represents the result of a download +type DownloadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + FilePath string `json:"file_path,omitempty"` + Error string `json:"error,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` +} + +// DownloadTrack downloads a track from the specified service +// requestJSON is a JSON string of DownloadRequest +// Returns JSON string of DownloadResponse +func DownloadTrack(requestJSON string) (string, error) { + var req DownloadRequest + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return errorResponse("Invalid request: " + err.Error()) + } + + var filePath string + var err error + + switch req.Service { + case "tidal": + filePath, err = downloadFromTidal(req) + case "qobuz": + filePath, err = downloadFromQobuz(req) + case "amazon": + filePath, err = downloadFromAmazon(req) + default: + return errorResponse("Unknown service: " + req.Service) + } + + if err != nil { + return errorResponse(err.Error()) + } + + // Check if file already exists + if len(filePath) > 7 && filePath[:7] == "EXISTS:" { + resp := DownloadResponse{ + Success: true, + Message: "File already exists", + FilePath: filePath[7:], + AlreadyExists: true, + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + + resp := DownloadResponse{ + Success: true, + Message: "Download complete", + FilePath: filePath, + } + + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +// DownloadWithFallback tries to download from services in order +// Starts with the preferred service from request, then tries others +func DownloadWithFallback(requestJSON string) (string, error) { + var req DownloadRequest + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return errorResponse("Invalid request: " + err.Error()) + } + + // Build service order starting with preferred service + allServices := []string{"tidal", "qobuz", "amazon"} + preferredService := req.Service + if preferredService == "" { + preferredService = "tidal" + } + + // Create ordered list: preferred first, then others + services := []string{preferredService} + for _, s := range allServices { + if s != preferredService { + services = append(services, s) + } + } + + var lastErr error + + for _, service := range services { + req.Service = service + + var filePath string + var err error + + switch service { + case "tidal": + filePath, err = downloadFromTidal(req) + case "qobuz": + filePath, err = downloadFromQobuz(req) + case "amazon": + filePath, err = downloadFromAmazon(req) + } + + if err == nil { + // Check if file already exists + if len(filePath) > 7 && filePath[:7] == "EXISTS:" { + resp := DownloadResponse{ + Success: true, + Message: "File already exists", + FilePath: filePath[7:], + AlreadyExists: true, + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + + resp := DownloadResponse{ + Success: true, + Message: "Downloaded from " + service, + FilePath: filePath, + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + + lastErr = err + } + + return errorResponse("All services failed. Last error: " + lastErr.Error()) +} + +// GetDownloadProgress returns current download progress +func GetDownloadProgress() string { + progress := getProgress() + jsonBytes, _ := json.Marshal(progress) + return string(jsonBytes) +} + +// SetDownloadDirectory sets the default download directory +func SetDownloadDirectory(path string) error { + return setDownloadDir(path) +} + +// CheckDuplicate checks if a file with the given ISRC exists +func CheckDuplicate(outputDir, isrc string) (string, error) { + existingFile, exists := CheckISRCExists(outputDir, isrc) + + result := map[string]interface{}{ + "exists": exists, + "filepath": existingFile, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// BuildFilename builds a filename from template and metadata +func BuildFilename(template string, metadataJSON string) (string, error) { + var metadata map[string]interface{} + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return "", err + } + + filename := buildFilenameFromTemplate(template, metadata) + return filename, nil +} + +// SanitizeFilename removes invalid characters from filename +func SanitizeFilename(filename string) string { + return sanitizeFilename(filename) +} + +// FetchLyrics fetches lyrics for a track from LRCLIB +// Returns JSON with lyrics data +func FetchLyrics(spotifyID, trackName, artistName string) (string, error) { + client := NewLyricsClient() + lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName) + if err != nil { + return "", err + } + + result := map[string]interface{}{ + "success": true, + "source": lyrics.Source, + "sync_type": lyrics.SyncType, + "lines": lyrics.Lines, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetLyricsLRC fetches lyrics and converts to LRC format string +func GetLyricsLRC(spotifyID, trackName, artistName string) (string, error) { + client := NewLyricsClient() + lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName) + if err != nil { + return "", err + } + + lrcContent := convertToLRC(lyrics) + return lrcContent, nil +} + +// EmbedLyricsToFile embeds lyrics into an existing FLAC file +func EmbedLyricsToFile(filePath, lyrics string) (string, error) { + err := EmbedLyrics(filePath, lyrics) + if err != nil { + return errorResponse("Failed to embed lyrics: " + err.Error()) + } + + resp := map[string]interface{}{ + "success": true, + "message": "Lyrics embedded successfully", + } + + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +func errorResponse(msg string) (string, error) { + resp := DownloadResponse{ + Success: false, + Error: msg, + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} diff --git a/go_backend/filename.go b/go_backend/filename.go new file mode 100644 index 00000000..a3651c1a --- /dev/null +++ b/go_backend/filename.go @@ -0,0 +1,106 @@ +package gobackend + +import ( + "fmt" + "regexp" + "strings" +) + +// Invalid filename characters for Android/Windows/Linux +var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) + +// sanitizeFilename removes invalid characters from filename +func sanitizeFilename(filename string) string { + // Replace invalid characters with underscore + sanitized := invalidChars.ReplaceAllString(filename, "_") + + // Remove leading/trailing spaces and dots + sanitized = strings.TrimSpace(sanitized) + sanitized = strings.Trim(sanitized, ".") + + // Collapse multiple underscores + multiUnderscore := regexp.MustCompile(`_+`) + sanitized = multiUnderscore.ReplaceAllString(sanitized, "_") + + // Limit length (Android has 255 byte limit for filenames) + if len(sanitized) > 200 { + sanitized = sanitized[:200] + } + + // Ensure not empty + if sanitized == "" { + sanitized = "untitled" + } + + return sanitized +} + +// buildFilenameFromTemplate builds a filename from template and metadata +func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string { + if template == "" { + template = "{artist} - {title}" + } + + result := template + + // Replace placeholders + placeholders := map[string]string{ + "{title}": getString(metadata, "title"), + "{artist}": getString(metadata, "artist"), + "{album}": getString(metadata, "album"), + "{track}": formatTrackNumber(getInt(metadata, "track")), + "{year}": getString(metadata, "year"), + "{disc}": formatDiscNumber(getInt(metadata, "disc")), + } + + for placeholder, value := range placeholders { + result = strings.ReplaceAll(result, placeholder, value) + } + + return result +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getInt(m map[string]interface{}, key string) int { + if v, ok := m[key]; ok { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + } + } + return 0 +} + +func formatTrackNumber(n int) string { + if n <= 0 { + return "" + } + return fmt.Sprintf("%02d", n) +} + +func formatDiscNumber(n int) string { + if n <= 0 { + return "" + } + return fmt.Sprintf("%d", n) +} + +// extractYear extracts year from date string (YYYY-MM-DD or YYYY) +func extractYear(date string) string { + if len(date) >= 4 { + return date[:4] + } + return date +} diff --git a/go_backend/go.mod b/go_backend/go.mod new file mode 100644 index 00000000..fcf64720 --- /dev/null +++ b/go_backend/go.mod @@ -0,0 +1,18 @@ +module github.com/zarz/spotiflac_android/go_backend + +go 1.24.0 + +toolchain go1.24.5 + +require ( + github.com/go-flac/flacpicture v0.3.0 + github.com/go-flac/flacvorbis v0.2.0 + github.com/go-flac/go-flac v1.0.0 +) + +require ( + golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) diff --git a/go_backend/go.sum b/go_backend/go.sum new file mode 100644 index 00000000..c93680e0 --- /dev/null +++ b/go_backend/go.sum @@ -0,0 +1,14 @@ +github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= +github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI= +github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= +github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= +github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= +github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= +golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g= +golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= diff --git a/go_backend/httputil.go b/go_backend/httputil.go new file mode 100644 index 00000000..5f6e800d --- /dev/null +++ b/go_backend/httputil.go @@ -0,0 +1,213 @@ +package gobackend + +import ( + "fmt" + "io" + "math/rand" + "net/http" + "strconv" + "time" +) + +// HTTP utility functions for consistent request handling across all downloaders + +// User-Agent pool for Android Chrome browsers +var userAgentTemplates = []string{ + "Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36", +} + +// getRandomUserAgent generates a random browser-like User-Agent string (Android Chrome format) +func getRandomUserAgent() string { + template := userAgentTemplates[rand.Intn(len(userAgentTemplates))] + + androidVersion := rand.Intn(5) + 10 // Android 10-14 + deviceModel := rand.Intn(900) + 100 // Random model number + chromeVersion := rand.Intn(25) + 100 // Chrome 100-124 + chromeBuild := rand.Intn(5000) + 5000 + chromePatch := rand.Intn(200) + 100 + + return fmt.Sprintf(template, androidVersion, deviceModel, chromeVersion, chromeBuild, chromePatch) +} + +// Default timeout values +const ( + DefaultTimeout = 60 * time.Second // Default HTTP timeout + DownloadTimeout = 120 * time.Second // Timeout for file downloads + SongLinkTimeout = 30 * time.Second // Timeout for SongLink API + DefaultMaxRetries = 3 // Default retry count + DefaultRetryDelay = 1 * time.Second // Initial retry delay +) + +// NewHTTPClientWithTimeout creates an HTTP client with specified timeout +func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + } +} + +// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header +func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", getRandomUserAgent()) + return client.Do(req) +} + +// RetryConfig holds configuration for retry logic +type RetryConfig struct { + MaxRetries int + InitialDelay time.Duration + MaxDelay time.Duration + BackoffFactor float64 +} + +// DefaultRetryConfig returns default retry configuration +func DefaultRetryConfig() RetryConfig { + return RetryConfig{ + MaxRetries: DefaultMaxRetries, + InitialDelay: DefaultRetryDelay, + MaxDelay: 16 * time.Second, + BackoffFactor: 2.0, + } +} + +// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff +// Handles 429 (Too Many Requests) responses with Retry-After header +func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { + var lastErr error + delay := config.InitialDelay + + for attempt := 0; attempt <= config.MaxRetries; attempt++ { + // Clone request for retry (body needs to be re-readable) + reqCopy := req.Clone(req.Context()) + reqCopy.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := client.Do(reqCopy) + if err != nil { + lastErr = err + if attempt < config.MaxRetries { + time.Sleep(delay) + delay = calculateNextDelay(delay, config) + } + continue + } + + // Success + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + + // Handle rate limiting (429) + if resp.StatusCode == 429 { + resp.Body.Close() + retryAfter := getRetryAfterDuration(resp) + if retryAfter > 0 { + delay = retryAfter + } + lastErr = fmt.Errorf("rate limited (429)") + if attempt < config.MaxRetries { + time.Sleep(delay) + delay = calculateNextDelay(delay, config) + } + continue + } + + // Server errors (5xx) - retry + if resp.StatusCode >= 500 { + resp.Body.Close() + lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode) + if attempt < config.MaxRetries { + time.Sleep(delay) + delay = calculateNextDelay(delay, config) + } + continue + } + + // Client errors (4xx except 429) - don't retry + return resp, nil + } + + return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr) +} + +// calculateNextDelay calculates the next delay with exponential backoff +func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration { + nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor) + if nextDelay > config.MaxDelay { + nextDelay = config.MaxDelay + } + return nextDelay +} + +// getRetryAfterDuration parses Retry-After header and returns duration +// Returns 60 seconds as default if header is missing or invalid +func getRetryAfterDuration(resp *http.Response) time.Duration { + retryAfter := resp.Header.Get("Retry-After") + if retryAfter == "" { + return 60 * time.Second // Default wait time + } + + // Try parsing as seconds + if seconds, err := strconv.Atoi(retryAfter); err == nil { + return time.Duration(seconds) * time.Second + } + + // Try parsing as HTTP date + if t, err := http.ParseTime(retryAfter); err == nil { + duration := time.Until(t) + if duration > 0 { + return duration + } + } + + return 60 * time.Second // Default +} + +// ReadResponseBody reads and returns the response body +// Returns error if body is empty +func ReadResponseBody(resp *http.Response) ([]byte, error) { + if resp == nil { + return nil, fmt.Errorf("response is nil") + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if len(body) == 0 { + return nil, fmt.Errorf("response body is empty") + } + + return body, nil +} + +// ValidateResponse checks if response is valid (non-nil, status 2xx) +func ValidateResponse(resp *http.Response) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + return nil +} + +// BuildErrorMessage creates a detailed error message for API failures +func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string { + msg := fmt.Sprintf("API %s failed", apiURL) + if statusCode > 0 { + msg += fmt.Sprintf(" (HTTP %d)", statusCode) + } + if responsePreview != "" { + // Truncate preview if too long + if len(responsePreview) > 100 { + responsePreview = responsePreview[:100] + "..." + } + msg += fmt.Sprintf(": %s", responsePreview) + } + return msg +} diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go new file mode 100644 index 00000000..f05a3143 --- /dev/null +++ b/go_backend/lyrics.go @@ -0,0 +1,299 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +type LRCLibResponse struct { + ID int `json:"id"` + Name string `json:"name"` + TrackName string `json:"trackName"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + Duration float64 `json:"duration"` + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"syncedLyrics"` +} + +type LyricsLine struct { + StartTimeMs int64 `json:"startTimeMs"` + Words string `json:"words"` + EndTimeMs int64 `json:"endTimeMs"` +} + +type LyricsResponse struct { + Lines []LyricsLine `json:"lines"` + SyncType string `json:"syncType"` + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + Provider string `json:"provider"` + Source string `json:"source"` +} + +type LyricsClient struct { + httpClient *http.Client +} + +func NewLyricsClient() *LyricsClient { + return &LyricsClient{ + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsResponse, error) { + baseURL := "https://lrclib.net/api/get" + params := url.Values{} + params.Set("artist_name", artist) + params.Set("track_name", track) + + fullURL := baseURL + "?" + params.Encode() + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch lyrics: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, fmt.Errorf("lyrics not found") + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var lrcResp LRCLibResponse + if err := json.NewDecoder(resp.Body).Decode(&lrcResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return c.parseLRCLibResponse(&lrcResp), nil +} + +func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) { + baseURL := "https://lrclib.net/api/search" + params := url.Values{} + params.Set("q", query) + + fullURL := baseURL + "?" + params.Encode() + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to search lyrics: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var results []LRCLibResponse + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if len(results) == 0 { + return nil, fmt.Errorf("no lyrics found") + } + + for _, result := range results { + if result.SyncedLyrics != "" { + return c.parseLRCLibResponse(&result), nil + } + } + + return c.parseLRCLibResponse(&results[0]), nil +} + +func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) { + // Strategy 1: Direct match with artist and track name + lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName) + if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { + lyrics.Source = "LRCLIB" + return lyrics, nil + } + + // Strategy 2: Try with simplified track name + simplifiedTrack := simplifyTrackName(trackName) + if simplifiedTrack != trackName { + lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack) + if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { + lyrics.Source = "LRCLIB (simplified)" + return lyrics, nil + } + } + + // Strategy 3: Search with full query + query := artistName + " " + trackName + lyrics, err = c.FetchLyricsFromLRCLibSearch(query) + if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { + lyrics.Source = "LRCLIB Search" + return lyrics, nil + } + + // Strategy 4: Search with simplified query + if simplifiedTrack != trackName { + query = artistName + " " + simplifiedTrack + lyrics, err = c.FetchLyricsFromLRCLibSearch(query) + if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { + lyrics.Source = "LRCLIB Search (simplified)" + return lyrics, nil + } + } + + return nil, fmt.Errorf("lyrics not found from any source") +} + +func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse { + result := &LyricsResponse{ + Instrumental: resp.Instrumental, + PlainLyrics: resp.PlainLyrics, + Provider: "LRCLIB", + } + + if resp.SyncedLyrics != "" { + result.Lines = parseSyncedLyrics(resp.SyncedLyrics) + result.SyncType = "LINE_SYNCED" + } else if resp.PlainLyrics != "" { + result.SyncType = "UNSYNCED" + lines := strings.Split(resp.PlainLyrics, "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" { + result.Lines = append(result.Lines, LyricsLine{ + StartTimeMs: 0, + Words: line, + EndTimeMs: 0, + }) + } + } + } + + return result +} + +func parseSyncedLyrics(syncedLyrics string) []LyricsLine { + var lines []LyricsLine + lrcPattern := regexp.MustCompile(`\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)`) + + for _, line := range strings.Split(syncedLyrics, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + matches := lrcPattern.FindStringSubmatch(line) + if len(matches) == 5 { + startMs := lrcTimestampToMs(matches[1], matches[2], matches[3]) + words := strings.TrimSpace(matches[4]) + + lines = append(lines, LyricsLine{ + StartTimeMs: startMs, + Words: words, + EndTimeMs: 0, + }) + } + } + + for i := 0; i < len(lines)-1; i++ { + lines[i].EndTimeMs = lines[i+1].StartTimeMs + } + + if len(lines) > 0 { + lines[len(lines)-1].EndTimeMs = lines[len(lines)-1].StartTimeMs + 5000 + } + + return lines +} + +func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 { + min, _ := strconv.ParseInt(minutes, 10, 64) + sec, _ := strconv.ParseInt(seconds, 10, 64) + cs, _ := strconv.ParseInt(centiseconds, 10, 64) + + if len(centiseconds) == 2 { + cs *= 10 + } + + return min*60*1000 + sec*1000 + cs +} + +func msToLRCTimestamp(ms int64) string { + totalSeconds := ms / 1000 + minutes := totalSeconds / 60 + seconds := totalSeconds % 60 + centiseconds := (ms % 1000) / 10 + + return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) +} + +func convertToLRC(lyrics *LyricsResponse) string { + if lyrics == nil || len(lyrics.Lines) == 0 { + return "" + } + + var builder strings.Builder + + if lyrics.SyncType == "LINE_SYNCED" { + for _, line := range lyrics.Lines { + timestamp := msToLRCTimestamp(line.StartTimeMs) + builder.WriteString(timestamp) + builder.WriteString(line.Words) + builder.WriteString("\n") + } + } else { + for _, line := range lyrics.Lines { + builder.WriteString(line.Words) + builder.WriteString("\n") + } + } + + return builder.String() +} + +func simplifyTrackName(name string) string { + patterns := []string{ + `\s*\(feat\..*?\)`, + `\s*\(ft\..*?\)`, + `\s*\(featuring.*?\)`, + `\s*\(with.*?\)`, + `\s*-\s*Remaster(ed)?.*$`, + `\s*-\s*\d{4}\s*Remaster.*$`, + `\s*\(Remaster(ed)?.*?\)`, + `\s*\(Deluxe.*?\)`, + `\s*\(Bonus.*?\)`, + `\s*\(Live.*?\)`, + `\s*\(Acoustic.*?\)`, + `\s*\(Radio Edit\)`, + `\s*\(Single Version\)`, + } + + result := name + for _, pattern := range patterns { + re := regexp.MustCompile("(?i)" + pattern) + result = re.ReplaceAllString(result, "") + } + + return strings.TrimSpace(result) +} diff --git a/go_backend/metadata.go b/go_backend/metadata.go new file mode 100644 index 00000000..496b1dda --- /dev/null +++ b/go_backend/metadata.go @@ -0,0 +1,337 @@ +package gobackend + +import ( + "fmt" + "os" + "strconv" + + "github.com/go-flac/flacpicture" + "github.com/go-flac/flacvorbis" + "github.com/go-flac/go-flac" +) + +// Metadata represents track metadata for embedding +type Metadata struct { + Title string + Artist string + Album string + AlbumArtist string + Date string + TrackNumber int + TotalTracks int + DiscNumber int + ISRC string + Description string + Lyrics string +} + +// EmbedMetadata embeds metadata into a FLAC file +func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { + f, err := flac.ParseFile(filePath) + if err != nil { + return fmt.Errorf("failed to parse FLAC file: %w", err) + } + + // Find or create vorbis comment block + var cmtIdx int = -1 + var cmt *flacvorbis.MetaDataBlockVorbisComment + + for idx, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmtIdx = idx + cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + return fmt.Errorf("failed to parse vorbis comment: %w", err) + } + break + } + } + + if cmt == nil { + cmt = flacvorbis.New() + } + + // Set metadata fields + setComment(cmt, "TITLE", metadata.Title) + setComment(cmt, "ARTIST", metadata.Artist) + setComment(cmt, "ALBUM", metadata.Album) + setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) + setComment(cmt, "DATE", metadata.Date) + + if metadata.TrackNumber > 0 { + if metadata.TotalTracks > 0 { + setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)) + } else { + setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber)) + } + } + + if metadata.DiscNumber > 0 { + setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) + } + + if metadata.ISRC != "" { + setComment(cmt, "ISRC", metadata.ISRC) + } + + if metadata.Description != "" { + setComment(cmt, "DESCRIPTION", metadata.Description) + } + + if metadata.Lyrics != "" { + setComment(cmt, "LYRICS", metadata.Lyrics) + setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) + } + + // Update or add vorbis comment block + cmtBlock := cmt.Marshal() + if cmtIdx >= 0 { + f.Meta[cmtIdx] = &cmtBlock + } else { + f.Meta = append(f.Meta, &cmtBlock) + } + + // Add cover art if provided + if coverPath != "" { + if fileExists(coverPath) { + coverData, err := os.ReadFile(coverPath) + if err != nil { + fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err) + } else { + // Remove existing picture blocks first (like PC version) + for i := len(f.Meta) - 1; i >= 0; i-- { + if f.Meta[i].Type == flac.Picture { + f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) + } + } + + picture, err := flacpicture.NewFromImageData( + flacpicture.PictureTypeFrontCover, + "Front Cover", + coverData, + "image/jpeg", + ) + if err != nil { + fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err) + } else { + picBlock := picture.Marshal() + f.Meta = append(f.Meta, &picBlock) + fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) + } + } + } else { + fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath) + } + } + + // Save file + return f.Save(filePath) +} + +// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes +// This avoids file permission issues on Android by not requiring a temp file +func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error { + f, err := flac.ParseFile(filePath) + if err != nil { + return fmt.Errorf("failed to parse FLAC file: %w", err) + } + + // Find or create vorbis comment block + var cmtIdx int = -1 + var cmt *flacvorbis.MetaDataBlockVorbisComment + + for idx, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmtIdx = idx + cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + return fmt.Errorf("failed to parse vorbis comment: %w", err) + } + break + } + } + + if cmt == nil { + cmt = flacvorbis.New() + } + + // Set metadata fields + setComment(cmt, "TITLE", metadata.Title) + setComment(cmt, "ARTIST", metadata.Artist) + setComment(cmt, "ALBUM", metadata.Album) + setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) + setComment(cmt, "DATE", metadata.Date) + + if metadata.TrackNumber > 0 { + if metadata.TotalTracks > 0 { + setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)) + } else { + setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber)) + } + } + + if metadata.DiscNumber > 0 { + setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) + } + + if metadata.ISRC != "" { + setComment(cmt, "ISRC", metadata.ISRC) + } + + if metadata.Description != "" { + setComment(cmt, "DESCRIPTION", metadata.Description) + } + + if metadata.Lyrics != "" { + setComment(cmt, "LYRICS", metadata.Lyrics) + setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) + } + + // Update or add vorbis comment block + cmtBlock := cmt.Marshal() + if cmtIdx >= 0 { + f.Meta[cmtIdx] = &cmtBlock + } else { + f.Meta = append(f.Meta, &cmtBlock) + } + + // Add cover art if provided + if len(coverData) > 0 { + // Remove existing picture blocks first + for i := len(f.Meta) - 1; i >= 0; i-- { + if f.Meta[i].Type == flac.Picture { + f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) + } + } + + picture, err := flacpicture.NewFromImageData( + flacpicture.PictureTypeFrontCover, + "Front Cover", + coverData, + "image/jpeg", + ) + if err != nil { + fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err) + } else { + picBlock := picture.Marshal() + f.Meta = append(f.Meta, &picBlock) + fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) + } + } + + // Save file + return f.Save(filePath) +} + +// ReadMetadata reads metadata from a FLAC file +func ReadMetadata(filePath string) (*Metadata, error) { + f, err := flac.ParseFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to parse FLAC file: %w", err) + } + + metadata := &Metadata{} + + for _, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + continue + } + + metadata.Title = getComment(cmt, "TITLE") + metadata.Artist = getComment(cmt, "ARTIST") + metadata.Album = getComment(cmt, "ALBUM") + metadata.AlbumArtist = getComment(cmt, "ALBUMARTIST") + metadata.Date = getComment(cmt, "DATE") + metadata.ISRC = getComment(cmt, "ISRC") + metadata.Description = getComment(cmt, "DESCRIPTION") + + metadata.Lyrics = getComment(cmt, "LYRICS") + if metadata.Lyrics == "" { + metadata.Lyrics = getComment(cmt, "UNSYNCEDLYRICS") + } + + trackNum := getComment(cmt, "TRACKNUMBER") + if trackNum != "" { + fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) + } + + discNum := getComment(cmt, "DISCNUMBER") + if discNum != "" { + fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) + } + + break + } + } + + return metadata, nil +} + +func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) { + if value == "" { + return + } + // Remove existing + for i := len(cmt.Comments) - 1; i >= 0; i-- { + if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" { + cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...) + } + } + // Add new + cmt.Comments = append(cmt.Comments, key+"="+value) +} + +func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string { + for _, comment := range cmt.Comments { + if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" { + return comment[len(key)+1:] + } + } + return "" +} + +// fileExists checks if a file exists +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// EmbedLyrics embeds lyrics into a FLAC file as a separate operation +func EmbedLyrics(filePath string, lyrics string) error { + f, err := flac.ParseFile(filePath) + if err != nil { + return fmt.Errorf("failed to parse FLAC file: %w", err) + } + + var cmtIdx int = -1 + var cmt *flacvorbis.MetaDataBlockVorbisComment + + for idx, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmtIdx = idx + cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + return fmt.Errorf("failed to parse vorbis comment: %w", err) + } + break + } + } + + if cmt == nil { + cmt = flacvorbis.New() + } + + setComment(cmt, "LYRICS", lyrics) + setComment(cmt, "UNSYNCEDLYRICS", lyrics) + + cmtBlock := cmt.Marshal() + if cmtIdx >= 0 { + f.Meta[cmtIdx] = &cmtBlock + } else { + f.Meta = append(f.Meta, &cmtBlock) + } + + return f.Save(filePath) +} diff --git a/go_backend/progress.go b/go_backend/progress.go new file mode 100644 index 00000000..49088fbf --- /dev/null +++ b/go_backend/progress.go @@ -0,0 +1,137 @@ +package gobackend + +import ( + "sync" +) + +// DownloadProgress represents current download progress +type DownloadProgress struct { + CurrentFile string `json:"current_file"` + Progress float64 `json:"progress"` + Speed float64 `json:"speed_mbps"` + BytesTotal int64 `json:"bytes_total"` + BytesReceived int64 `json:"bytes_received"` + IsDownloading bool `json:"is_downloading"` +} + +var ( + currentProgress DownloadProgress + progressMu sync.RWMutex + downloadDir string + downloadDirMu sync.RWMutex +) + +// getProgress returns current download progress +func getProgress() DownloadProgress { + progressMu.RLock() + defer progressMu.RUnlock() + return currentProgress +} + +// SetDownloadProgress sets the current download progress (MB downloaded) +func SetDownloadProgress(mbDownloaded float64) { + progressMu.Lock() + defer progressMu.Unlock() + currentProgress.Progress = mbDownloaded + currentProgress.IsDownloading = true +} + +// SetDownloadSpeed sets the current download speed +func SetDownloadSpeed(speedMBps float64) { + progressMu.Lock() + defer progressMu.Unlock() + currentProgress.Speed = speedMBps +} + +// SetCurrentFile sets the current file being downloaded and resets progress +func SetCurrentFile(filename string) { + progressMu.Lock() + defer progressMu.Unlock() + // Reset progress for new file + currentProgress.BytesReceived = 0 + currentProgress.BytesTotal = 0 + currentProgress.Progress = 0 + currentProgress.CurrentFile = filename + currentProgress.IsDownloading = true +} + +// ResetProgress resets the download progress +func ResetProgress() { + progressMu.Lock() + defer progressMu.Unlock() + currentProgress = DownloadProgress{} +} + +// setDownloadDir sets the default download directory +func setDownloadDir(path string) error { + downloadDirMu.Lock() + defer downloadDirMu.Unlock() + downloadDir = path + return nil +} + +// getDownloadDir returns the default download directory +func getDownloadDir() string { + downloadDirMu.RLock() + defer downloadDirMu.RUnlock() + return downloadDir +} + +// SetDownloading sets the download status +func SetDownloading(status bool) { + progressMu.Lock() + defer progressMu.Unlock() + currentProgress.IsDownloading = status +} + +// SetBytesTotal sets total bytes to download +func SetBytesTotal(total int64) { + progressMu.Lock() + defer progressMu.Unlock() + currentProgress.BytesTotal = total +} + +// SetBytesReceived sets bytes received so far +func SetBytesReceived(received int64) { + progressMu.Lock() + defer progressMu.Unlock() + currentProgress.BytesReceived = received + if currentProgress.BytesTotal > 0 { + currentProgress.Progress = float64(received) / float64(currentProgress.BytesTotal) * 100 + } +} + +// ProgressWriter wraps io.Writer to track download progress +type ProgressWriter struct { + writer interface{ Write([]byte) (int, error) } + total int64 + current int64 +} + +// NewProgressWriter creates a new progress writer wrapping an io.Writer +func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter { + // Reset bytes received when starting new download + SetBytesReceived(0) + return &ProgressWriter{ + writer: w, + current: 0, + total: 0, + } +} + +// Write implements io.Writer +func (pw *ProgressWriter) Write(p []byte) (int, error) { + n, err := pw.writer.Write(p) + if err != nil { + return n, err + } + pw.current += int64(n) + pw.total += int64(n) + SetBytesReceived(pw.current) + return n, nil +} + +// GetTotal returns total bytes written +func (pw *ProgressWriter) GetTotal() int64 { + return pw.total +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go new file mode 100644 index 00000000..1c6fea55 --- /dev/null +++ b/go_backend/qobuz.go @@ -0,0 +1,411 @@ +package gobackend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" +) + +// QobuzDownloader handles Qobuz downloads +type QobuzDownloader struct { + client *http.Client + appID string + apiURL string +} + +// QobuzTrack represents a Qobuz track +type QobuzTrack struct { + ID int64 `json:"id"` + Title string `json:"title"` + ISRC string `json:"isrc"` + Duration int `json:"duration"` + TrackNumber int `json:"track_number"` + MaximumBitDepth int `json:"maximum_bit_depth"` + MaximumSamplingRate float64 `json:"maximum_sampling_rate"` + Album struct { + Title string `json:"title"` + ReleaseDate string `json:"release_date_original"` + Image struct { + Large string `json:"large"` + } `json:"image"` + } `json:"album"` + Performer struct { + Name string `json:"name"` + } `json:"performer"` +} + +// NewQobuzDownloader creates a new Qobuz downloader +func NewQobuzDownloader() *QobuzDownloader { + return &QobuzDownloader{ + client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout + appID: "798273057", + } +} + +// GetAvailableAPIs returns list of available Qobuz APIs +// Uses same APIs as PC version for compatibility +func (q *QobuzDownloader) GetAvailableAPIs() []string { + // Same APIs as PC version (referensi/backend/qobuz.go) + // Primary: dab.yeet.su, Fallback: dabmusic.xyz + encodedAPIs := []string{ + "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC) + "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC) + } + + var apis []string + for _, encoded := range encodedAPIs { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + continue + } + apis = append(apis, "https://"+string(decoded)) + } + + return apis +} + +// SearchTrackByISRC searches for a track by ISRC +func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") + searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) + } + + var result struct { + Tracks struct { + Items []QobuzTrack `json:"items"` + } `json:"tracks"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + // Find exact ISRC match + for i := range result.Tracks.Items { + if result.Tracks.Items[i].ISRC == isrc { + return &result.Tracks.Items[i], nil + } + } + + if len(result.Tracks.Items) == 0 { + return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) + } + + return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) +} + +// SearchTrackByMetadata searches for a track using artist name and track name +func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) { + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") + + // Try multiple search strategies + queries := []string{} + + // Strategy 1: Artist + Track name + if artistName != "" && trackName != "" { + queries = append(queries, artistName+" "+trackName) + } + + // Strategy 2: Track name only + if trackName != "" { + queries = append(queries, trackName) + } + + for _, query := range queries { + searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + continue + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + continue + } + + if resp.StatusCode != 200 { + resp.Body.Close() + continue + } + + var result struct { + Tracks struct { + Items []QobuzTrack `json:"items"` + } `json:"tracks"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + resp.Body.Close() + continue + } + resp.Body.Close() + + if len(result.Tracks.Items) > 0 { + // Return first result with best quality + for i := range result.Tracks.Items { + track := &result.Tracks.Items[i] + if track.MaximumBitDepth >= 24 { + return track, nil + } + } + // Return first result if no hi-res found + return &result.Tracks.Items[0], nil + } + } + + return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) +} + +// getQobuzDownloadURLSequential requests download URL from APIs sequentially +// Uses same URL format as PC version: /api/stream?trackId={id}&quality={quality} +func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) { + if len(apis) == 0 { + return "", "", fmt.Errorf("no APIs available") + } + + client := NewHTTPClientWithTimeout(DefaultTimeout) + retryConfig := DefaultRetryConfig() + var errors []string + + for _, apiURL := range apis { + // All APIs now use same format: https://domain/api/stream?trackId={id}&quality={quality} + // The apiURL already includes the path, just append trackID and quality + reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality) + + fmt.Printf("[Qobuz] Trying: %s\n", reqURL) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) + continue + } + + resp, err := DoRequestWithRetry(client, req, retryConfig) + if err != nil { + errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) + continue + } + + body, err := ReadResponseBody(resp) + resp.Body.Close() + if err != nil { + errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error())) + continue + } + + // Check if response is HTML (error page) + if len(body) > 0 && body[0] == '<' { + errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "received HTML instead of JSON")) + continue + } + + // Check for error in JSON response + var errorResp struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" { + errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, errorResp.Error)) + continue + } + + var result struct { + URL string `json:"url"` + } + if err := json.Unmarshal(body, &result); err != nil { + errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "invalid JSON: "+err.Error())) + continue + } + + if result.URL != "" { + fmt.Printf("[Qobuz] Got download URL from: %s\n", apiURL) + return apiURL, result.URL, nil + } + + errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL in response")) + } + + return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) +} + +// GetDownloadURL gets download URL for a track - tries APIs sequentially +func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { + apis := q.GetAvailableAPIs() + if len(apis) == 0 { + return "", fmt.Errorf("no Qobuz API available") + } + + _, downloadURL, err := getQobuzDownloadURLSequential(apis, trackID, quality) + if err != nil { + return "", err + } + + return downloadURL, nil +} + +// DownloadFile downloads a file from URL with User-Agent and progress tracking +func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error { + // Set current file being downloaded + SetCurrentFile(filepath.Base(outputPath)) + SetDownloading(true) + defer SetDownloading(false) + + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + } + + // Set total bytes if available + if resp.ContentLength > 0 { + SetBytesTotal(resp.ContentLength) + } + + out, err := os.Create(outputPath) + if err != nil { + return err + } + defer out.Close() + + // Use ProgressWriter for tracking + progressWriter := NewProgressWriter(out) + _, err = io.Copy(progressWriter, resp.Body) + return err +} + +// downloadFromQobuz downloads a track using the request parameters +func downloadFromQobuz(req DownloadRequest) (string, error) { + downloader := NewQobuzDownloader() + + // Check for existing file first + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return "EXISTS:" + existingFile, nil + } + + var track *QobuzTrack + var err error + + // Strategy 1: Search by ISRC + if req.ISRC != "" { + track, err = downloader.SearchTrackByISRC(req.ISRC) + } + + // Strategy 2: Search by metadata + if track == nil { + track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName) + } + + if track == nil { + errMsg := "could not find track on Qobuz" + if err != nil { + errMsg = err.Error() + } + return "", fmt.Errorf("qobuz search failed: %s", errMsg) + } + + // Build filename + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "track": req.TrackNumber, + "year": extractYear(req.ReleaseDate), + "disc": req.DiscNumber, + }) + filename = sanitizeFilename(filename) + ".flac" + outputPath := filepath.Join(req.OutputDir, filename) + + // Check if file already exists + if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { + return "EXISTS:" + outputPath, nil + } + + // Get download URL using parallel API requests + downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit + if err != nil { + return "", fmt.Errorf("failed to get download URL: %w", err) + } + + // Download file + if err := downloader.DownloadFile(downloadURL, outputPath); err != nil { + return "", fmt.Errorf("download failed: %w", err) + } + + // Embed metadata + metadata := Metadata{ + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + AlbumArtist: req.AlbumArtist, + Date: req.ReleaseDate, + TrackNumber: req.TrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + } + + // Download cover to memory (avoids file permission issues on Android) + var coverData []byte + if req.CoverURL != "" { + fmt.Println("[Qobuz] Downloading cover to memory...") + data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover) + if err == nil { + coverData = data + fmt.Printf("[Qobuz] Cover downloaded successfully (%d bytes)\n", len(coverData)) + } else { + fmt.Printf("[Qobuz] Warning: failed to download cover: %v\n", err) + } + } + + if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { + fmt.Printf("Warning: failed to embed metadata: %v\n", err) + } + + // Embed lyrics if enabled + if req.EmbedLyrics { + fmt.Println("[Qobuz] Fetching lyrics...") + lyricsClient := NewLyricsClient() + lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName) + if lyricsErr != nil { + fmt.Printf("[Qobuz] Warning: lyrics fetch error: %v\n", lyricsErr) + } else if lyrics == nil || len(lyrics.Lines) == 0 { + fmt.Println("[Qobuz] No lyrics found for this track") + } else { + fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) + lrcContent := convertToLRC(lyrics) + if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil { + fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) + } else { + fmt.Println("[Qobuz] Lyrics embedded successfully") + } + } + } + + return outputPath, nil +} diff --git a/go_backend/ratelimit.go b/go_backend/ratelimit.go new file mode 100644 index 00000000..eefc0272 --- /dev/null +++ b/go_backend/ratelimit.go @@ -0,0 +1,111 @@ +package gobackend + +import ( + "sync" + "time" +) + +// RateLimiter implements a sliding window rate limiter +type RateLimiter struct { + mu sync.Mutex + maxRequests int + window time.Duration + timestamps []time.Time +} + +// NewRateLimiter creates a new rate limiter with specified max requests per window +func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter { + return &RateLimiter{ + maxRequests: maxRequests, + window: window, + timestamps: make([]time.Time, 0, maxRequests), + } +} + +// WaitForSlot blocks until a request is allowed under the rate limit +// Returns immediately if under the limit, otherwise waits until a slot is available +func (r *RateLimiter) WaitForSlot() { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + + // Remove timestamps outside the window + r.cleanOldTimestamps(now) + + // If under limit, record and return immediately + if len(r.timestamps) < r.maxRequests { + r.timestamps = append(r.timestamps, now) + return + } + + // Calculate wait time until oldest timestamp expires + oldestTimestamp := r.timestamps[0] + waitUntil := oldestTimestamp.Add(r.window) + waitDuration := waitUntil.Sub(now) + + if waitDuration > 0 { + // Release lock while waiting + r.mu.Unlock() + time.Sleep(waitDuration) + r.mu.Lock() + + // Clean again after waiting + r.cleanOldTimestamps(time.Now()) + } + + // Record this request + r.timestamps = append(r.timestamps, time.Now()) +} + +// cleanOldTimestamps removes timestamps that are outside the current window +func (r *RateLimiter) cleanOldTimestamps(now time.Time) { + cutoff := now.Add(-r.window) + validStart := 0 + + for i, ts := range r.timestamps { + if ts.After(cutoff) { + validStart = i + break + } + validStart = i + 1 + } + + if validStart > 0 { + r.timestamps = r.timestamps[validStart:] + } +} + +// TryAcquire attempts to acquire a slot without blocking +// Returns true if successful, false if rate limit would be exceeded +func (r *RateLimiter) TryAcquire() bool { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + r.cleanOldTimestamps(now) + + if len(r.timestamps) < r.maxRequests { + r.timestamps = append(r.timestamps, now) + return true + } + + return false +} + +// Available returns the number of requests available in the current window +func (r *RateLimiter) Available() int { + r.mu.Lock() + defer r.mu.Unlock() + + r.cleanOldTimestamps(time.Now()) + return r.maxRequests - len(r.timestamps) +} + +// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10) +var songLinkRateLimiter = NewRateLimiter(9, time.Minute) + +// GetSongLinkRateLimiter returns the global SongLink rate limiter +func GetSongLinkRateLimiter() *RateLimiter { + return songLinkRateLimiter +} diff --git a/go_backend/romaji.go b/go_backend/romaji.go new file mode 100644 index 00000000..6acf10a6 --- /dev/null +++ b/go_backend/romaji.go @@ -0,0 +1,276 @@ +package gobackend + +import ( + "strings" + "unicode" +) + +// Japanese character ranges +const ( + hiraganaStart = 0x3040 + hiraganaEnd = 0x309F + katakanaStart = 0x30A0 + katakanaEnd = 0x30FF + kanjiStart = 0x4E00 + kanjiEnd = 0x9FFF +) + +// hiraganaToRomaji maps hiragana characters to romaji +var hiraganaToRomaji = map[rune]string{ + // Basic vowels + 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o", + // K-row + 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko", + // S-row + 'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so", + // T-row + 'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to", + // N-row + 'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no", + // H-row + 'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho", + // M-row + 'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo", + // Y-row + 'や': "ya", 'ゆ': "yu", 'よ': "yo", + // R-row + 'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro", + // W-row + 'わ': "wa", 'を': "wo", + // N + 'ん': "n", + // Voiced (dakuten) - G-row + 'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go", + // Z-row + 'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo", + // D-row + 'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do", + // B-row + 'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo", + // P-row (handakuten) + 'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po", + // Small characters + 'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo", + 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", + 'っ': "", // Small tsu - handled specially + // Long vowel mark + 'ー': "", +} + +// katakanaToRomaji maps katakana characters to romaji +var katakanaToRomaji = map[rune]string{ + // Basic vowels + 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o", + // K-row + 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko", + // S-row + 'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so", + // T-row + 'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to", + // N-row + 'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no", + // H-row + 'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho", + // M-row + 'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo", + // Y-row + 'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo", + // R-row + 'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro", + // W-row + 'ワ': "wa", 'ヲ': "wo", + // N + 'ン': "n", + // Voiced (dakuten) - G-row + 'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go", + // Z-row + 'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo", + // D-row + 'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do", + // B-row + 'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo", + // P-row (handakuten) + 'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po", + // Small characters + 'ャ': "ya", 'ュ': "yu", 'ョ': "yo", + 'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o", + 'ッ': "", // Small tsu - handled specially + // Extended katakana + 'ヴ': "vu", + // Long vowel mark + 'ー': "", +} + +// Extended katakana combinations (multi-character) +var katakanaExtended = map[string]string{ + "ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo", +} + +// Combination mappings for small ya/yu/yo +var hiraganaCombo = map[string]string{ + "きゃ": "kya", "きゅ": "kyu", "きょ": "kyo", + "しゃ": "sha", "しゅ": "shu", "しょ": "sho", + "ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho", + "にゃ": "nya", "にゅ": "nyu", "にょ": "nyo", + "ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo", + "みゃ": "mya", "みゅ": "myu", "みょ": "myo", + "りゃ": "rya", "りゅ": "ryu", "りょ": "ryo", + "ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo", + "じゃ": "ja", "じゅ": "ju", "じょ": "jo", + "びゃ": "bya", "びゅ": "byu", "びょ": "byo", + "ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo", +} + +var katakanaCombo = map[string]string{ + "キャ": "kya", "キュ": "kyu", "キョ": "kyo", + "シャ": "sha", "シュ": "shu", "ショ": "sho", + "チャ": "cha", "チュ": "chu", "チョ": "cho", + "ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo", + "ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo", + "ミャ": "mya", "ミュ": "myu", "ミョ": "myo", + "リャ": "rya", "リュ": "ryu", "リョ": "ryo", + "ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo", + "ジャ": "ja", "ジュ": "ju", "ジョ": "jo", + "ビャ": "bya", "ビュ": "byu", "ビョ": "byo", + "ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo", + // Extended katakana combinations + "ティ": "ti", "ディ": "di", + "トゥ": "tu", "ドゥ": "du", + "ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo", + "ウィ": "wi", "ウェ": "we", "ウォ": "wo", + "ヴァ": "va", "ヴィ": "vi", "ヴェ": "ve", "ヴォ": "vo", +} + +// ContainsJapanese checks if a string contains Japanese characters (Hiragana, Katakana, or Kanji) +func ContainsJapanese(s string) bool { + for _, r := range s { + if isHiragana(r) || isKatakana(r) || isKanji(r) { + return true + } + } + return false +} + +// ContainsKana checks if a string contains Hiragana or Katakana (convertible to romaji) +func ContainsKana(s string) bool { + for _, r := range s { + if isHiragana(r) || isKatakana(r) { + return true + } + } + return false +} + +func isHiragana(r rune) bool { + return r >= hiraganaStart && r <= hiraganaEnd +} + +func isKatakana(r rune) bool { + return r >= katakanaStart && r <= katakanaEnd +} + +func isKanji(r rune) bool { + return r >= kanjiStart && r <= kanjiEnd +} + +// ToRomaji converts Japanese kana (Hiragana/Katakana) to romaji +// Kanji characters are preserved as-is since they require dictionary lookup +func ToRomaji(s string) string { + if !ContainsKana(s) { + return s + } + + runes := []rune(s) + var result strings.Builder + result.Grow(len(s) * 2) // Romaji is typically longer + + i := 0 + for i < len(runes) { + r := runes[i] + + // Check for two-character combinations first + if i+1 < len(runes) { + combo := string(runes[i : i+2]) + if romaji, ok := hiraganaCombo[combo]; ok { + result.WriteString(romaji) + i += 2 + continue + } + if romaji, ok := katakanaCombo[combo]; ok { + result.WriteString(romaji) + i += 2 + continue + } + } + + // Handle small tsu (っ/ッ) - doubles the next consonant + if r == 'っ' || r == 'ッ' { + if i+1 < len(runes) { + nextRune := runes[i+1] + var nextRomaji string + if romaji, ok := hiraganaToRomaji[nextRune]; ok { + nextRomaji = romaji + } else if romaji, ok := katakanaToRomaji[nextRune]; ok { + nextRomaji = romaji + } + if len(nextRomaji) > 0 { + result.WriteByte(nextRomaji[0]) // Double the consonant + } + } + i++ + continue + } + + // Handle long vowel mark (ー) + if r == 'ー' { + // Extend the previous vowel + resultStr := result.String() + if len(resultStr) > 0 { + lastChar := resultStr[len(resultStr)-1] + if lastChar == 'a' || lastChar == 'i' || lastChar == 'u' || lastChar == 'e' || lastChar == 'o' { + result.WriteByte(lastChar) + } + } + i++ + continue + } + + // Single character conversion + if romaji, ok := hiraganaToRomaji[r]; ok { + result.WriteString(romaji) + i++ + continue + } + + if romaji, ok := katakanaToRomaji[r]; ok { + result.WriteString(romaji) + i++ + continue + } + + // Keep non-Japanese characters as-is + if unicode.IsSpace(r) { + result.WriteRune(' ') + } else { + result.WriteRune(r) + } + i++ + } + + return result.String() +} + +// GetRomajiVariants returns search variants for Japanese text +// Returns the original string plus romaji version if applicable +func GetRomajiVariants(s string) []string { + variants := []string{s} + + if ContainsKana(s) { + romaji := ToRomaji(s) + if romaji != s && strings.TrimSpace(romaji) != "" { + variants = append(variants, romaji) + } + } + + return variants +} diff --git a/go_backend/songlink.go b/go_backend/songlink.go new file mode 100644 index 00000000..64476f71 --- /dev/null +++ b/go_backend/songlink.go @@ -0,0 +1,153 @@ +package gobackend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// SongLinkClient handles song.link API interactions +type SongLinkClient struct { + client *http.Client +} + +// TrackAvailability represents track availability on different platforms +type TrackAvailability struct { + SpotifyID string `json:"spotify_id"` + Tidal bool `json:"tidal"` + Amazon bool `json:"amazon"` + Qobuz bool `json:"qobuz"` + TidalURL string `json:"tidal_url,omitempty"` + AmazonURL string `json:"amazon_url,omitempty"` + QobuzURL string `json:"qobuz_url,omitempty"` +} + +// NewSongLinkClient creates a new SongLink client +func NewSongLinkClient() *SongLinkClient { + return &SongLinkClient{ + client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout + } +} + +// CheckTrackAvailability checks track availability on streaming platforms +func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { + // Use global rate limiter - blocks until request is allowed + songLinkRateLimiter.WaitForSlot() + + // Build API URL + spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") + spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) + + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") + apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Use retry logic with User-Agent + retryConfig := DefaultRetryConfig() + resp, err := DoRequestWithRetry(s.client, req, retryConfig) + if err != nil { + return nil, fmt.Errorf("failed to check availability: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + body, err := ReadResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var songLinkResp struct { + LinksByPlatform map[string]struct { + URL string `json:"url"` + } `json:"linksByPlatform"` + } + + if err := json.Unmarshal(body, &songLinkResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + availability := &TrackAvailability{ + SpotifyID: spotifyTrackID, + } + + // Check Tidal + if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { + availability.Tidal = true + availability.TidalURL = tidalLink.URL + } + + // Check Amazon + if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { + availability.Amazon = true + availability.AmazonURL = amazonLink.URL + } + + // Check Qobuz using ISRC + if isrc != "" { + availability.Qobuz = checkQobuzAvailability(isrc) + } + + return availability, nil +} + +// GetStreamingURLs gets streaming URLs for a Spotify track +func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) { + availability, err := s.CheckTrackAvailability(spotifyTrackID, "") + if err != nil { + return nil, err + } + + urls := make(map[string]string) + if availability.TidalURL != "" { + urls["tidal"] = availability.TidalURL + } + if availability.AmazonURL != "" { + urls["amazon"] = availability.AmazonURL + } + + return urls, nil +} + +func checkQobuzAvailability(isrc string) bool { + client := NewHTTPClientWithTimeout(10 * time.Second) + appID := "798273057" + + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") + searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return false + } + + resp, err := DoRequestWithUserAgent(client, req) + if err != nil { + return false + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return false + } + + var searchResp struct { + Tracks struct { + Total int `json:"total"` + } `json:"tracks"` + } + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return false + } + + return searchResp.Tracks.Total > 0 +} diff --git a/go_backend/spotify.go b/go_backend/spotify.go new file mode 100644 index 00000000..745a21ac --- /dev/null +++ b/go_backend/spotify.go @@ -0,0 +1,616 @@ +package gobackend + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const ( + spotifyTokenURL = "https://accounts.spotify.com/api/token" + playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" + albumBaseURL = "https://api.spotify.com/v1/albums/%s" + trackBaseURL = "https://api.spotify.com/v1/tracks/%s" + searchBaseURL = "https://api.spotify.com/v1/search" +) + +var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") + +// SpotifyMetadataClient handles Spotify API interactions +type SpotifyMetadataClient struct { + httpClient *http.Client + clientID string + clientSecret string + cachedToken string + tokenExpiresAt time.Time + rng *rand.Rand + rngMu sync.Mutex + userAgent string +} + +// NewSpotifyMetadataClient creates a new Spotify client +func NewSpotifyMetadataClient() *SpotifyMetadataClient { + src := rand.NewSource(time.Now().UnixNano()) + + // Decode credentials from base64 + clientID := "" + if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil { + clientID = string(decoded) + } + + clientSecret := "" + if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil { + clientSecret = string(decoded) + } + + c := &SpotifyMetadataClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + clientID: clientID, + clientSecret: clientSecret, + rng: rand.New(src), + } + c.userAgent = c.randomUserAgent() + return c +} + +// TrackMetadata represents track information +type TrackMetadata struct { + SpotifyID string `json:"spotify_id,omitempty"` + Artists string `json:"artists"` + Name string `json:"name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + DurationMS int `json:"duration_ms"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TrackNumber int `json:"track_number"` + TotalTracks int `json:"total_tracks,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ExternalURL string `json:"external_urls"` + ISRC string `json:"isrc"` +} + +// AlbumTrackMetadata holds per-track info for album/playlist +type AlbumTrackMetadata struct { + SpotifyID string `json:"spotify_id,omitempty"` + Artists string `json:"artists"` + Name string `json:"name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + DurationMS int `json:"duration_ms"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TrackNumber int `json:"track_number"` + TotalTracks int `json:"total_tracks,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ExternalURL string `json:"external_urls"` + ISRC string `json:"isrc"` + AlbumID string `json:"album_id,omitempty"` + AlbumURL string `json:"album_url,omitempty"` +} + +// AlbumInfoMetadata holds album information +type AlbumInfoMetadata struct { + TotalTracks int `json:"total_tracks"` + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + Artists string `json:"artists"` + Images string `json:"images"` +} + +// AlbumResponsePayload is the response for album requests +type AlbumResponsePayload struct { + AlbumInfo AlbumInfoMetadata `json:"album_info"` + TrackList []AlbumTrackMetadata `json:"track_list"` +} + +// PlaylistInfoMetadata holds playlist information +type PlaylistInfoMetadata struct { + Tracks struct { + Total int `json:"total"` + } `json:"tracks"` + Owner struct { + DisplayName string `json:"display_name"` + Name string `json:"name"` + Images string `json:"images"` + } `json:"owner"` +} + +// PlaylistResponsePayload is the response for playlist requests +type PlaylistResponsePayload struct { + PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` + TrackList []AlbumTrackMetadata `json:"track_list"` +} + +// TrackResponse is the response for single track requests +type TrackResponse struct { + Track TrackMetadata `json:"track"` +} + +// SearchResult represents search results +type SearchResult struct { + Tracks []TrackMetadata `json:"tracks"` + Total int `json:"total"` +} + +type spotifyURI struct { + Type string + ID string +} + +type accessTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn interface{} `json:"expires_in"` + TokenType string `json:"token_type"` +} + +// Internal API response types +type image struct { + URL string `json:"url"` +} + +type externalURL struct { + Spotify string `json:"spotify"` +} + +type externalID struct { + ISRC string `json:"isrc"` +} + +type artist struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type albumSimplified struct { + ID string `json:"id"` + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + TotalTracks int `json:"total_tracks"` + Images []image `json:"images"` + ExternalURL externalURL `json:"external_urls"` + Artists []artist `json:"artists"` +} + +type trackFull struct { + ID string `json:"id"` + Name string `json:"name"` + DurationMS int `json:"duration_ms"` + TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` + ExternalURL externalURL `json:"external_urls"` + ExternalID externalID `json:"external_ids"` + Album albumSimplified `json:"album"` + Artists []artist `json:"artists"` +} + +// GetFilteredData fetches and formats Spotify data +func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { + parsed, err := parseSpotifyURI(spotifyURL) + if err != nil { + return nil, err + } + + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + switch parsed.Type { + case "track": + return c.fetchTrack(ctx, parsed.ID, token) + case "album": + return c.fetchAlbum(ctx, parsed.ID, token) + case "playlist": + return c.fetchPlaylist(ctx, parsed.ID, token) + default: + return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type) + } +} + +// SearchTracks searches for tracks on Spotify +func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) { + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit) + + var response struct { + Tracks struct { + Items []trackFull `json:"items"` + Total int `json:"total"` + } `json:"tracks"` + } + + if err := c.getJSON(ctx, searchURL, token, &response); err != nil { + return nil, err + } + + result := &SearchResult{ + Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)), + Total: response.Tracks.Total, + } + + for _, track := range response.Tracks.Items { + result.Tracks = append(result.Tracks, TrackMetadata{ + SpotifyID: track.ID, + Artists: joinArtists(track.Artists), + Name: track.Name, + AlbumName: track.Album.Name, + AlbumArtist: joinArtists(track.Album.Artists), + DurationMS: track.DurationMS, + Images: firstImageURL(track.Album.Images), + ReleaseDate: track.Album.ReleaseDate, + TrackNumber: track.TrackNumber, + TotalTracks: track.Album.TotalTracks, + DiscNumber: track.DiscNumber, + ExternalURL: track.ExternalURL.Spotify, + ISRC: track.ExternalID.ISRC, + }) + } + + return result, nil +} + +func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) { + var data trackFull + if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil { + return nil, err + } + + return &TrackResponse{ + Track: TrackMetadata{ + SpotifyID: data.ID, + Artists: joinArtists(data.Artists), + Name: data.Name, + AlbumName: data.Album.Name, + AlbumArtist: joinArtists(data.Album.Artists), + DurationMS: data.DurationMS, + Images: firstImageURL(data.Album.Images), + ReleaseDate: data.Album.ReleaseDate, + TrackNumber: data.TrackNumber, + TotalTracks: data.Album.TotalTracks, + DiscNumber: data.DiscNumber, + ExternalURL: data.ExternalURL.Spotify, + ISRC: data.ExternalID.ISRC, + }, + }, nil +} + +func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) { + var data struct { + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + TotalTracks int `json:"total_tracks"` + Images []image `json:"images"` + Artists []artist `json:"artists"` + Tracks struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + DurationMS int `json:"duration_ms"` + TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` + ExternalURL externalURL `json:"external_urls"` + Artists []artist `json:"artists"` + } `json:"items"` + } `json:"tracks"` + } + + if err := c.getJSON(ctx, fmt.Sprintf(albumBaseURL, albumID), token, &data); err != nil { + return nil, err + } + + albumImage := firstImageURL(data.Images) + info := AlbumInfoMetadata{ + TotalTracks: data.TotalTracks, + Name: data.Name, + ReleaseDate: data.ReleaseDate, + Artists: joinArtists(data.Artists), + Images: albumImage, + } + + tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items)) + for _, item := range data.Tracks.Items { + // Fetch ISRC for each track + isrc := c.fetchTrackISRC(ctx, item.ID, token) + + tracks = append(tracks, AlbumTrackMetadata{ + SpotifyID: item.ID, + Artists: joinArtists(item.Artists), + Name: item.Name, + AlbumName: data.Name, + AlbumArtist: joinArtists(data.Artists), + DurationMS: item.DurationMS, + Images: albumImage, + ReleaseDate: data.ReleaseDate, + TrackNumber: item.TrackNumber, + TotalTracks: data.TotalTracks, + DiscNumber: item.DiscNumber, + ExternalURL: item.ExternalURL.Spotify, + ISRC: isrc, + AlbumID: albumID, + }) + } + + return &AlbumResponsePayload{ + AlbumInfo: info, + TrackList: tracks, + }, nil +} + +func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { + var data struct { + Name string `json:"name"` + Images []image `json:"images"` + Owner struct { + DisplayName string `json:"display_name"` + } `json:"owner"` + Tracks struct { + Items []struct { + Track *trackFull `json:"track"` + } `json:"items"` + Total int `json:"total"` + } `json:"tracks"` + } + + if err := c.getJSON(ctx, fmt.Sprintf(playlistBaseURL, playlistID), token, &data); err != nil { + return nil, err + } + + var info PlaylistInfoMetadata + info.Tracks.Total = data.Tracks.Total + info.Owner.DisplayName = data.Owner.DisplayName + info.Owner.Name = data.Name + info.Owner.Images = firstImageURL(data.Images) + + tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items)) + for _, item := range data.Tracks.Items { + if item.Track == nil { + continue + } + tracks = append(tracks, AlbumTrackMetadata{ + SpotifyID: item.Track.ID, + Artists: joinArtists(item.Track.Artists), + Name: item.Track.Name, + AlbumName: item.Track.Album.Name, + AlbumArtist: joinArtists(item.Track.Album.Artists), + DurationMS: item.Track.DurationMS, + Images: firstImageURL(item.Track.Album.Images), + ReleaseDate: item.Track.Album.ReleaseDate, + TrackNumber: item.Track.TrackNumber, + TotalTracks: item.Track.Album.TotalTracks, + DiscNumber: item.Track.DiscNumber, + ExternalURL: item.Track.ExternalURL.Spotify, + ISRC: item.Track.ExternalID.ISRC, + AlbumID: item.Track.Album.ID, + AlbumURL: item.Track.Album.ExternalURL.Spotify, + }) + } + + return &PlaylistResponsePayload{ + PlaylistInfo: info, + TrackList: tracks, + }, nil +} + +func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string { + var data struct { + ExternalID externalID `json:"external_ids"` + } + if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil { + return "" + } + return data.ExternalID.ISRC +} + +func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) { + if c.cachedToken != "" && time.Now().Before(c.tokenExpiresAt) { + return c.cachedToken, nil + } + + data := url.Values{} + data.Set("grant_type", "client_credentials") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + + req.SetBasicAuth(c.clientID, c.clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get access token: %d", resp.StatusCode) + } + + var token accessTokenResponse + if err := json.Unmarshal(body, &token); err != nil { + return "", err + } + + c.cachedToken = token.AccessToken + if expiresIn, ok := token.ExpiresIn.(float64); ok { + c.tokenExpiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) + } + + return token.AccessToken, nil +} + +func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token string, dst interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("Accept", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("spotify API returned status %d", resp.StatusCode) + } + + return json.Unmarshal(body, dst) +} + +func (c *SpotifyMetadataClient) randomUserAgent() string { + c.rngMu.Lock() + defer c.rngMu.Unlock() + + chromeMajor := 80 + c.rng.Intn(25) + chromeBuild := 3000 + c.rng.Intn(1500) + chromePatch := 60 + c.rng.Intn(65) + + return fmt.Sprintf( + "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36", + chromeMajor, chromeBuild, chromePatch, + ) +} + +func parseSpotifyURI(input string) (spotifyURI, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return spotifyURI{}, errInvalidSpotifyURL + } + + // Handle spotify: URI format + if strings.HasPrefix(trimmed, "spotify:") { + parts := strings.Split(trimmed, ":") + if len(parts) == 3 { + switch parts[1] { + case "album", "track", "playlist", "artist": + return spotifyURI{Type: parts[1], ID: parts[2]}, nil + } + } + } + + // Handle URL format + parsed, err := url.Parse(trimmed) + if err != nil { + return spotifyURI{}, err + } + + // Handle embed.spotify.com URLs + if parsed.Host == "embed.spotify.com" { + if parsed.RawQuery == "" { + return spotifyURI{}, errInvalidSpotifyURL + } + qs, _ := url.ParseQuery(parsed.RawQuery) + embedded := qs.Get("uri") + if embedded == "" { + return spotifyURI{}, errInvalidSpotifyURL + } + return parseSpotifyURI(embedded) + } + + // Handle plain ID (no scheme/host) - defaults to playlist + if parsed.Scheme == "" && parsed.Host == "" { + id := strings.Trim(strings.TrimSpace(parsed.Path), "/") + if id == "" { + return spotifyURI{}, errInvalidSpotifyURL + } + return spotifyURI{Type: "playlist", ID: id}, nil + } + + if parsed.Host != "open.spotify.com" && parsed.Host != "play.spotify.com" { + return spotifyURI{}, errInvalidSpotifyURL + } + + parts := cleanPathParts(parsed.Path) + if len(parts) == 0 { + return spotifyURI{}, errInvalidSpotifyURL + } + + // Skip embed prefix if present + if parts[0] == "embed" { + parts = parts[1:] + } + if len(parts) == 0 { + return spotifyURI{}, errInvalidSpotifyURL + } + + // Skip intl- prefix if present + if strings.HasPrefix(parts[0], "intl-") { + parts = parts[1:] + } + if len(parts) == 0 { + return spotifyURI{}, errInvalidSpotifyURL + } + + // Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id} + if len(parts) == 2 { + switch parts[0] { + case "album", "track", "playlist", "artist": + return spotifyURI{Type: parts[0], ID: parts[1]}, nil + } + } + + // Handle nested playlist URLs: /user/{user}/playlist/{id} + if len(parts) == 4 && parts[2] == "playlist" { + return spotifyURI{Type: "playlist", ID: parts[3]}, nil + } + + return spotifyURI{}, errInvalidSpotifyURL +} + +func cleanPathParts(path string) []string { + raw := strings.Split(path, "/") + parts := make([]string, 0, len(raw)) + for _, part := range raw { + if part != "" { + parts = append(parts, part) + } + } + return parts +} + +func joinArtists(artists []artist) string { + names := make([]string, len(artists)) + for i, a := range artists { + names[i] = a.Name + } + return strings.Join(names, ", ") +} + +func firstImageURL(images []image) string { + if len(images) > 0 { + return images[0].URL + } + return "" +} diff --git a/go_backend/tidal.go b/go_backend/tidal.go new file mode 100644 index 00000000..7eb390f8 --- /dev/null +++ b/go_backend/tidal.go @@ -0,0 +1,925 @@ +package gobackend + +import ( + "encoding/base64" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// TidalDownloader handles Tidal downloads +type TidalDownloader struct { + client *http.Client + clientID string + clientSecret string + apiURL string +} + +// TidalTrack represents a Tidal track +type TidalTrack struct { + ID int64 `json:"id"` + Title string `json:"title"` + ISRC string `json:"isrc"` + AudioQuality string `json:"audioQuality"` + TrackNumber int `json:"trackNumber"` + VolumeNumber int `json:"volumeNumber"` + Duration int `json:"duration"` + Album struct { + Title string `json:"title"` + Cover string `json:"cover"` + ReleaseDate string `json:"releaseDate"` + } `json:"album"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + Artist struct { + Name string `json:"name"` + } `json:"artist"` + MediaMetadata struct { + Tags []string `json:"tags"` + } `json:"mediaMetadata"` +} + +// TidalAPIResponseV2 is the new API response format (version 2.0) +type TidalAPIResponseV2 struct { + Version string `json:"version"` + Data struct { + TrackID int64 `json:"trackId"` + AssetPresentation string `json:"assetPresentation"` + AudioMode string `json:"audioMode"` + AudioQuality string `json:"audioQuality"` + ManifestMimeType string `json:"manifestMimeType"` + ManifestHash string `json:"manifestHash"` + Manifest string `json:"manifest"` + BitDepth int `json:"bitDepth"` + SampleRate int `json:"sampleRate"` + } `json:"data"` +} + +// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format +type TidalBTSManifest struct { + MimeType string `json:"mimeType"` + Codecs string `json:"codecs"` + EncryptionType string `json:"encryptionType"` + URLs []string `json:"urls"` +} + +// MPD represents DASH manifest structure +type MPD struct { + XMLName xml.Name `xml:"MPD"` + Period struct { + AdaptationSet struct { + Representation struct { + SegmentTemplate struct { + Initialization string `xml:"initialization,attr"` + Media string `xml:"media,attr"` + Timeline struct { + Segments []struct { + Duration int `xml:"d,attr"` + Repeat int `xml:"r,attr"` + } `xml:"S"` + } `xml:"SegmentTimeline"` + } `xml:"SegmentTemplate"` + } `xml:"Representation"` + } `xml:"AdaptationSet"` + } `xml:"Period"` +} + +// NewTidalDownloader creates a new Tidal downloader +func NewTidalDownloader() *TidalDownloader { + clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") + clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") + + downloader := &TidalDownloader{ + client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout + clientID: string(clientID), + clientSecret: string(clientSecret), + } + + // Get first available API + apis := downloader.GetAvailableAPIs() + if len(apis) > 0 { + downloader.apiURL = apis[0] + } + + return downloader +} + +// GetAvailableAPIs returns list of available Tidal APIs +func (t *TidalDownloader) GetAvailableAPIs() []string { + encodedAPIs := []string{ + "dm9nZWwucXFkbC5zaXRl", // API 1 - vogel.qqdl.site + "bWF1cy5xcWRsLnNpdGU=", // API 2 - maus.qqdl.site + "aHVuZC5xcWRsLnNpdGU=", // API 3 - hund.qqdl.site + "a2F0emUucXFkbC5zaXRl", // API 4 - katze.qqdl.site + "d29sZi5xcWRsLnNpdGU=", // API 5 - wolf.qqdl.site + "dGlkYWwua2lub3BsdXMub25saW5l", // API 6 - tidal.kinoplus.online + "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // API 7 - tidal-api.binimum.org + "dHJpdG9uLnNxdWlkLnd0Zg==", // API 8 - triton.squid.wtf + } + + var apis []string + for _, encoded := range encodedAPIs { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + continue + } + apis = append(apis, "https://"+string(decoded)) + } + + return apis +} + +// GetAccessToken gets Tidal access token +func (t *TidalDownloader) GetAccessToken() (string, error) { + data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID) + + authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=") + req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data)) + if err != nil { + return "", err + } + + req.SetBasicAuth(t.clientID, t.clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := DoRequestWithUserAgent(t.client, req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode) + } + + var result struct { + AccessToken string `json:"access_token"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + return result.AccessToken, nil +} + +// GetTidalURLFromSpotify gets Tidal URL from Spotify track ID using SongLink +func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { + spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") + spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) + + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") + apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + resp, err := DoRequestWithUserAgent(t.client, req) + if err != nil { + return "", fmt.Errorf("failed to get Tidal URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("SongLink API returned status %d", resp.StatusCode) + } + + var songLinkResp struct { + LinksByPlatform map[string]struct { + URL string `json:"url"` + } `json:"linksByPlatform"` + } + if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + tidalLink, ok := songLinkResp.LinksByPlatform["tidal"] + if !ok || tidalLink.URL == "" { + return "", fmt.Errorf("tidal link not found in SongLink") + } + + return tidalLink.URL, nil +} + +// GetTrackIDFromURL extracts track ID from Tidal URL +func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { + parts := strings.Split(tidalURL, "/track/") + if len(parts) < 2 { + return 0, fmt.Errorf("invalid tidal URL format") + } + + trackIDStr := strings.Split(parts[1], "?")[0] + trackIDStr = strings.TrimSpace(trackIDStr) + + var trackID int64 + _, err := fmt.Sscanf(trackIDStr, "%d", &trackID) + if err != nil { + return 0, fmt.Errorf("failed to parse track ID: %w", err) + } + + return trackID, nil +} + +// GetTrackInfoByID gets track info by Tidal track ID +func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { + token, err := t.GetAccessToken() + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + + trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=") + trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID) + + req, err := http.NewRequest("GET", trackURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := DoRequestWithUserAgent(t.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to get track info: HTTP %d", resp.StatusCode) + } + + var trackInfo TidalTrack + if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil { + return nil, err + } + + return &trackInfo, nil +} + + +// SearchTrackByISRC searches for a track by ISRC +func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { + token, err := t.GetAccessToken() + if err != nil { + return nil, err + } + + searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") + searchURL := fmt.Sprintf("%s%s&limit=50&countryCode=US", string(searchBase), url.QueryEscape(isrc)) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := DoRequestWithUserAgent(t.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) + } + + var result struct { + Items []TidalTrack `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + // Find exact ISRC match + for i := range result.Items { + if result.Items[i].ISRC == isrc { + return &result.Items[i], nil + } + } + + if len(result.Items) == 0 { + return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) + } + + return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) +} + +// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority +func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { + token, err := t.GetAccessToken() + if err != nil { + return nil, err + } + + // Build search queries - multiple strategies + queries := []string{} + + // Strategy 1: Artist + Track name (original) + if artistName != "" && trackName != "" { + queries = append(queries, artistName+" "+trackName) + } + + // Strategy 2: Track name only + if trackName != "" { + queries = append(queries, trackName) + } + + // Strategy 3: Romaji versions if Japanese detected + if ContainsJapanese(trackName) || ContainsJapanese(artistName) { + // Try romaji version of track name + if ContainsKana(trackName) { + romajiTrack := ToRomaji(trackName) + if romajiTrack != trackName { + if artistName != "" { + queries = append(queries, artistName+" "+romajiTrack) + } + queries = append(queries, romajiTrack) + } + } + // Try romaji version of artist name + if ContainsKana(artistName) { + romajiArtist := ToRomaji(artistName) + if romajiArtist != artistName { + queries = append(queries, romajiArtist+" "+trackName) + // Try both romaji + if ContainsKana(trackName) { + romajiTrack := ToRomaji(trackName) + queries = append(queries, romajiArtist+" "+romajiTrack) + } + } + } + } + + // Strategy 4: Artist only as last resort + if artistName != "" { + queries = append(queries, artistName) + } + + searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") + + // Collect all search results from all queries + var allTracks []TidalTrack + searchedQueries := make(map[string]bool) + + for _, query := range queries { + cleanQuery := strings.TrimSpace(query) + if cleanQuery == "" || searchedQueries[cleanQuery] { + continue + } + searchedQueries[cleanQuery] = true + + searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery)) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + continue + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := DoRequestWithUserAgent(t.client, req) + if err != nil { + continue + } + + if resp.StatusCode != 200 { + resp.Body.Close() + continue + } + + var result struct { + Items []TidalTrack `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + resp.Body.Close() + continue + } + resp.Body.Close() + + if len(result.Items) > 0 { + allTracks = append(allTracks, result.Items...) + } + } + + if len(allTracks) == 0 { + return nil, fmt.Errorf("no tracks found for any search query") + } + + // Priority 1: Match by ISRC (exact match) + if spotifyISRC != "" { + for i := range allTracks { + track := &allTracks[i] + if track.ISRC == spotifyISRC { + return track, nil + } + } + // If ISRC was provided but no match found, return error + return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) + } + + // Priority 2: Match by duration (within tolerance) + prefer best quality + if expectedDuration > 0 { + tolerance := 3 // 3 seconds tolerance + var durationMatches []*TidalTrack + + for i := range allTracks { + track := &allTracks[i] + durationDiff := track.Duration - expectedDuration + if durationDiff < 0 { + durationDiff = -durationDiff + } + if durationDiff <= tolerance { + durationMatches = append(durationMatches, track) + } + } + + if len(durationMatches) > 0 { + // Find best quality among duration matches + bestMatch := durationMatches[0] + for _, track := range durationMatches { + for _, tag := range track.MediaMetadata.Tags { + if tag == "HIRES_LOSSLESS" { + bestMatch = track + break + } + } + } + return bestMatch, nil + } + } + + // Priority 3: Just take the best quality from first results + bestMatch := &allTracks[0] + for i := range allTracks { + track := &allTracks[i] + for _, tag := range track.MediaMetadata.Tags { + if tag == "HIRES_LOSSLESS" { + bestMatch = track + break + } + } + if bestMatch != &allTracks[0] { + break + } + } + + return bestMatch, nil +} + +// SearchTrackByMetadata searches for a track using artist name and track name +func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { + return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0) +} + + +// getDownloadURLSequential requests download URL from APIs sequentially +// Returns the first successful result (supports both v1 and v2 API formats) +func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) { + if len(apis) == 0 { + return "", "", fmt.Errorf("no APIs available") + } + + client := NewHTTPClientWithTimeout(DefaultTimeout) + retryConfig := DefaultRetryConfig() + var errors []string + + for _, apiURL := range apis { + reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) + continue + } + + resp, err := DoRequestWithRetry(client, req, retryConfig) + if err != nil { + errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) + continue + } + + body, err := ReadResponseBody(resp) + resp.Body.Close() + if err != nil { + errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error())) + continue + } + + // Try v2 format first (object with manifest) + var v2Response TidalAPIResponseV2 + if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { + return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil + } + + // Fallback to v1 format (array with OriginalTrackUrl) + var v1Responses []struct { + OriginalTrackURL string `json:"OriginalTrackUrl"` + } + if err := json.Unmarshal(body, &v1Responses); err == nil { + for _, item := range v1Responses { + if item.OriginalTrackURL != "" { + return apiURL, item.OriginalTrackURL, nil + } + } + } + + errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response")) + } + + return "", "", fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) +} + +// GetDownloadURL gets download URL for a track - tries APIs sequentially +func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { + apis := t.GetAvailableAPIs() + if len(apis) == 0 { + return "", fmt.Errorf("no API URL configured") + } + + _, downloadURL, err := getDownloadURLSequential(apis, trackID, quality) + if err != nil { + return "", fmt.Errorf("failed to get download URL: %w", err) + } + + return downloadURL, nil +} + +// parseManifest parses Tidal manifest (supports both BTS and DASH formats) +func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { + manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) + if err != nil { + return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err) + } + + manifestStr := string(manifestBytes) + + // Check if it's BTS format (JSON) or DASH format (XML) + if strings.HasPrefix(manifestStr, "{") { + // BTS format - JSON with direct URLs + var btsManifest TidalBTSManifest + if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil { + return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err) + } + + if len(btsManifest.URLs) == 0 { + return "", "", nil, fmt.Errorf("no URLs in BTS manifest") + } + + return btsManifest.URLs[0], "", nil, nil + } + + // DASH format - XML with segments + var mpd MPD + if err := xml.Unmarshal(manifestBytes, &mpd); err != nil { + return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err) + } + + segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate + initURL = segTemplate.Initialization + mediaTemplate := segTemplate.Media + + if initURL == "" || mediaTemplate == "" { + // Fallback: try regex extraction + initRe := regexp.MustCompile(`initialization="([^"]+)"`) + mediaRe := regexp.MustCompile(`media="([^"]+)"`) + + if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 { + initURL = match[1] + } + if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 { + mediaTemplate = match[1] + } + } + + if initURL == "" { + return "", "", nil, fmt.Errorf("no initialization URL found in manifest") + } + + // Unescape HTML entities in URLs + initURL = strings.ReplaceAll(initURL, "&", "&") + mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&") + + // Calculate segment count from timeline + segmentCount := 0 + for _, seg := range segTemplate.Timeline.Segments { + segmentCount += seg.Repeat + 1 + } + + // If no segments found via XML, try regex + if segmentCount == 0 { + segRe := regexp.MustCompile(` 1 && match[1] != "" { + fmt.Sscanf(match[1], "%d", &repeat) + } + segmentCount += repeat + 1 + } + } + + // Generate media URLs for each segment + for i := 1; i <= segmentCount; i++ { + mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) + mediaURLs = append(mediaURLs, mediaURL) + } + + return "", initURL, mediaURLs, nil +} + + +// DownloadFile downloads a file from URL with progress tracking +func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error { + // Handle manifest-based download + if strings.HasPrefix(downloadURL, "MANIFEST:") { + return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath) + } + + // Set current file being downloaded + SetCurrentFile(filepath.Base(outputPath)) + SetDownloading(true) + defer SetDownloading(false) + + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := DoRequestWithUserAgent(t.client, req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + } + + // Set total bytes if available + if resp.ContentLength > 0 { + SetBytesTotal(resp.ContentLength) + } + + out, err := os.Create(outputPath) + if err != nil { + return err + } + defer out.Close() + + // Use ProgressWriter for tracking + progressWriter := NewProgressWriter(out) + _, err = io.Copy(progressWriter, resp.Body) + return err +} + +func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) error { + directURL, initURL, mediaURLs, err := parseManifest(manifestB64) + if err != nil { + return fmt.Errorf("failed to parse manifest: %w", err) + } + + client := &http.Client{ + Timeout: 120 * time.Second, + } + + // If we have a direct URL (BTS format), download directly + if directURL != "" { + resp, err := client.Get(directURL) + if err != nil { + return fmt.Errorf("failed to download file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + out, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + return err + } + + // DASH format - download segments to temporary file + // Note: On Android, we can't use ffmpeg, so we'll try to download as M4A + // and hope the player can handle it, or we save as .m4a instead of .flac + tempPath := outputPath + ".m4a.tmp" + out, err := os.Create(tempPath) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + + // Download initialization segment + resp, err := client.Get(initURL) + if err != nil { + out.Close() + os.Remove(tempPath) + return fmt.Errorf("failed to download init segment: %w", err) + } + if resp.StatusCode != 200 { + resp.Body.Close() + out.Close() + os.Remove(tempPath) + return fmt.Errorf("init segment download failed with status %d", resp.StatusCode) + } + _, err = io.Copy(out, resp.Body) + resp.Body.Close() + if err != nil { + out.Close() + os.Remove(tempPath) + return fmt.Errorf("failed to write init segment: %w", err) + } + + // Download media segments + for i, mediaURL := range mediaURLs { + resp, err := client.Get(mediaURL) + if err != nil { + out.Close() + os.Remove(tempPath) + return fmt.Errorf("failed to download segment %d: %w", i+1, err) + } + if resp.StatusCode != 200 { + resp.Body.Close() + out.Close() + os.Remove(tempPath) + return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode) + } + _, err = io.Copy(out, resp.Body) + resp.Body.Close() + if err != nil { + out.Close() + os.Remove(tempPath) + return fmt.Errorf("failed to write segment %d: %w", i+1, err) + } + } + + out.Close() + + // For Android, we'll save as M4A since we can't use ffmpeg + // Rename temp file to final output (change extension to .m4a if needed) + m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" + if err := os.Rename(tempPath, m4aPath); err != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to rename temp file: %w", err) + } + + // If the original output was .flac, we need to indicate this is actually m4a + // For now, we'll just keep it as m4a + return nil +} + +// downloadFromTidal downloads a track using the request parameters +func downloadFromTidal(req DownloadRequest) (string, error) { + downloader := NewTidalDownloader() + + // Check for existing file first + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return "EXISTS:" + existingFile, nil + } + + var track *TidalTrack + var err error + + // Strategy 1: Try to get Tidal URL from SongLink (using Spotify ID) + if req.SpotifyID != "" { + tidalURL, slErr := downloader.GetTidalURLFromSpotify(req.SpotifyID) + if slErr == nil && tidalURL != "" { + // Extract track ID and get track info + trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) + if idErr == nil { + track, err = downloader.GetTrackInfoByID(trackID) + } + } + } + + // Strategy 2: Search by ISRC with multi-strategy fallback + if track == nil && req.ISRC != "" { + track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, 0) + } + + // Strategy 3: Search by metadata only (no ISRC requirement) + if track == nil { + track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName) + } + + if track == nil { + errMsg := "could not find track on Tidal" + if err != nil { + errMsg = err.Error() + } + return "", fmt.Errorf("tidal search failed: %s", errMsg) + } + + // Build filename + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "track": req.TrackNumber, + "year": extractYear(req.ReleaseDate), + "disc": req.DiscNumber, + }) + filename = sanitizeFilename(filename) + ".flac" + outputPath := filepath.Join(req.OutputDir, filename) + + // Check if file already exists + if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { + return "EXISTS:" + outputPath, nil + } + + // Get download URL using parallel API requests + downloadURL, err := downloader.GetDownloadURL(track.ID, "LOSSLESS") + if err != nil { + return "", fmt.Errorf("failed to get download URL: %w", err) + } + + // Download file + if err := downloader.DownloadFile(downloadURL, outputPath); err != nil { + return "", fmt.Errorf("download failed: %w", err) + } + + // Check if file was saved as M4A (DASH stream) instead of FLAC + // downloadFromManifest saves DASH streams as .m4a + actualOutputPath := outputPath + m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" + if _, err := os.Stat(m4aPath); err == nil { + // File was saved as M4A, use that path + actualOutputPath = m4aPath + fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) + } else if _, err := os.Stat(outputPath); err != nil { + // Neither FLAC nor M4A exists + return "", fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) + } + + // Embed metadata + metadata := Metadata{ + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + AlbumArtist: req.AlbumArtist, + Date: req.ReleaseDate, + TrackNumber: req.TrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + } + + // Download cover to memory (avoids file permission issues on Android) + var coverData []byte + if req.CoverURL != "" { + fmt.Println("[Tidal] Downloading cover to memory...") + data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover) + if err == nil { + coverData = data + fmt.Printf("[Tidal] Cover downloaded successfully (%d bytes)\n", len(coverData)) + } else { + fmt.Printf("[Tidal] Warning: failed to download cover: %v\n", err) + } + } + + // Only embed metadata to FLAC files (M4A will be converted by Flutter) + if strings.HasSuffix(actualOutputPath, ".flac") { + if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { + fmt.Printf("Warning: failed to embed metadata: %v\n", err) + } + + // Embed lyrics if enabled + if req.EmbedLyrics { + fmt.Println("[Tidal] Fetching lyrics...") + lyricsClient := NewLyricsClient() + lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName) + if lyricsErr != nil { + fmt.Printf("[Tidal] Warning: lyrics fetch error: %v\n", lyricsErr) + } else if lyrics == nil || len(lyrics.Lines) == 0 { + fmt.Println("[Tidal] No lyrics found for this track") + } else { + fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) + lrcContent := convertToLRC(lyrics) + if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil { + fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) + } else { + fmt.Println("[Tidal] Lyrics embedded successfully") + } + } + } + } else { + fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath) + } + + return actualOutputPath, nil +} diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ee37f15091ce76f2d23c9eb428ca3550bec8faa1 GIT binary patch literal 19642 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 + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 00000000..de176d9f --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,51 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '14.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + # FFmpeg for audio conversion (full version with all codecs) + # Note: ffmpeg-kit-flutter-new already includes this, but we specify for clarity +end + +target 'RunnerTests' do + inherit! :search_paths +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + end + end +end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..6e7f25aa --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.zarz.spotiflacAndroid; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.zarz.spotiflacAndroid.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.zarz.spotiflacAndroid.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.zarz.spotiflacAndroid.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.zarz.spotiflacAndroid; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.zarz.spotiflacAndroid; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e3773d42 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..f902994a --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,155 @@ +import Flutter +import UIKit +import Gobackend // Import Go framework + +@main +@objc class AppDelegate: FlutterAppDelegate { + private let CHANNEL = "com.zarz.spotiflac/backend" + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + + let controller = window?.rootViewController as! FlutterViewController + let channel = FlutterMethodChannel( + name: CHANNEL, + binaryMessenger: controller.binaryMessenger + ) + + channel.setMethodCallHandler { [weak self] call, result in + self?.handleMethodCall(call: call, result: result) + } + + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let response = try self.invokeGoMethod(call: call) + DispatchQueue.main.async { + result(response) + } + } catch { + DispatchQueue.main.async { + result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil)) + } + } + } + } + + private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? { + var error: NSError? + + switch call.method { + case "parseSpotifyUrl": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendParseSpotifyURL(url, &error) + if let error = error { throw error } + return response + + case "getSpotifyMetadata": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendGetSpotifyMetadata(url, &error) + if let error = error { throw error } + return response + + case "searchSpotify": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let limit = args["limit"] as? Int ?? 10 + let response = GobackendSearchSpotify(query, Int(limit), &error) + if let error = error { throw error } + return response + + case "checkAvailability": + let args = call.arguments as! [String: Any] + let spotifyId = args["spotify_id"] as! String + let isrc = args["isrc"] as! String + let response = GobackendCheckAvailability(spotifyId, isrc, &error) + if let error = error { throw error } + return response + + case "downloadTrack": + let requestJson = call.arguments as! String + let response = GobackendDownloadTrack(requestJson, &error) + if let error = error { throw error } + return response + + case "downloadWithFallback": + let requestJson = call.arguments as! String + let response = GobackendDownloadWithFallback(requestJson, &error) + if let error = error { throw error } + return response + + case "getDownloadProgress": + let response = GobackendGetDownloadProgress() + return response + + case "setDownloadDirectory": + let args = call.arguments as! [String: Any] + let path = args["path"] as! String + try GobackendSetDownloadDirectory(path) + return nil + + case "checkDuplicate": + let args = call.arguments as! [String: Any] + let outputDir = args["output_dir"] as! String + let isrc = args["isrc"] as! String + let response = GobackendCheckDuplicate(outputDir, isrc, &error) + if let error = error { throw error } + return response + + case "buildFilename": + let args = call.arguments as! [String: Any] + let template = args["template"] as! String + let metadata = args["metadata"] as! String + let response = GobackendBuildFilename(template, metadata, &error) + if let error = error { throw error } + return response + + case "sanitizeFilename": + let args = call.arguments as! [String: Any] + let filename = args["filename"] as! String + let response = GobackendSanitizeFilename(filename) + return response + + case "fetchLyrics": + let args = call.arguments as! [String: Any] + let spotifyId = args["spotify_id"] as! String + let trackName = args["track_name"] as! String + let artistName = args["artist_name"] as! String + let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error) + if let error = error { throw error } + return response + + case "getLyricsLRC": + let args = call.arguments as! [String: Any] + let spotifyId = args["spotify_id"] as! String + let trackName = args["track_name"] as! String + let artistName = args["artist_name"] as! String + let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, &error) + if let error = error { throw error } + return response + + case "embedLyricsToFile": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let lyrics = args["lyrics"] as! String + let response = GobackendEmbedLyricsToFile(filePath, lyrics, &error) + if let error = error { throw error } + return response + + default: + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Method not implemented: \(call.method)"] + ) + } + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..1779d1aa --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 00000000..9cbf2319 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,68 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + SpotiFLAC + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + SpotiFLAC + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + UISupportsDocumentBrowser + + + + NSPhotoLibraryUsageDescription + SpotiFLAC needs access to save album artwork + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 00000000..fc8638ee --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:spotiflac_android/screens/main_shell.dart'; +import 'package:spotiflac_android/screens/setup_screen.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; + +final _routerProvider = Provider((ref) { + final settings = ref.watch(settingsProvider); + + return GoRouter( + initialLocation: settings.isFirstLaunch ? '/setup' : '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const MainShell(), + ), + GoRoute( + path: '/setup', + builder: (context, state) => const SetupScreen(), + ), + ], + ); +}); + +class SpotiFLACApp extends ConsumerWidget { + const SpotiFLACApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(_routerProvider); + + return DynamicColorWrapper( + builder: (lightTheme, darkTheme, themeMode) { + return MaterialApp.router( + title: 'SpotiFLAC', + debugShowCheckedModeBanner: false, + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeMode, + themeAnimationDuration: const Duration(milliseconds: 300), + themeAnimationCurve: Curves.easeInOut, + routerConfig: router, + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..98c3348b --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp( + const ProviderScope( + child: SpotiFLACApp(), + ), + ); +} diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart new file mode 100644 index 00000000..6ccf2e49 --- /dev/null +++ b/lib/models/download_item.dart @@ -0,0 +1,62 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:spotiflac_android/models/track.dart'; + +part 'download_item.g.dart'; + +/// Download status enum +enum DownloadStatus { + queued, + downloading, + completed, + failed, + skipped, +} + +@JsonSerializable() +class DownloadItem { + final String id; + final Track track; + final String service; + final DownloadStatus status; + final double progress; + final String? filePath; + final String? error; + final DateTime createdAt; + + const DownloadItem({ + required this.id, + required this.track, + required this.service, + this.status = DownloadStatus.queued, + this.progress = 0.0, + this.filePath, + this.error, + required this.createdAt, + }); + + DownloadItem copyWith({ + String? id, + Track? track, + String? service, + DownloadStatus? status, + double? progress, + String? filePath, + String? error, + DateTime? createdAt, + }) { + return DownloadItem( + id: id ?? this.id, + track: track ?? this.track, + service: service ?? this.service, + status: status ?? this.status, + progress: progress ?? this.progress, + filePath: filePath ?? this.filePath, + error: error ?? this.error, + createdAt: createdAt ?? this.createdAt, + ); + } + + factory DownloadItem.fromJson(Map json) => + _$DownloadItemFromJson(json); + Map toJson() => _$DownloadItemToJson(this); +} diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart new file mode 100644 index 00000000..53736f6c --- /dev/null +++ b/lib/models/download_item.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'download_item.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( + id: json['id'] as String, + track: Track.fromJson(json['track'] as Map), + service: json['service'] as String, + status: $enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ?? + DownloadStatus.queued, + progress: (json['progress'] as num?)?.toDouble() ?? 0.0, + filePath: json['filePath'] as String?, + error: json['error'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + +Map _$DownloadItemToJson(DownloadItem instance) => + { + 'id': instance.id, + 'track': instance.track.toJson(), + 'service': instance.service, + 'status': _$DownloadStatusEnumMap[instance.status]!, + 'progress': instance.progress, + 'filePath': instance.filePath, + 'error': instance.error, + 'createdAt': instance.createdAt.toIso8601String(), + }; + +const _$DownloadStatusEnumMap = { + DownloadStatus.queued: 'queued', + DownloadStatus.downloading: 'downloading', + DownloadStatus.completed: 'completed', + DownloadStatus.failed: 'failed', + DownloadStatus.skipped: 'skipped', +}; + +K? $enumDecodeNullable( + Map enumValues, + Object? source, { + K? unknownValue, +}) { + if (source == null) { + return null; + } + return enumValues.entries + .singleWhere( + (e) => e.value == source, + orElse: () => throw ArgumentError( + '`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}', + ), + ) + .key; +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart new file mode 100644 index 00000000..1c77dda4 --- /dev/null +++ b/lib/models/settings.dart @@ -0,0 +1,52 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'settings.g.dart'; + +@JsonSerializable() +class AppSettings { + final String defaultService; + final String audioQuality; + final String filenameFormat; + final String downloadDirectory; + final bool autoFallback; + final bool embedLyrics; + final bool maxQualityCover; + final bool isFirstLaunch; + + const AppSettings({ + this.defaultService = 'tidal', + this.audioQuality = 'LOSSLESS', + this.filenameFormat = '{title} - {artist}', + this.downloadDirectory = '', + this.autoFallback = true, + this.embedLyrics = true, + this.maxQualityCover = true, + this.isFirstLaunch = true, + }); + + AppSettings copyWith({ + String? defaultService, + String? audioQuality, + String? filenameFormat, + String? downloadDirectory, + bool? autoFallback, + bool? embedLyrics, + bool? maxQualityCover, + bool? isFirstLaunch, + }) { + return AppSettings( + defaultService: defaultService ?? this.defaultService, + audioQuality: audioQuality ?? this.audioQuality, + filenameFormat: filenameFormat ?? this.filenameFormat, + downloadDirectory: downloadDirectory ?? this.downloadDirectory, + autoFallback: autoFallback ?? this.autoFallback, + embedLyrics: embedLyrics ?? this.embedLyrics, + maxQualityCover: maxQualityCover ?? this.maxQualityCover, + isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, + ); + } + + factory AppSettings.fromJson(Map json) => + _$AppSettingsFromJson(json); + Map toJson() => _$AppSettingsToJson(this); +} diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart new file mode 100644 index 00000000..692bbb54 --- /dev/null +++ b/lib/models/settings.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'settings.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AppSettings _$AppSettingsFromJson(Map json) => AppSettings( + defaultService: json['defaultService'] as String? ?? 'tidal', + audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS', + filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}', + downloadDirectory: json['downloadDirectory'] as String? ?? '', + autoFallback: json['autoFallback'] as bool? ?? true, + embedLyrics: json['embedLyrics'] as bool? ?? true, + maxQualityCover: json['maxQualityCover'] as bool? ?? true, + isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, + ); + +Map _$AppSettingsToJson(AppSettings instance) => + { + 'defaultService': instance.defaultService, + 'audioQuality': instance.audioQuality, + 'filenameFormat': instance.filenameFormat, + 'downloadDirectory': instance.downloadDirectory, + 'autoFallback': instance.autoFallback, + 'embedLyrics': instance.embedLyrics, + 'maxQualityCover': instance.maxQualityCover, + 'isFirstLaunch': instance.isFirstLaunch, + }; diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart new file mode 100644 index 00000000..6b6aaa15 --- /dev/null +++ b/lib/models/theme_settings.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +/// Storage keys for theme settings persistence +const String kThemeModeKey = 'theme_mode'; +const String kUseDynamicColorKey = 'use_dynamic_color'; +const String kSeedColorKey = 'seed_color'; + +/// Default Spotify green color for fallback +const int kDefaultSeedColor = 0xFF1DB954; + +/// Theme settings model for Material Expressive 3 +class ThemeSettings { + final ThemeMode themeMode; + final bool useDynamicColor; + final int seedColorValue; + + const ThemeSettings({ + this.themeMode = ThemeMode.system, + this.useDynamicColor = true, + this.seedColorValue = kDefaultSeedColor, + }); + + /// Get seed color as Color object + Color get seedColor => Color(seedColorValue); + + /// Create a copy with updated values + ThemeSettings copyWith({ + ThemeMode? themeMode, + bool? useDynamicColor, + int? seedColorValue, + }) { + return ThemeSettings( + themeMode: themeMode ?? this.themeMode, + useDynamicColor: useDynamicColor ?? this.useDynamicColor, + seedColorValue: seedColorValue ?? this.seedColorValue, + ); + } + + /// Convert to JSON map for persistence + Map toJson() => { + kThemeModeKey: themeMode.name, + kUseDynamicColorKey: useDynamicColor, + kSeedColorKey: seedColorValue, + }; + + /// Create from JSON map + factory ThemeSettings.fromJson(Map json) { + return ThemeSettings( + themeMode: _themeModeFromString(json[kThemeModeKey] as String?), + useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true, + seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ThemeSettings && + other.themeMode == themeMode && + other.useDynamicColor == useDynamicColor && + other.seedColorValue == seedColorValue; + } + + @override + int get hashCode => + themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode; +} + +/// Helper to convert string to ThemeMode +ThemeMode _themeModeFromString(String? value) { + if (value == null) return ThemeMode.system; + return ThemeMode.values.firstWhere( + (e) => e.name == value, + orElse: () => ThemeMode.system, + ); +} diff --git a/lib/models/track.dart b/lib/models/track.dart new file mode 100644 index 00000000..68647758 --- /dev/null +++ b/lib/models/track.dart @@ -0,0 +1,61 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'track.g.dart'; + +/// Track model representing a music track +@JsonSerializable() +class Track { + final String id; + final String name; + final String artistName; + final String albumName; + final String? albumArtist; + final String? coverUrl; + final String? isrc; + final int duration; + final int? trackNumber; + final int? discNumber; + final String? releaseDate; + final ServiceAvailability? availability; + + const Track({ + required this.id, + required this.name, + required this.artistName, + required this.albumName, + this.albumArtist, + this.coverUrl, + this.isrc, + required this.duration, + this.trackNumber, + this.discNumber, + this.releaseDate, + this.availability, + }); + + factory Track.fromJson(Map json) => _$TrackFromJson(json); + Map toJson() => _$TrackToJson(this); +} + +@JsonSerializable() +class ServiceAvailability { + final bool tidal; + final bool qobuz; + final bool amazon; + final String? tidalUrl; + final String? qobuzUrl; + final String? amazonUrl; + + const ServiceAvailability({ + this.tidal = false, + this.qobuz = false, + this.amazon = false, + this.tidalUrl, + this.qobuzUrl, + this.amazonUrl, + }); + + factory ServiceAvailability.fromJson(Map json) => + _$ServiceAvailabilityFromJson(json); + Map toJson() => _$ServiceAvailabilityToJson(this); +} diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart new file mode 100644 index 00000000..b0778bea --- /dev/null +++ b/lib/models/track.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'track.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Track _$TrackFromJson(Map json) => Track( + id: json['id'] as String, + name: json['name'] as String, + artistName: json['artistName'] as String, + albumName: json['albumName'] as String, + albumArtist: json['albumArtist'] as String?, + coverUrl: json['coverUrl'] as String?, + isrc: json['isrc'] as String?, + duration: (json['duration'] as num).toInt(), + trackNumber: (json['trackNumber'] as num?)?.toInt(), + discNumber: (json['discNumber'] as num?)?.toInt(), + releaseDate: json['releaseDate'] as String?, + availability: json['availability'] == null + ? null + : ServiceAvailability.fromJson( + json['availability'] as Map), + ); + +Map _$TrackToJson(Track instance) => { + 'id': instance.id, + 'name': instance.name, + 'artistName': instance.artistName, + 'albumName': instance.albumName, + 'albumArtist': instance.albumArtist, + 'coverUrl': instance.coverUrl, + 'isrc': instance.isrc, + 'duration': instance.duration, + 'trackNumber': instance.trackNumber, + 'discNumber': instance.discNumber, + 'releaseDate': instance.releaseDate, + 'availability': instance.availability?.toJson(), + }; + +ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => + ServiceAvailability( + tidal: json['tidal'] as bool? ?? false, + qobuz: json['qobuz'] as bool? ?? false, + amazon: json['amazon'] as bool? ?? false, + tidalUrl: json['tidalUrl'] as String?, + qobuzUrl: json['qobuzUrl'] as String?, + amazonUrl: json['amazonUrl'] as String?, + ); + +Map _$ServiceAvailabilityToJson( + ServiceAvailability instance) => + { + 'tidal': instance.tidal, + 'qobuz': instance.qobuz, + 'amazon': instance.amazon, + 'tidalUrl': instance.tidalUrl, + 'qobuzUrl': instance.qobuzUrl, + 'amazonUrl': instance.amazonUrl, + }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart new file mode 100644 index 00000000..bc91ab6b --- /dev/null +++ b/lib/providers/download_queue_provider.dart @@ -0,0 +1,520 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new/return_code.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; + +// Download History Item model +class DownloadHistoryItem { + final String id; + final String trackName; + final String artistName; + final String albumName; + final String? coverUrl; + final String filePath; + final String service; + final DateTime downloadedAt; + + const DownloadHistoryItem({ + required this.id, + required this.trackName, + required this.artistName, + required this.albumName, + this.coverUrl, + required this.filePath, + required this.service, + required this.downloadedAt, + }); +} + +// Download History State +class DownloadHistoryState { + final List items; + + const DownloadHistoryState({this.items = const []}); + + DownloadHistoryState copyWith({List? items}) { + return DownloadHistoryState(items: items ?? this.items); + } +} + +// Download History Notifier (Riverpod 3.x) +class DownloadHistoryNotifier extends Notifier { + @override + DownloadHistoryState build() { + return const DownloadHistoryState(); + } + + void addToHistory(DownloadHistoryItem item) { + state = state.copyWith(items: [item, ...state.items]); + } + + void removeFromHistory(String id) { + state = state.copyWith( + items: state.items.where((item) => item.id != id).toList(), + ); + } + + void clearHistory() { + state = const DownloadHistoryState(); + } +} + +// Download History Provider +final downloadHistoryProvider = NotifierProvider( + DownloadHistoryNotifier.new, +); + +class DownloadQueueState { + final List items; + final DownloadItem? currentDownload; + final bool isProcessing; + final String outputDir; + final String filenameFormat; + final bool autoFallback; + + const DownloadQueueState({ + this.items = const [], + this.currentDownload, + this.isProcessing = false, + this.outputDir = '', + this.filenameFormat = '{artist} - {title}', + this.autoFallback = true, + }); + + DownloadQueueState copyWith({ + List? items, + DownloadItem? currentDownload, + bool? isProcessing, + String? outputDir, + String? filenameFormat, + bool? autoFallback, + }) { + return DownloadQueueState( + items: items ?? this.items, + currentDownload: currentDownload ?? this.currentDownload, + isProcessing: isProcessing ?? this.isProcessing, + outputDir: outputDir ?? this.outputDir, + filenameFormat: filenameFormat ?? this.filenameFormat, + autoFallback: autoFallback ?? this.autoFallback, + ); + } + + int get queuedCount => items.where((i) => i.status == DownloadStatus.queued || i.status == DownloadStatus.downloading).length; + int get completedCount => items.where((i) => i.status == DownloadStatus.completed).length; + int get failedCount => items.where((i) => i.status == DownloadStatus.failed).length; +} + +// Download Queue Notifier (Riverpod 3.x) +class DownloadQueueNotifier extends Notifier { + Timer? _progressTimer; + + @override + DownloadQueueState build() { + // Initialize output directory asynchronously + Future.microtask(() async { + await _initOutputDir(); + }); + return const DownloadQueueState(); + } + + void _startProgressPolling(String itemId) { + _progressTimer?.cancel(); + _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async { + try { + final progress = await PlatformBridge.getDownloadProgress(); + final bytesReceived = progress['bytes_received'] as int? ?? 0; + final bytesTotal = progress['bytes_total'] as int? ?? 0; + final isDownloading = progress['is_downloading'] as bool? ?? false; + + if (isDownloading && bytesTotal > 0) { + final percentage = bytesReceived / bytesTotal; + updateProgress(itemId, percentage); + + // Log progress + final mbReceived = bytesReceived / (1024 * 1024); + final mbTotal = bytesTotal / (1024 * 1024); + print('[DownloadQueue] Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)'); + } + } catch (e) { + // Ignore polling errors + } + }); + } + + void _stopProgressPolling() { + _progressTimer?.cancel(); + _progressTimer = null; + } + + Future _initOutputDir() async { + if (state.outputDir.isEmpty) { + try { + if (Platform.isIOS) { + // iOS: Use Documents directory (accessible via Files app) + final dir = await getApplicationDocumentsDirectory(); + final musicDir = Directory('${dir.path}/SpotiFLAC'); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + state = state.copyWith(outputDir: musicDir.path); + } else { + // Android: Use external storage Music folder + final dir = await getExternalStorageDirectory(); + if (dir != null) { + final musicDir = Directory('${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC'); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + state = state.copyWith(outputDir: musicDir.path); + } else { + // Fallback to documents directory + final docDir = await getApplicationDocumentsDirectory(); + final musicDir = Directory('${docDir.path}/SpotiFLAC'); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + state = state.copyWith(outputDir: musicDir.path); + } + } + } catch (e) { + // Fallback for any platform + final dir = await getApplicationDocumentsDirectory(); + final musicDir = Directory('${dir.path}/SpotiFLAC'); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + state = state.copyWith(outputDir: musicDir.path); + } + } + } + + void setOutputDir(String dir) { + state = state.copyWith(outputDir: dir); + } + + void updateSettings(AppSettings settings) { + state = state.copyWith( + outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir, + filenameFormat: settings.filenameFormat, + autoFallback: settings.autoFallback, + ); + } + + String addToQueue(Track track, String service) { + final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; + final item = DownloadItem( + id: id, + track: track, + service: service, + createdAt: DateTime.now(), + ); + + state = state.copyWith(items: [...state.items, item]); + + if (!state.isProcessing) { + // Run in microtask to not block UI + Future.microtask(() => _processQueue()); + } + + return id; + } + + void addMultipleToQueue(List tracks, String service) { + final newItems = tracks.map((track) { + final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; + return DownloadItem( + id: id, + track: track, + service: service, + createdAt: DateTime.now(), + ); + }).toList(); + + state = state.copyWith(items: [...state.items, ...newItems]); + + if (!state.isProcessing) { + // Run in microtask to not block UI + Future.microtask(() => _processQueue()); + } + } + + void updateItemStatus(String id, DownloadStatus status, {double? progress, String? filePath, String? error}) { + final items = state.items.map((item) { + if (item.id == id) { + return item.copyWith( + status: status, + progress: progress ?? item.progress, + filePath: filePath, + error: error, + ); + } + return item; + }).toList(); + + state = state.copyWith(items: items); + } + + void updateProgress(String id, double progress) { + updateItemStatus(id, DownloadStatus.downloading, progress: progress); + } + + void cancelItem(String id) { + updateItemStatus(id, DownloadStatus.skipped); + } + + void clearCompleted() { + final items = state.items.where((item) => + item.status != DownloadStatus.completed && + item.status != DownloadStatus.failed && + item.status != DownloadStatus.skipped + ).toList(); + + state = state.copyWith(items: items); + } + + void clearAll() { + state = const DownloadQueueState(); + } + + /// Embed metadata and cover to a FLAC file after M4A conversion + Future _embedMetadataAndCover(String flacPath, Track track) async { + // Download cover first + String? coverPath; + if (track.coverUrl != null && track.coverUrl!.isNotEmpty) { + coverPath = '$flacPath.cover.jpg'; + try { + // Download cover using HTTP + final httpClient = HttpClient(); + final request = await httpClient.getUrl(Uri.parse(track.coverUrl!)); + final response = await request.close(); + if (response.statusCode == 200) { + final file = File(coverPath); + final sink = file.openWrite(); + await response.pipe(sink); + await sink.close(); + print('[DownloadQueue] Cover downloaded to: $coverPath'); + } else { + print('[DownloadQueue] Failed to download cover: HTTP ${response.statusCode}'); + coverPath = null; + } + httpClient.close(); + } catch (e) { + print('[DownloadQueue] Failed to download cover: $e'); + coverPath = null; + } + } + + // Use Go backend to embed metadata + try { + // For now, we'll use FFmpeg to embed cover since Go backend expects to download the file + // FFmpeg can embed cover art to FLAC + if (coverPath != null && await File(coverPath).exists()) { + final tempOutput = '$flacPath.tmp'; + final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y'; + + final session = await FFmpegKit.execute(command); + final returnCode = await session.getReturnCode(); + + if (ReturnCode.isSuccess(returnCode)) { + // Replace original with temp + await File(flacPath).delete(); + await File(tempOutput).rename(flacPath); + print('[DownloadQueue] Cover embedded via FFmpeg'); + } else { + // Try alternative method using metaflac-style embedding + print('[DownloadQueue] FFmpeg cover embed failed, trying alternative...'); + // Clean up temp file if exists + final tempFile = File(tempOutput); + if (await tempFile.exists()) { + await tempFile.delete(); + } + } + + // Clean up cover file + try { + await File(coverPath).delete(); + } catch (_) {} + } + } catch (e) { + print('[DownloadQueue] Failed to embed metadata: $e'); + } + } + + Future _processQueue() async { + if (state.isProcessing) return; // Prevent multiple concurrent processing + + state = state.copyWith(isProcessing: true); + print('[DownloadQueue] Starting queue processing...'); + + // Ensure output directory is initialized before processing + if (state.outputDir.isEmpty) { + print('[DownloadQueue] Output dir empty, initializing...'); + await _initOutputDir(); + } + + // If still empty, use fallback + if (state.outputDir.isEmpty) { + print('[DownloadQueue] Using fallback directory...'); + final dir = await getApplicationDocumentsDirectory(); + final musicDir = Directory('${dir.path}/SpotiFLAC'); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + state = state.copyWith(outputDir: musicDir.path); + } + + print('[DownloadQueue] Output directory: ${state.outputDir}'); + + while (true) { + final nextItem = state.items.firstWhere( + (item) => item.status == DownloadStatus.queued, + orElse: () => DownloadItem( + id: '', + track: const Track(id: '', name: '', artistName: '', albumName: '', duration: 0), + service: '', + createdAt: DateTime.now(), + ), + ); + + if (nextItem.id.isEmpty) { + print('[DownloadQueue] No more items to process'); + break; + } + + print('[DownloadQueue] Processing: ${nextItem.track.name} by ${nextItem.track.artistName}'); + print('[DownloadQueue] Cover URL: ${nextItem.track.coverUrl}'); + + state = state.copyWith(currentDownload: nextItem); + updateItemStatus(nextItem.id, DownloadStatus.downloading); + + // Start progress polling + _startProgressPolling(nextItem.id); + + try { + Map result; + + if (state.autoFallback) { + print('[DownloadQueue] Using auto-fallback mode'); + result = await PlatformBridge.downloadWithFallback( + isrc: nextItem.track.isrc ?? '', + spotifyId: nextItem.track.id, + trackName: nextItem.track.name, + artistName: nextItem.track.artistName, + albumName: nextItem.track.albumName, + albumArtist: nextItem.track.albumArtist, + coverUrl: nextItem.track.coverUrl, + outputDir: state.outputDir, + filenameFormat: state.filenameFormat, + trackNumber: nextItem.track.trackNumber ?? 1, + discNumber: nextItem.track.discNumber ?? 1, + releaseDate: nextItem.track.releaseDate, + preferredService: nextItem.service, + ); + } else { + result = await PlatformBridge.downloadTrack( + isrc: nextItem.track.isrc ?? '', + service: nextItem.service, + spotifyId: nextItem.track.id, + trackName: nextItem.track.name, + artistName: nextItem.track.artistName, + albumName: nextItem.track.albumName, + albumArtist: nextItem.track.albumArtist, + coverUrl: nextItem.track.coverUrl, + outputDir: state.outputDir, + filenameFormat: state.filenameFormat, + trackNumber: nextItem.track.trackNumber ?? 1, + discNumber: nextItem.track.discNumber ?? 1, + releaseDate: nextItem.track.releaseDate, + ); + } + + // Stop progress polling for this item + _stopProgressPolling(); + + print('[DownloadQueue] Result: $result'); + + if (result['success'] == true) { + var filePath = result['file_path'] as String?; + print('[DownloadQueue] Download success, file: $filePath'); + + // Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC + if (filePath != null && filePath.endsWith('.m4a')) { + print('[DownloadQueue] Converting M4A to FLAC...'); + updateItemStatus(nextItem.id, DownloadStatus.downloading, progress: 0.9); + final flacPath = await FFmpegService.convertM4aToFlac(filePath); + if (flacPath != null) { + filePath = flacPath; + print('[DownloadQueue] Converted to: $flacPath'); + + // After conversion, embed metadata and cover to the new FLAC file + print('[DownloadQueue] Embedding metadata and cover to converted FLAC...'); + try { + await _embedMetadataAndCover( + flacPath, + nextItem.track, + ); + print('[DownloadQueue] Metadata and cover embedded successfully'); + } catch (e) { + print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e'); + } + } + } + + updateItemStatus( + nextItem.id, + DownloadStatus.completed, + progress: 1.0, + filePath: filePath, + ); + + if (filePath != null) { + ref.read(downloadHistoryProvider.notifier).addToHistory( + DownloadHistoryItem( + id: nextItem.id, + trackName: nextItem.track.name, + artistName: nextItem.track.artistName, + albumName: nextItem.track.albumName, + coverUrl: nextItem.track.coverUrl, + filePath: filePath, + service: result['service'] as String? ?? nextItem.service, + downloadedAt: DateTime.now(), + ), + ); + } + } else { + final errorMsg = result['error'] as String? ?? 'Download failed'; + print('[DownloadQueue] Download failed: $errorMsg'); + updateItemStatus( + nextItem.id, + DownloadStatus.failed, + error: errorMsg, + ); + } + } catch (e, stackTrace) { + _stopProgressPolling(); + print('[DownloadQueue] Exception: $e'); + print('[DownloadQueue] StackTrace: $stackTrace'); + updateItemStatus( + nextItem.id, + DownloadStatus.failed, + error: e.toString(), + ); + } + } + + _stopProgressPolling(); + print('[DownloadQueue] Queue processing finished'); + state = state.copyWith(isProcessing: false, currentDownload: null); + } +} + +final downloadQueueProvider = NotifierProvider( + DownloadQueueNotifier.new, +); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart new file mode 100644 index 00000000..111c3fd9 --- /dev/null +++ b/lib/providers/settings_provider.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/models/settings.dart'; + +const _settingsKey = 'app_settings'; + +class SettingsNotifier extends Notifier { + @override + AppSettings build() { + _loadSettings(); + return const AppSettings(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(_settingsKey); + if (json != null) { + state = AppSettings.fromJson(jsonDecode(json)); + } + } + + Future _saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_settingsKey, jsonEncode(state.toJson())); + } + + void setDefaultService(String service) { + state = state.copyWith(defaultService: service); + _saveSettings(); + } + + void setAudioQuality(String quality) { + state = state.copyWith(audioQuality: quality); + _saveSettings(); + } + + void setFilenameFormat(String format) { + state = state.copyWith(filenameFormat: format); + _saveSettings(); + } + + void setDownloadDirectory(String directory) { + state = state.copyWith(downloadDirectory: directory); + _saveSettings(); + } + + void setAutoFallback(bool enabled) { + state = state.copyWith(autoFallback: enabled); + _saveSettings(); + } + + void setEmbedLyrics(bool enabled) { + state = state.copyWith(embedLyrics: enabled); + _saveSettings(); + } + + void setMaxQualityCover(bool enabled) { + state = state.copyWith(maxQualityCover: enabled); + _saveSettings(); + } + + void setFirstLaunchComplete() { + state = state.copyWith(isFirstLaunch: false); + _saveSettings(); + } +} + +final settingsProvider = NotifierProvider( + SettingsNotifier.new, +); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 00000000..76a62189 --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; + +/// Provider for theme settings state management +final themeProvider = NotifierProvider(() { + return ThemeNotifier(); +}); + +/// Notifier for managing theme settings with persistence +class ThemeNotifier extends Notifier { + @override + ThemeSettings build() { + // Load settings asynchronously on first access + _loadFromStorage(); + return const ThemeSettings(); + } + + /// Load theme settings from SharedPreferences + Future _loadFromStorage() async { + try { + final prefs = await SharedPreferences.getInstance(); + final modeString = prefs.getString(kThemeModeKey); + final useDynamic = prefs.getBool(kUseDynamicColorKey); + final seedColor = prefs.getInt(kSeedColorKey); + + state = ThemeSettings( + themeMode: _themeModeFromString(modeString), + useDynamicColor: useDynamic ?? true, + seedColorValue: seedColor ?? kDefaultSeedColor, + ); + } catch (e) { + debugPrint('Error loading theme settings: $e'); + // Keep default state on error + } + } + + /// Save current settings to SharedPreferences + Future _saveToStorage() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(kThemeModeKey, state.themeMode.name); + await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor); + await prefs.setInt(kSeedColorKey, state.seedColorValue); + } catch (e) { + debugPrint('Error saving theme settings: $e'); + } + } + + /// Set theme mode (light, dark, or system) + Future setThemeMode(ThemeMode mode) async { + state = state.copyWith(themeMode: mode); + await _saveToStorage(); + } + + /// Enable or disable dynamic color from wallpaper + Future setUseDynamicColor(bool value) async { + state = state.copyWith(useDynamicColor: value); + await _saveToStorage(); + } + + /// Set custom seed color (used when dynamic color is disabled) + Future setSeedColor(Color color) async { + state = state.copyWith(seedColorValue: color.toARGB32()); + await _saveToStorage(); + } + + /// Set seed color from int value + Future setSeedColorValue(int colorValue) async { + state = state.copyWith(seedColorValue: colorValue); + await _saveToStorage(); + } + + /// Helper to convert string to ThemeMode + ThemeMode _themeModeFromString(String? value) { + if (value == null) return ThemeMode.system; + return ThemeMode.values.firstWhere( + (e) => e.name == value, + orElse: () => ThemeMode.system, + ); + } +} diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart new file mode 100644 index 00000000..2a75c7cd --- /dev/null +++ b/lib/providers/track_provider.dart @@ -0,0 +1,190 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +class TrackState { + final List tracks; + final bool isLoading; + final String? error; + final String? albumName; + final String? playlistName; + final String? coverUrl; + + const TrackState({ + this.tracks = const [], + this.isLoading = false, + this.error, + this.albumName, + this.playlistName, + this.coverUrl, + }); + + TrackState copyWith({ + List? tracks, + bool? isLoading, + String? error, + String? albumName, + String? playlistName, + String? coverUrl, + }) { + return TrackState( + tracks: tracks ?? this.tracks, + isLoading: isLoading ?? this.isLoading, + error: error, + albumName: albumName ?? this.albumName, + playlistName: playlistName ?? this.playlistName, + coverUrl: coverUrl ?? this.coverUrl, + ); + } +} + +class TrackNotifier extends Notifier { + @override + TrackState build() { + return const TrackState(); + } + + Future fetchFromUrl(String url) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final parsed = await PlatformBridge.parseSpotifyUrl(url); + final type = parsed['type'] as String; + + final metadata = await PlatformBridge.getSpotifyMetadata(url); + + if (type == 'track') { + final trackData = metadata['track'] as Map; + final track = _parseTrack(trackData); + state = state.copyWith( + tracks: [track], + isLoading: false, + albumName: null, + playlistName: null, + coverUrl: track.coverUrl, + ); + } else if (type == 'album') { + final albumInfo = metadata['album_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + state = state.copyWith( + tracks: tracks, + isLoading: false, + albumName: albumInfo['name'] as String?, + playlistName: null, + coverUrl: albumInfo['images'] as String?, + ); + } else if (type == 'playlist') { + final playlistInfo = metadata['playlist_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + final owner = playlistInfo['owner'] as Map?; + state = state.copyWith( + tracks: tracks, + isLoading: false, + albumName: null, + playlistName: owner?['name'] as String?, + coverUrl: owner?['images'] as String?, + ); + } + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future search(String query) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final results = await PlatformBridge.searchSpotify(query, limit: 20); + final trackList = results['tracks'] as List? ?? []; + final tracks = trackList.map((t) => _parseSearchTrack(t as Map)).toList(); + state = state.copyWith( + tracks: tracks, + isLoading: false, + albumName: null, + playlistName: null, + ); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future checkAvailability(int index) async { + if (index < 0 || index >= state.tracks.length) return; + + final track = state.tracks[index]; + if (track.isrc == null || track.isrc!.isEmpty) return; + + try { + final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!); + final updatedTrack = Track( + id: track.id, + name: track.name, + artistName: track.artistName, + albumName: track.albumName, + albumArtist: track.albumArtist, + coverUrl: track.coverUrl, + isrc: track.isrc, + duration: track.duration, + trackNumber: track.trackNumber, + discNumber: track.discNumber, + releaseDate: track.releaseDate, + availability: ServiceAvailability( + tidal: availability['tidal'] as bool? ?? false, + qobuz: availability['qobuz'] as bool? ?? false, + amazon: availability['amazon'] as bool? ?? false, + tidalUrl: availability['tidal_url'] as String?, + qobuzUrl: availability['qobuz_url'] as String?, + amazonUrl: availability['amazon_url'] as String?, + ), + ); + + final tracks = List.from(state.tracks); + tracks[index] = updatedTrack; + state = state.copyWith(tracks: tracks); + } catch (e) { + // Silently fail availability check + } + } + + void clear() { + state = const TrackState(); + } + + Track _parseTrack(Map data) { + return Track( + id: data['spotify_id'] as String? ?? '', + name: data['name'] as String? ?? '', + artistName: data['artists'] as String? ?? '', + albumName: data['album_name'] as String? ?? '', + albumArtist: data['album_artist'] as String?, + coverUrl: data['images'] as String?, + isrc: data['isrc'] as String?, + duration: data['duration_ms'] as int? ?? 0, + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date'] as String?, + ); + } + + Track _parseSearchTrack(Map data) { + return Track( + id: data['spotify_id'] as String? ?? '', + name: data['name'] as String? ?? '', + artistName: data['artists'] as String? ?? '', + albumName: data['album_name'] as String? ?? '', + albumArtist: data['album_artist'] as String?, + coverUrl: data['images'] as String?, + isrc: data['isrc'] as String?, + duration: data['duration_ms'] as int? ?? 0, + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date'] as String?, + ); + } +} + +final trackProvider = NotifierProvider( + TrackNotifier.new, +); diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart new file mode 100644 index 00000000..b03d8407 --- /dev/null +++ b/lib/screens/history_screen.dart @@ -0,0 +1,372 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; + +class HistoryScreen extends ConsumerWidget { + const HistoryScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyState = ref.watch(downloadHistoryProvider); + final history = historyState.items; + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Download History'), + actions: [ + if (history.isNotEmpty) + IconButton( + icon: const Icon(Icons.delete_sweep), + onPressed: () => _showClearHistoryDialog(context, ref), + tooltip: 'Clear history', + ), + ], + ), + body: history.isEmpty + ? _buildEmptyState(context, colorScheme) + : ListView.builder( + itemCount: history.length, + itemBuilder: (context, index) { + final item = history[index]; + return _buildHistoryItem(context, ref, item, colorScheme); + }, + ), + ); + } + + Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.history, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No download history', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Downloaded tracks will appear here', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + + Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) { + final fileExists = File(item.filePath).existsSync(); + + return Dismissible( + key: Key(item.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + color: colorScheme.error, + child: Icon(Icons.delete, color: colorScheme.onError), + ), + onDismissed: (_) { + ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Removed "${item.trackName}" from history')), + ); + }, + child: ListTile( + leading: item.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + title: Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + Row( + children: [ + Icon( + _getServiceIcon(item.service), + size: 12, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + _formatDate(item.downloadedAt), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (!fileExists) ...[ + const SizedBox(width: 8), + Icon( + Icons.warning, + size: 12, + color: colorScheme.error, + ), + const SizedBox(width: 2), + Text( + 'File missing', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.error, + ), + ), + ], + ], + ), + ], + ), + trailing: fileExists + ? IconButton( + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + onPressed: () => _openFile(context, item.filePath), + ) + : Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant), + onTap: fileExists ? () => _openFile(context, item.filePath) : null, + onLongPress: () => _showItemDetails(context, ref, item, colorScheme), + ), + ); + } + + IconData _getServiceIcon(String service) { + switch (service.toLowerCase()) { + case 'tidal': + return Icons.waves; + case 'qobuz': + return Icons.album; + case 'amazon': + return Icons.shopping_cart; + default: + return Icons.cloud_download; + } + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + + if (diff.inDays == 0) { + if (diff.inHours == 0) { + return '${diff.inMinutes}m ago'; + } + return '${diff.inHours}h ago'; + } else if (diff.inDays == 1) { + return 'Yesterday'; + } else if (diff.inDays < 7) { + return '${diff.inDays}d ago'; + } else { + return '${date.day}/${date.month}/${date.year}'; + } + } + + Future _openFile(BuildContext context, String filePath) async { + try { + final result = await OpenFilex.open(filePath); + + if (result.type != ResultType.done) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Cannot open: ${result.message}'), + action: SnackBarAction( + label: 'Copy Path', + onPressed: () { + Clipboard.setData(ClipboardData(text: filePath)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Path copied to clipboard')), + ); + }, + ), + ), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot open file: $e')), + ); + } + } + } + + void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) { + showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (item.coverUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 64, + height: 64, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.trackName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + item.artistName, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + Text( + item.albumName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(), + _buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme), + _buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme), + _buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + onPressed: () { + ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); + Navigator.pop(context); + }, + icon: Icon(Icons.delete, color: colorScheme.error), + label: Text('Remove', style: TextStyle(color: colorScheme.error)), + ), + if (File(item.filePath).existsSync()) + TextButton.icon( + onPressed: () { + Navigator.pop(context); + _openFile(context, item.filePath); + }, + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + label: Text('Play', style: TextStyle(color: colorScheme.primary)), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: isPath ? 12 : 14, + fontFamily: isPath ? 'monospace' : null, + ), + ), + ), + ], + ), + ); + } + + void _showClearHistoryDialog(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear History'), + content: const Text( + 'Are you sure you want to clear all download history? ' + 'This will not delete the downloaded files.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + ref.read(downloadHistoryProvider.notifier).clearHistory(); + Navigator.pop(context); + }, + child: Text('Clear', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + } +} diff --git a/lib/screens/history_tab.dart b/lib/screens/history_tab.dart new file mode 100644 index 00000000..4122e238 --- /dev/null +++ b/lib/screens/history_tab.dart @@ -0,0 +1,388 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; + +class HistoryTab extends ConsumerWidget { + const HistoryTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyState = ref.watch(downloadHistoryProvider); + final history = historyState.items; + final colorScheme = Theme.of(context).colorScheme; + + return Column( + children: [ + // Header with clear action + if (history.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${history.length} downloads', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + TextButton.icon( + onPressed: () => _showClearHistoryDialog(context, ref), + icon: Icon(Icons.delete_sweep, size: 18, color: colorScheme.error), + label: Text('Clear history', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ), + + // History list + Expanded( + child: history.isEmpty + ? _buildEmptyState(context, colorScheme) + : ListView.builder( + itemCount: history.length, + itemBuilder: (context, index) { + final item = history[index]; + return _buildHistoryItem(context, ref, item, colorScheme); + }, + ), + ), + ], + ); + } + + Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.history, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No download history', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Downloaded tracks will appear here', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + + Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) { + final fileExists = File(item.filePath).existsSync(); + + return Dismissible( + key: Key(item.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + color: colorScheme.error, + child: Icon(Icons.delete, color: colorScheme.onError), + ), + onDismissed: (_) { + ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Removed "${item.trackName}" from history')), + ); + }, + child: ListTile( + leading: item.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + title: Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + Row( + children: [ + Icon( + _getServiceIcon(item.service), + size: 12, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + _formatDate(item.downloadedAt), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (!fileExists) ...[ + const SizedBox(width: 8), + Icon( + Icons.warning, + size: 12, + color: colorScheme.error, + ), + const SizedBox(width: 2), + Text( + 'File missing', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.error, + ), + ), + ], + ], + ), + ], + ), + trailing: fileExists + ? IconButton( + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + onPressed: () => _openFile(context, item.filePath), + ) + : Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant), + onTap: fileExists ? () => _openFile(context, item.filePath) : null, + onLongPress: () => _showItemDetails(context, ref, item, colorScheme), + ), + ); + } + + IconData _getServiceIcon(String service) { + switch (service.toLowerCase()) { + case 'tidal': + return Icons.waves; + case 'qobuz': + return Icons.album; + case 'amazon': + return Icons.shopping_cart; + default: + return Icons.cloud_download; + } + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + + if (diff.inDays == 0) { + if (diff.inHours == 0) { + return '${diff.inMinutes}m ago'; + } + return '${diff.inHours}h ago'; + } else if (diff.inDays == 1) { + return 'Yesterday'; + } else if (diff.inDays < 7) { + return '${diff.inDays}d ago'; + } else { + return '${date.day}/${date.month}/${date.year}'; + } + } + + Future _openFile(BuildContext context, String filePath) async { + try { + final result = await OpenFilex.open(filePath); + + if (result.type != ResultType.done) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Cannot open: ${result.message}'), + action: SnackBarAction( + label: 'Copy Path', + onPressed: () { + Clipboard.setData(ClipboardData(text: filePath)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Path copied to clipboard')), + ); + }, + ), + ), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot open file: $e')), + ); + } + } + } + + void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) { + showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (item.coverUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 64, + height: 64, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.trackName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + item.artistName, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + Text( + item.albumName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(), + _buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme), + _buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme), + _buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + onPressed: () { + ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); + Navigator.pop(context); + }, + icon: Icon(Icons.delete, color: colorScheme.error), + label: Text('Remove', style: TextStyle(color: colorScheme.error)), + ), + if (File(item.filePath).existsSync()) + TextButton.icon( + onPressed: () { + Navigator.pop(context); + _openFile(context, item.filePath); + }, + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + label: Text('Play', style: TextStyle(color: colorScheme.primary)), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: isPath ? 12 : 14, + fontFamily: isPath ? 'monospace' : null, + ), + ), + ), + ], + ), + ); + } + + void _showClearHistoryDialog(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear History'), + content: const Text( + 'Are you sure you want to clear all download history? ' + 'This will not delete the downloaded files.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + ref.read(downloadHistoryProvider.notifier).clearHistory(); + Navigator.pop(context); + }, + child: Text('Clear', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 00000000..ea51b161 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/providers/track_provider.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; + +class HomeScreen extends ConsumerStatefulWidget { + const HomeScreen({super.key}); + + @override + ConsumerState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { + final _urlController = TextEditingController(); + int _currentIndex = 0; + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + Future _pasteFromClipboard() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null) { + _urlController.text = data!.text!; + } + } + + Future _fetchMetadata() async { + final url = _urlController.text.trim(); + if (url.isEmpty) return; + + if (url.startsWith('http') || url.startsWith('spotify:')) { + await ref.read(trackProvider.notifier).fetchFromUrl(url); + } else { + await ref.read(trackProvider.notifier).search(url); + } + } + + void _downloadTrack(int index) { + final trackState = ref.read(trackProvider); + if (index >= 0 && index < trackState.tracks.length) { + final track = trackState.tracks[index]; + final settings = ref.read(settingsProvider); + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added "${track.name}" to queue')), + ); + } + } + + void _downloadAll() { + final trackState = ref.read(trackProvider); + if (trackState.tracks.isEmpty) return; + + final settings = ref.read(settingsProvider); + ref.read(downloadQueueProvider.notifier).addMultipleToQueue( + trackState.tracks, + settings.defaultService, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')), + ); + } + + void _onNavTap(int index) { + setState(() => _currentIndex = index); + switch (index) { + case 0: + // Already on home + break; + case 1: + context.push('/queue'); + break; + case 2: + context.push('/history'); + break; + } + } + + @override + Widget build(BuildContext context) { + final trackState = ref.watch(trackProvider); + final queueState = ref.watch(downloadQueueProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + leading: Padding( + padding: const EdgeInsets.all(8.0), + child: CircleAvatar( + backgroundColor: colorScheme.primaryContainer, + child: Icon(Icons.music_note, color: colorScheme.onPrimaryContainer, size: 20), + ), + ), + title: const Text('SpotiFLAC'), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // URL Input + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: _urlController, + decoration: InputDecoration( + hintText: 'Paste Spotify URL or search...', + prefixIcon: const Icon(Icons.link), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard), + IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata), + ], + ), + ), + onSubmitted: (_) => _fetchMetadata(), + ), + ), + + // Error message + if (trackState.error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + trackState.error!, + style: TextStyle(color: colorScheme.error), + ), + ), + + // Loading indicator + if (trackState.isLoading) + LinearProgressIndicator(color: colorScheme.primary), + + // Album/Playlist header + if (trackState.albumName != null || trackState.playlistName != null) + _buildHeader(trackState, colorScheme), + + // Download All button + if (trackState.tracks.length > 1) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: FilledButton.icon( + onPressed: _downloadAll, + icon: const Icon(Icons.download), + label: Text('Download All (${trackState.tracks.length})'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + ), + ), + + // Track list + Expanded( + child: trackState.tracks.isEmpty + ? _buildEmptyState(colorScheme) + : ListView.builder( + itemCount: trackState.tracks.length, + itemBuilder: (context, index) => _buildTrackTile(index, colorScheme), + ), + ), + ], + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: _onNavTap, + destinations: [ + const NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Badge( + isLabelVisible: queueState.queuedCount > 0, + label: Text('${queueState.queuedCount}'), + child: const Icon(Icons.queue_music_outlined), + ), + selectedIcon: Badge( + isLabelVisible: queueState.queuedCount > 0, + label: Text('${queueState.queuedCount}'), + child: const Icon(Icons.queue_music), + ), + label: 'Queue', + ), + const NavigationDestination( + icon: Icon(Icons.history_outlined), + selectedIcon: Icon(Icons.history), + label: 'History', + ), + ], + ), + ); + } + + Widget _buildHeader(TrackState state, ColorScheme colorScheme) { + return Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (state.coverUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: state.coverUrl!, + width: 80, + height: 80, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + width: 80, + height: 80, + color: colorScheme.surfaceContainerHighest, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.albumName ?? state.playlistName ?? '', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '${state.tracks.length} tracks', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Play all button + FilledButton.tonal( + onPressed: _downloadAll, + style: FilledButton.styleFrom( + shape: const CircleBorder(), + padding: const EdgeInsets.all(16), + ), + child: const Icon(Icons.download), + ), + ], + ), + ), + ); + } + + Widget _buildTrackTile(int index, ColorScheme colorScheme) { + final track = ref.watch(trackProvider).tracks[index]; + return ListTile( + leading: track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + trailing: Text( + _formatDuration(track.duration), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + onTap: () => _downloadTrack(index), + ); + } + + String _formatDuration(int ms) { + if (ms == 0) return ''; + final duration = Duration(milliseconds: ms); + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + Widget _buildEmptyState(ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.music_note, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'Paste a Spotify URL to get started', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart new file mode 100644 index 00000000..ba08918e --- /dev/null +++ b/lib/screens/home_tab.dart @@ -0,0 +1,457 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/providers/track_provider.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; + +class HomeTab extends ConsumerStatefulWidget { + const HomeTab({super.key}); + + @override + ConsumerState createState() => _HomeTabState(); +} + +class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin { + final _urlController = TextEditingController(); + + @override + bool get wantKeepAlive => true; + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + Future _pasteFromClipboard() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null) { + _urlController.text = data!.text!; + } + } + + Future _fetchMetadata() async { + final url = _urlController.text.trim(); + if (url.isEmpty) return; + + if (url.startsWith('http') || url.startsWith('spotify:')) { + await ref.read(trackProvider.notifier).fetchFromUrl(url); + } else { + await ref.read(trackProvider.notifier).search(url); + } + } + + void _downloadTrack(int index) { + final trackState = ref.read(trackProvider); + if (index >= 0 && index < trackState.tracks.length) { + final track = trackState.tracks[index]; + final settings = ref.read(settingsProvider); + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added "${track.name}" to queue')), + ); + } + } + + void _downloadAll() { + final trackState = ref.read(trackProvider); + if (trackState.tracks.isEmpty) return; + + final settings = ref.read(settingsProvider); + ref.read(downloadQueueProvider.notifier).addMultipleToQueue( + trackState.tracks, + settings.defaultService, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')), + ); + } + + Future _openFile(String filePath) async { + try { + await OpenFilex.open(filePath); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot open file: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + final trackState = ref.watch(trackProvider); + final historyState = ref.watch(downloadHistoryProvider); + final colorScheme = Theme.of(context).colorScheme; + + return CustomScrollView( + slivers: [ + // Search bar + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: _urlController, + decoration: InputDecoration( + hintText: 'Paste Spotify URL or search...', + prefixIcon: const Icon(Icons.link), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard), + IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata), + ], + ), + ), + onSubmitted: (_) => _fetchMetadata(), + ), + ), + ), + + // Error message + if (trackState.error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + trackState.error!, + style: TextStyle(color: colorScheme.error), + ), + ), + ), + + // Loading indicator + if (trackState.isLoading) + const SliverToBoxAdapter( + child: LinearProgressIndicator(), + ), + + // Album/Playlist header + if (trackState.albumName != null || trackState.playlistName != null) + SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)), + + // Download All button (when no header) + if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: FilledButton.icon( + onPressed: _downloadAll, + icon: const Icon(Icons.download), + label: Text('Download All (${trackState.tracks.length})'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + ), + ), + ), + + // Track list + if (trackState.tracks.isNotEmpty) + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildTrackTile(index, colorScheme), + childCount: trackState.tracks.length, + ), + ), + + // Divider between search results and history + if (trackState.tracks.isNotEmpty && historyState.items.isNotEmpty) + const SliverToBoxAdapter( + child: Divider(height: 32), + ), + + // Recent Downloads section header + if (historyState.items.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Recent Downloads', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + TextButton( + onPressed: () => _showClearHistoryDialog(colorScheme), + child: const Text('Clear'), + ), + ], + ), + ), + ), + + // Recent Downloads list + if (historyState.items.isNotEmpty) + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildHistoryTile(historyState.items[index], colorScheme), + childCount: historyState.items.length > 5 ? 5 : historyState.items.length, + ), + ), + + // Show more history button + if (historyState.items.length > 5) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: OutlinedButton( + onPressed: () => _showAllHistory(colorScheme), + child: Text('Show all ${historyState.items.length} downloads'), + ), + ), + ), + + // Empty state (when no tracks and no history) + if (trackState.tracks.isEmpty && historyState.items.isEmpty) + SliverFillRemaining( + child: _buildEmptyState(colorScheme), + ), + + // Bottom padding + const SliverToBoxAdapter( + child: SizedBox(height: 16), + ), + ], + ); + } + + Widget _buildHeader(TrackState state, ColorScheme colorScheme) { + return Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (state.coverUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: state.coverUrl!, + width: 80, + height: 80, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + width: 80, + height: 80, + color: colorScheme.surfaceContainerHighest, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.albumName ?? state.playlistName ?? '', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '${state.tracks.length} tracks', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Download all button + FilledButton.tonal( + onPressed: _downloadAll, + style: FilledButton.styleFrom( + shape: const CircleBorder(), + padding: const EdgeInsets.all(16), + ), + child: const Icon(Icons.download), + ), + ], + ), + ), + ); + } + + Widget _buildTrackTile(int index, ColorScheme colorScheme) { + final track = ref.watch(trackProvider).tracks[index]; + return ListTile( + leading: track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + trailing: IconButton( + icon: Icon(Icons.download, color: colorScheme.primary), + onPressed: () => _downloadTrack(index), + ), + onTap: () => _downloadTrack(index), + ); + } + + Widget _buildHistoryTile(DownloadHistoryItem item, ColorScheme colorScheme) { + final fileExists = File(item.filePath).existsSync(); + + return ListTile( + leading: item.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + trailing: fileExists + ? IconButton( + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + onPressed: () => _openFile(item.filePath), + ) + : Icon(Icons.error_outline, color: colorScheme.error, size: 20), + onTap: fileExists ? () => _openFile(item.filePath) : null, + ); + } + + Widget _buildEmptyState(ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.music_note, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'Paste a Spotify URL to get started', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + void _showClearHistoryDialog(ColorScheme colorScheme) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear History'), + content: const Text('Clear all download history?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + ref.read(downloadHistoryProvider.notifier).clearHistory(); + Navigator.pop(context); + }, + child: Text('Clear', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + } + + void _showAllHistory(ColorScheme colorScheme) { + final historyState = ref.read(downloadHistoryProvider); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'All Downloads (${historyState.items.length})', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + controller: scrollController, + itemCount: historyState.items.length, + itemBuilder: (context, index) => _buildHistoryTile( + historyState.items[index], + colorScheme, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart new file mode 100644 index 00000000..e1f1e1e5 --- /dev/null +++ b/lib/screens/main_shell.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/screens/home_tab.dart'; +import 'package:spotiflac_android/screens/queue_tab.dart'; +import 'package:spotiflac_android/screens/settings_tab.dart'; + +class MainShell extends ConsumerStatefulWidget { + const MainShell({super.key}); + + @override + ConsumerState createState() => _MainShellState(); +} + +class _MainShellState extends ConsumerState { + int _currentIndex = 0; + late PageController _pageController; + + @override + void initState() { + super.initState(); + _pageController = PageController(initialPage: _currentIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _onNavTap(int index) { + setState(() => _currentIndex = index); + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + void _onPageChanged(int index) { + setState(() => _currentIndex = index); + } + + @override + Widget build(BuildContext context) { + final queueState = ref.watch(downloadQueueProvider); + + return Scaffold( + appBar: AppBar( + leading: Padding( + padding: const EdgeInsets.all(8.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.asset( + 'assets/images/logo.png', + width: 40, + height: 40, + ), + ), + ), + title: const Text('SpotiFLAC'), + ), + body: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + physics: const BouncingScrollPhysics(), + children: const [ + HomeTab(), + QueueTab(), + SettingsTab(), + ], + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: _onNavTap, + animationDuration: const Duration(milliseconds: 300), + destinations: [ + const NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Badge( + isLabelVisible: queueState.queuedCount > 0, + label: Text('${queueState.queuedCount}'), + child: const Icon(Icons.download_outlined), + ), + selectedIcon: Badge( + isLabelVisible: queueState.queuedCount > 0, + label: Text('${queueState.queuedCount}'), + child: const Icon(Icons.download), + ), + label: 'Downloads', + ), + const NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ); + } +} diff --git a/lib/screens/queue_screen.dart b/lib/screens/queue_screen.dart new file mode 100644 index 00000000..39d15481 --- /dev/null +++ b/lib/screens/queue_screen.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; + +class QueueScreen extends ConsumerWidget { + const QueueScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queueState = ref.watch(downloadQueueProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Download Queue'), + actions: [ + if (queueState.items.isNotEmpty) + IconButton( + icon: const Icon(Icons.delete_sweep), + onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(), + tooltip: 'Clear completed', + ), + if (queueState.items.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear_all), + onPressed: () => _showClearAllDialog(context, ref), + tooltip: 'Clear all', + ), + ], + ), + body: queueState.items.isEmpty + ? _buildEmptyState(context, colorScheme) + : ListView.builder( + itemCount: queueState.items.length, + itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme), + ), + ); + } + + Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.queue, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No downloads in queue', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Add tracks from the home screen', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + + Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) { + return ListTile( + leading: item.track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.track.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + if (item.status == DownloadStatus.downloading) ...[ + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: item.progress > 0 ? item.progress : null, + backgroundColor: colorScheme.surfaceContainerHighest, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Text( + '${(item.progress * 100).toStringAsFixed(0)}%', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ], + ), + trailing: _buildStatusIcon(context, item, colorScheme), + onTap: item.status == DownloadStatus.queued + ? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id) + : null, + ); + } + + Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) { + switch (item.status) { + case DownloadStatus.queued: + return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant); + case DownloadStatus.downloading: + return SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + value: item.progress, + strokeWidth: 2, + color: colorScheme.primary, + ), + ); + case DownloadStatus.completed: + return Icon(Icons.check_circle, color: colorScheme.primary); + case DownloadStatus.failed: + return IconButton( + icon: Icon(Icons.error, color: colorScheme.error), + onPressed: () => _showErrorDialog(context, item, colorScheme), + tooltip: 'Tap to see error details', + ); + case DownloadStatus.skipped: + return Icon(Icons.skip_next, color: colorScheme.primary); + } + } + + void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.error, color: colorScheme.error), + const SizedBox(width: 8), + const Text('Download Failed'), + ], + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)), + Text('Artist: ${item.track.artistName}'), + const SizedBox(height: 16), + const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + item.error ?? 'Unknown error', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + + void _showClearAllDialog(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear All'), + content: const Text('Are you sure you want to clear all downloads?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + ref.read(downloadQueueProvider.notifier).clearAll(); + Navigator.pop(context); + }, + child: Text('Clear', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + } +} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart new file mode 100644 index 00000000..ea49b8d4 --- /dev/null +++ b/lib/screens/queue_tab.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; + +class QueueTab extends ConsumerWidget { + const QueueTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queueState = ref.watch(downloadQueueProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Column( + children: [ + // Header with actions + if (queueState.items.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${queueState.items.length} items', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + Row( + children: [ + TextButton.icon( + onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(), + icon: const Icon(Icons.done_all, size: 18), + label: const Text('Clear done'), + ), + TextButton.icon( + onPressed: () => _showClearAllDialog(context, ref), + icon: Icon(Icons.clear_all, size: 18, color: colorScheme.error), + label: Text('Clear all', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ], + ), + ), + + // Queue list + Expanded( + child: queueState.items.isEmpty + ? _buildEmptyState(context, colorScheme) + : ListView.builder( + itemCount: queueState.items.length, + itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme), + ), + ), + ], + ); + } + + Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.queue_music, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No downloads in queue', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Add tracks from the Home tab', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + + Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) { + return ListTile( + leading: item.track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.track.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + if (item.status == DownloadStatus.downloading) ...[ + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: item.progress > 0 ? item.progress : null, + backgroundColor: colorScheme.surfaceContainerHighest, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Text( + '${(item.progress * 100).toStringAsFixed(0)}%', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ], + ), + trailing: _buildStatusIcon(context, item, colorScheme), + onTap: item.status == DownloadStatus.queued + ? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id) + : null, + ); + } + + Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) { + switch (item.status) { + case DownloadStatus.queued: + return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant); + case DownloadStatus.downloading: + return SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + value: item.progress, + strokeWidth: 2, + color: colorScheme.primary, + ), + ); + case DownloadStatus.completed: + return Icon(Icons.check_circle, color: colorScheme.primary); + case DownloadStatus.failed: + return IconButton( + icon: Icon(Icons.error, color: colorScheme.error), + onPressed: () => _showErrorDialog(context, item, colorScheme), + tooltip: 'Tap to see error details', + ); + case DownloadStatus.skipped: + return Icon(Icons.skip_next, color: colorScheme.primary); + } + } + + void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.error, color: colorScheme.error), + const SizedBox(width: 8), + const Text('Download Failed'), + ], + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)), + Text('Artist: ${item.track.artistName}'), + const SizedBox(height: 16), + const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + item.error ?? 'Unknown error', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + + void _showClearAllDialog(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear All'), + content: const Text('Are you sure you want to clear all downloads?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + ref.read(downloadQueueProvider.notifier).clearAll(); + Navigator.pop(context); + }, + child: Text('Clear', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart new file mode 100644 index 00000000..d775cf92 --- /dev/null +++ b/lib/screens/search_screen.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/providers/track_provider.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; + +class SearchScreen extends ConsumerStatefulWidget { + final String query; + + const SearchScreen({super.key, required this.query}); + + @override + ConsumerState createState() => _SearchScreenState(); +} + +class _SearchScreenState extends ConsumerState { + late TextEditingController _searchController; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(text: widget.query); + if (widget.query.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(trackProvider.notifier).search(widget.query); + }); + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _search() { + final query = _searchController.text.trim(); + if (query.isNotEmpty) { + ref.read(trackProvider.notifier).search(query); + } + } + + void _downloadTrack(int index) { + final trackState = ref.read(trackProvider); + if (index >= 0 && index < trackState.tracks.length) { + final track = trackState.tracks[index]; + final settings = ref.read(settingsProvider); + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added "${track.name}" to queue')), + ); + } + } + + @override + Widget build(BuildContext context) { + final trackState = ref.watch(trackProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: TextField( + controller: _searchController, + style: TextStyle(color: colorScheme.onSurface), + decoration: InputDecoration( + hintText: 'Search tracks...', + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + onSubmitted: (_) => _search(), + autofocus: widget.query.isEmpty, + ), + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: _search, + ), + ], + ), + body: Column( + children: [ + if (trackState.isLoading) + LinearProgressIndicator(color: colorScheme.primary), + if (trackState.error != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + trackState.error!, + style: TextStyle(color: colorScheme.error), + ), + ), + Expanded( + child: trackState.tracks.isEmpty + ? _buildEmptyState(colorScheme) + : ListView.builder( + itemCount: trackState.tracks.length, + itemBuilder: (context, index) => _buildTrackTile(index, colorScheme), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState(ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'Search for tracks', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _buildTrackTile(int index, ColorScheme colorScheme) { + final track = ref.watch(trackProvider).tracks[index]; + return ListTile( + leading: track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + Text( + track.albumName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + trailing: IconButton( + icon: Icon(Icons.download, color: colorScheme.primary), + onPressed: () => _downloadTrack(index), + ), + onTap: () => _downloadTrack(index), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 00000000..1d487d36 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,426 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final themeSettings = ref.watch(themeProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + children: [ + // Theme Section + _buildSectionHeader(context, 'Appearance', colorScheme), + + // Theme Mode + ListTile( + leading: Icon(Icons.brightness_6, color: colorScheme.primary), + title: const Text('Theme Mode'), + subtitle: Text(_getThemeModeName(themeSettings.themeMode)), + onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode), + ), + + // Dynamic Color Toggle + SwitchListTile( + secondary: Icon(Icons.palette, color: colorScheme.primary), + title: const Text('Dynamic Color'), + subtitle: const Text('Use colors from your wallpaper'), + value: themeSettings.useDynamicColor, + onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value), + ), + + // Seed Color Picker (only when dynamic color is disabled) + if (!themeSettings.useDynamicColor) + ListTile( + leading: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Color(themeSettings.seedColorValue), + shape: BoxShape.circle, + border: Border.all(color: colorScheme.outline), + ), + ), + title: const Text('Accent Color'), + subtitle: const Text('Choose your preferred color'), + onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue), + ), + + // Theme Preview + _buildThemePreview(context, colorScheme), + + const Divider(), + + // Download Section + _buildSectionHeader(context, 'Download', colorScheme), + + // Download Service + ListTile( + leading: Icon(Icons.cloud_download, color: colorScheme.primary), + title: const Text('Default Service'), + subtitle: Text(_getServiceName(settings.defaultService)), + onTap: () => _showServicePicker(context, ref, settings.defaultService), + ), + + // Audio Quality + ListTile( + leading: Icon(Icons.high_quality, color: colorScheme.primary), + title: const Text('Audio Quality'), + subtitle: Text(_getQualityName(settings.audioQuality)), + onTap: () => _showQualityPicker(context, ref, settings.audioQuality), + ), + + // Filename Format + ListTile( + leading: Icon(Icons.text_fields, color: colorScheme.primary), + title: const Text('Filename Format'), + subtitle: Text(settings.filenameFormat), + onTap: () => _showFormatEditor(context, ref, settings.filenameFormat), + ), + + // Download Directory + ListTile( + leading: Icon(Icons.folder, color: colorScheme.primary), + title: const Text('Download Directory'), + subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory), + onTap: () => _pickDirectory(context, ref), + ), + + const Divider(), + + // Options Section + _buildSectionHeader(context, 'Options', colorScheme), + + // Auto Fallback + SwitchListTile( + secondary: Icon(Icons.sync, color: colorScheme.primary), + title: const Text('Auto Fallback'), + subtitle: const Text('Try other services if download fails'), + value: settings.autoFallback, + onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value), + ), + + // Embed Lyrics + SwitchListTile( + secondary: Icon(Icons.lyrics, color: colorScheme.primary), + title: const Text('Embed Lyrics'), + subtitle: const Text('Embed synced lyrics into FLAC files'), + value: settings.embedLyrics, + onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value), + ), + + // Max Quality Cover + SwitchListTile( + secondary: Icon(Icons.image, color: colorScheme.primary), + title: const Text('Max Quality Cover'), + subtitle: const Text('Download highest resolution cover art'), + value: settings.maxQualityCover, + onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value), + ), + + const Divider(), + + // About + ListTile( + leading: Icon(Icons.info, color: colorScheme.primary), + title: const Text('About'), + subtitle: const Text('SpotiFLAC v1.0.0'), + onTap: () => showAboutDialog( + context: context, + applicationName: 'SpotiFLAC', + applicationVersion: '1.0.0', + applicationLegalese: '© 2024 SpotiFLAC', + ), + ), + ], + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) { + return Padding( + padding: const EdgeInsets.all(16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Theme Preview', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary), + _buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary), + _buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary), + _buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildColorChip(String label, Color background, Color foreground) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + label, + style: TextStyle(color: foreground, fontSize: 12), + ), + ); + } + + String _getThemeModeName(ThemeMode mode) { + switch (mode) { + case ThemeMode.light: return 'Light'; + case ThemeMode.dark: return 'Dark'; + case ThemeMode.system: return 'System'; + } + } + + String _getServiceName(String service) { + switch (service) { + case 'tidal': return 'Tidal'; + case 'qobuz': return 'Qobuz'; + case 'amazon': return 'Amazon Music'; + default: return service; + } + } + + String _getQualityName(String quality) { + switch (quality) { + case 'LOSSLESS': return 'FLAC (Lossless)'; + case 'HI_RES': return 'Hi-Res FLAC (24-bit)'; + default: return quality; + } + } + + void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Theme Mode'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme), + _buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme), + _buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme), + ], + ), + ), + ); + } + + Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) { + final isSelected = mode == current; + return ListTile( + leading: Icon(icon, color: isSelected ? colorScheme.primary : null), + title: Text(label), + trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(themeProvider.notifier).setThemeMode(mode); + Navigator.pop(context); + }, + ); + } + + void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) { + final colors = [ + const Color(0xFF1DB954), // Spotify Green + const Color(0xFF6750A4), // Purple + const Color(0xFF0061A4), // Blue + const Color(0xFF006E1C), // Green + const Color(0xFFBA1A1A), // Red + const Color(0xFF984061), // Pink + const Color(0xFF7D5260), // Brown + const Color(0xFF006874), // Teal + const Color(0xFFFF6F00), // Orange + ]; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Choose Accent Color'), + content: Wrap( + spacing: 12, + runSpacing: 12, + children: colors.map((color) { + final isSelected = color.toARGB32() == currentColor; + return GestureDetector( + onTap: () { + ref.read(themeProvider.notifier).setSeedColor(color); + Navigator.pop(context); + }, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: isSelected + ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) + : null, + ), + child: isSelected + ? const Icon(Icons.check, color: Colors.white) + : null, + ), + ); + }).toList(), + ), + ), + ); + } + + void _showServicePicker(BuildContext context, WidgetRef ref, String current) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Service'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme), + _buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme), + _buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme), + ], + ), + ), + ); + } + + Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) { + final isSelected = value == current; + return ListTile( + title: Text(label), + trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setDefaultService(value); + Navigator.pop(context); + }, + ); + } + + void _showQualityPicker(BuildContext context, WidgetRef ref, String current) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Quality'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme), + _buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme), + ], + ), + ), + ); + } + + Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) { + final isSelected = value == current; + return ListTile( + title: Text(title), + subtitle: Text(subtitle), + trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setAudioQuality(value); + Navigator.pop(context); + }, + ); + } + + void _showFormatEditor(BuildContext context, WidgetRef ref, String current) { + final controller = TextEditingController(text: current); + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Filename Format'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + decoration: const InputDecoration( + hintText: '{artist} - {title}', + ), + ), + const SizedBox(height: 16), + Text( + 'Available placeholders:', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + '{title}, {artist}, {album}, {track}, {year}, {disc}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ), + ); + } + + Future _pickDirectory(BuildContext context, WidgetRef ref) async { + final result = await FilePicker.platform.getDirectoryPath(); + if (result != null) { + ref.read(settingsProvider.notifier).setDownloadDirectory(result); + } + } +} diff --git a/lib/screens/settings_tab.dart b/lib/screens/settings_tab.dart new file mode 100644 index 00000000..a85ed60e --- /dev/null +++ b/lib/screens/settings_tab.dart @@ -0,0 +1,395 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; + +class SettingsTab extends ConsumerStatefulWidget { + const SettingsTab({super.key}); + + @override + ConsumerState createState() => _SettingsTabState(); +} + +class _SettingsTabState extends ConsumerState with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + final settings = ref.watch(settingsProvider); + final themeSettings = ref.watch(themeProvider); + final colorScheme = Theme.of(context).colorScheme; + + return ListView( + children: [ + // Theme Section + _buildSectionHeader(context, 'Appearance', colorScheme), + + // Theme Mode + ListTile( + leading: Icon(Icons.brightness_6, color: colorScheme.primary), + title: const Text('Theme Mode'), + subtitle: Text(_getThemeModeName(themeSettings.themeMode)), + onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode), + ), + + // Dynamic Color Toggle + SwitchListTile( + secondary: Icon(Icons.palette, color: colorScheme.primary), + title: const Text('Dynamic Color'), + subtitle: const Text('Use colors from your wallpaper'), + value: themeSettings.useDynamicColor, + onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value), + ), + + // Seed Color Picker (only when dynamic color is disabled) + if (!themeSettings.useDynamicColor) + ListTile( + leading: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Color(themeSettings.seedColorValue), + shape: BoxShape.circle, + border: Border.all(color: colorScheme.outline), + ), + ), + title: const Text('Accent Color'), + subtitle: const Text('Choose your preferred color'), + onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue), + ), + + // Theme Preview + _buildThemePreview(context, colorScheme), + + const Divider(), + + // Download Section + _buildSectionHeader(context, 'Download', colorScheme), + + // Download Service + ListTile( + leading: Icon(Icons.cloud_download, color: colorScheme.primary), + title: const Text('Default Service'), + subtitle: Text(_getServiceName(settings.defaultService)), + onTap: () => _showServicePicker(context, ref, settings.defaultService), + ), + + // Audio Quality + ListTile( + leading: Icon(Icons.high_quality, color: colorScheme.primary), + title: const Text('Audio Quality'), + subtitle: Text(_getQualityName(settings.audioQuality)), + onTap: () => _showQualityPicker(context, ref, settings.audioQuality), + ), + + // Filename Format + ListTile( + leading: Icon(Icons.text_fields, color: colorScheme.primary), + title: const Text('Filename Format'), + subtitle: Text(settings.filenameFormat), + onTap: () => _showFormatEditor(context, ref, settings.filenameFormat), + ), + + // Download Directory + ListTile( + leading: Icon(Icons.folder, color: colorScheme.primary), + title: const Text('Download Directory'), + subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory), + onTap: () => _pickDirectory(context, ref), + ), + + const Divider(), + + // Options Section + _buildSectionHeader(context, 'Options', colorScheme), + + // Auto Fallback + SwitchListTile( + secondary: Icon(Icons.sync, color: colorScheme.primary), + title: const Text('Auto Fallback'), + subtitle: const Text('Try other services if download fails'), + value: settings.autoFallback, + onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value), + ), + + // Embed Lyrics + SwitchListTile( + secondary: Icon(Icons.lyrics, color: colorScheme.primary), + title: const Text('Embed Lyrics'), + subtitle: const Text('Embed synced lyrics into FLAC files'), + value: settings.embedLyrics, + onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value), + ), + + // Max Quality Cover + SwitchListTile( + secondary: Icon(Icons.image, color: colorScheme.primary), + title: const Text('Max Quality Cover'), + subtitle: const Text('Download highest resolution cover art'), + value: settings.maxQualityCover, + onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value), + ), + + const Divider(), + + // About + ListTile( + leading: Icon(Icons.info, color: colorScheme.primary), + title: const Text('About'), + subtitle: const Text('SpotiFLAC v1.0.0'), + onTap: () => showAboutDialog( + context: context, + applicationName: 'SpotiFLAC', + applicationVersion: '1.0.0', + applicationLegalese: '© 2024 SpotiFLAC', + ), + ), + + // Bottom padding for navigation bar + const SizedBox(height: 16), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) { + return Padding( + padding: const EdgeInsets.all(16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Theme Preview', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary), + _buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary), + _buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary), + _buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildColorChip(String label, Color background, Color foreground) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)), + child: Text(label, style: TextStyle(color: foreground, fontSize: 12)), + ); + } + + String _getThemeModeName(ThemeMode mode) { + switch (mode) { + case ThemeMode.light: return 'Light'; + case ThemeMode.dark: return 'Dark'; + case ThemeMode.system: return 'System'; + } + } + + String _getServiceName(String service) { + switch (service) { + case 'tidal': return 'Tidal'; + case 'qobuz': return 'Qobuz'; + case 'amazon': return 'Amazon Music'; + default: return service; + } + } + + String _getQualityName(String quality) { + switch (quality) { + case 'LOSSLESS': return 'FLAC (Lossless)'; + case 'HI_RES': return 'Hi-Res FLAC (24-bit)'; + default: return quality; + } + } + + void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Theme Mode'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme), + _buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme), + _buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme), + ], + ), + ), + ); + } + + Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) { + final isSelected = mode == current; + return ListTile( + leading: Icon(icon, color: isSelected ? colorScheme.primary : null), + title: Text(label), + trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(themeProvider.notifier).setThemeMode(mode); + Navigator.pop(context); + }, + ); + } + + void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) { + final colors = [ + const Color(0xFF1DB954), const Color(0xFF6750A4), const Color(0xFF0061A4), + const Color(0xFF006E1C), const Color(0xFFBA1A1A), const Color(0xFF984061), + const Color(0xFF7D5260), const Color(0xFF006874), const Color(0xFFFF6F00), + ]; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Choose Accent Color'), + content: Wrap( + spacing: 12, + runSpacing: 12, + children: colors.map((color) { + final isSelected = color.toARGB32() == currentColor; + return GestureDetector( + onTap: () { + ref.read(themeProvider.notifier).setSeedColor(color); + Navigator.pop(context); + }, + child: Container( + width: 48, height: 48, + decoration: BoxDecoration( + color: color, shape: BoxShape.circle, + border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null, + ), + child: isSelected ? const Icon(Icons.check, color: Colors.white) : null, + ), + ); + }).toList(), + ), + ), + ); + } + + void _showServicePicker(BuildContext context, WidgetRef ref, String current) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Service'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme), + _buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme), + _buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme), + ], + ), + ), + ); + } + + Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) { + final isSelected = value == current; + return ListTile( + title: Text(label), + trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setDefaultService(value); + Navigator.pop(context); + }, + ); + } + + void _showQualityPicker(BuildContext context, WidgetRef ref, String current) { + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Quality'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme), + _buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme), + ], + ), + ), + ); + } + + Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) { + final isSelected = value == current; + return ListTile( + title: Text(title), + subtitle: Text(subtitle), + trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setAudioQuality(value); + Navigator.pop(context); + }, + ); + } + + void _showFormatEditor(BuildContext context, WidgetRef ref, String current) { + final controller = TextEditingController(text: current); + final colorScheme = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Filename Format'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}')), + const SizedBox(height: 16), + Text('Available placeholders:', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: 4), + Text('{title}, {artist}, {album}, {track}, {year}, {disc}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + FilledButton( + onPressed: () { + ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ), + ); + } + + Future _pickDirectory(BuildContext context, WidgetRef ref) async { + final result = await FilePicker.platform.getDirectoryPath(); + if (result != null) { + ref.read(settingsProvider.notifier).setDownloadDirectory(result); + } + } +} diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart new file mode 100644 index 00000000..8b5befb9 --- /dev/null +++ b/lib/screens/setup_screen.dart @@ -0,0 +1,549 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:go_router/go_router.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; + +class SetupScreen extends ConsumerStatefulWidget { + const SetupScreen({super.key}); + + @override + ConsumerState createState() => _SetupScreenState(); +} + +class _SetupScreenState extends ConsumerState { + int _currentStep = 0; + bool _permissionGranted = false; + String? _selectedDirectory; + bool _isLoading = false; + int _androidSdkVersion = 0; + + @override + void initState() { + super.initState(); + _initDeviceInfo(); + } + + Future _initDeviceInfo() async { + if (Platform.isAndroid) { + final deviceInfo = DeviceInfoPlugin(); + final androidInfo = await deviceInfo.androidInfo; + _androidSdkVersion = androidInfo.version.sdkInt; + debugPrint('Android SDK Version: $_androidSdkVersion'); + } + await _checkInitialPermission(); + } + + Future _checkInitialPermission() async { + if (Platform.isIOS) { + // iOS doesn't need storage permission - app uses its own Documents directory + if (mounted) { + setState(() => _permissionGranted = true); + } + } else if (Platform.isAndroid) { + PermissionStatus status; + + if (_androidSdkVersion >= 33) { + status = await Permission.audio.status; + } else if (_androidSdkVersion >= 30) { + status = await Permission.manageExternalStorage.status; + } else { + status = await Permission.storage.status; + } + + if (status.isGranted && mounted) { + setState(() => _permissionGranted = true); + } + } + } + + Future _requestPermission() async { + setState(() => _isLoading = true); + + try { + if (Platform.isIOS) { + // iOS doesn't need storage permission - app uses its own Documents directory + setState(() => _permissionGranted = true); + } else if (Platform.isAndroid) { + PermissionStatus status; + + if (_androidSdkVersion >= 33) { + status = await Permission.audio.request(); + if (!status.isGranted) { + await Permission.notification.request(); + } + } else if (_androidSdkVersion >= 30) { + status = await Permission.manageExternalStorage.request(); + } else { + status = await Permission.storage.request(); + } + + if (status.isGranted) { + setState(() => _permissionGranted = true); + } else if (status.isPermanentlyDenied) { + _showPermissionDeniedDialog(); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Permission denied. Please grant permission to continue.'), + ), + ); + } + } + } + } catch (e) { + debugPrint('Permission error: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } finally { + setState(() => _isLoading = false); + } + } + + void _showPermissionDeniedDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Permission Required'), + content: const Text( + 'Storage permission is required to save downloaded music files. ' + 'Please grant permission in app settings.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + openAppSettings(); + }, + child: const Text('Open Settings'), + ), + ], + ), + ); + } + + Future _selectDirectory() async { + setState(() => _isLoading = true); + + try { + String? selectedDirectory = await FilePicker.platform.getDirectoryPath( + dialogTitle: 'Select Download Folder', + ); + + if (selectedDirectory != null) { + setState(() => _selectedDirectory = selectedDirectory); + } else { + final defaultDir = await _getDefaultDirectory(); + if (mounted) { + final useDefault = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Use Default Folder?'), + content: Text( + 'No folder selected. Would you like to use the default Music folder?\n\n$defaultDir', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Use Default'), + ), + ], + ), + ); + + if (useDefault == true) { + setState(() => _selectedDirectory = defaultDir); + } + } + } + } finally { + setState(() => _isLoading = false); + } + } + + Future _getDefaultDirectory() async { + if (Platform.isIOS) { + // iOS: Use Documents directory (accessible via Files app) + final appDir = await getApplicationDocumentsDirectory(); + final musicDir = Directory('${appDir.path}/SpotiFLAC'); + try { + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir.path; + } catch (e) { + debugPrint('Cannot create SpotiFLAC folder: $e'); + } + return '${appDir.path}/SpotiFLAC'; + } else if (Platform.isAndroid) { + final musicDir = Directory('/storage/emulated/0/Music/SpotiFLAC'); + try { + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir.path; + } catch (e) { + debugPrint('Cannot create Music folder: $e'); + } + } + final appDir = await getApplicationDocumentsDirectory(); + return '${appDir.path}/SpotiFLAC'; + } + + Future _completeSetup() async { + if (_selectedDirectory == null) return; + + setState(() => _isLoading = true); + + try { + final dir = Directory(_selectedDirectory!); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!); + ref.read(settingsProvider.notifier).setFirstLaunchComplete(); + + if (mounted) { + context.go('/'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } finally { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height - + MediaQuery.of(context).padding.top - + MediaQuery.of(context).padding.bottom - 48, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Top section - Logo/Title + Column( + children: [ + const SizedBox(height: 24), + ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.asset( + 'assets/images/logo.png', + width: 96, + height: 96, + ), + ), + const SizedBox(height: 12), + Text( + 'SpotiFLAC', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 4), + Text( + 'Download Spotify tracks in FLAC', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + + // Middle section - Steps and Content + Column( + children: [ + const SizedBox(height: 24), + _buildStepIndicator(colorScheme), + const SizedBox(height: 24), + _currentStep == 0 + ? _buildPermissionStep(colorScheme) + : _buildDirectoryStep(colorScheme), + ], + ), + + // Bottom section - Navigation Buttons + Column( + children: [ + const SizedBox(height: 24), + _buildNavigationButtons(colorScheme), + const SizedBox(height: 16), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildStepIndicator(ColorScheme colorScheme) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildStepDot(0, 'Permission', colorScheme), + Container( + width: 40, + height: 2, + color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest, + ), + _buildStepDot(1, 'Folder', colorScheme), + ], + ); + } + + Widget _buildStepDot(int step, String label, ColorScheme colorScheme) { + final isActive = _currentStep >= step; + final isCompleted = (step == 0 && _permissionGranted) || + (step == 1 && _selectedDirectory != null); + + return Column( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted + ? colorScheme.primary + : isActive + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + ), + child: Center( + child: isCompleted + ? Icon(Icons.check, size: 18, color: colorScheme.onPrimary) + : Text( + '${step + 1}', + style: TextStyle( + color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + Widget _buildPermissionStep(ColorScheme colorScheme) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _permissionGranted ? Icons.check_circle : Icons.folder_open, + size: 56, + color: _permissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + _permissionGranted + ? 'Storage Permission Granted!' + : 'Storage Permission Required', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + _permissionGranted + ? 'You can now select where to save your music files.' + : 'SpotiFLAC needs storage access to save downloaded music files to your device.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + if (!_permissionGranted) + FilledButton.icon( + onPressed: _isLoading ? null : _requestPermission, + icon: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : const Icon(Icons.security), + label: const Text('Grant Permission'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ); + } + + Widget _buildDirectoryStep(ColorScheme colorScheme) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _selectedDirectory != null ? Icons.folder : Icons.create_new_folder, + size: 56, + color: _selectedDirectory != null ? colorScheme.primary : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + _selectedDirectory != null + ? 'Download Folder Selected!' + : 'Choose Download Folder', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + if (_selectedDirectory != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.folder, color: colorScheme.primary, size: 20), + const SizedBox(width: 8), + Flexible( + child: Text( + _selectedDirectory!, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ) + else + Text( + 'Select a folder where your downloaded music will be saved.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _isLoading ? null : _selectDirectory, + icon: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open), + label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ); + } + + Widget _buildNavigationButtons(ColorScheme colorScheme) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Back button + if (_currentStep > 0) + TextButton.icon( + onPressed: () => setState(() => _currentStep--), + icon: const Icon(Icons.arrow_back), + label: const Text('Back'), + ) + else + const SizedBox(width: 100), + + // Next/Finish button + if (_currentStep == 0) + FilledButton( + onPressed: _permissionGranted + ? () => setState(() => _currentStep++) + : null, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Next'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), + ], + ), + ) + else + FilledButton( + onPressed: _selectedDirectory != null && !_isLoading + ? _completeSetup + : null, + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Get Started'), + SizedBox(width: 8), + Icon(Icons.check, size: 18), + ], + ), + ), + ], + ); + } +} diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart new file mode 100644 index 00000000..043aa3d5 --- /dev/null +++ b/lib/services/ffmpeg_service.dart @@ -0,0 +1,122 @@ +import 'dart:io'; +import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new/return_code.dart'; + +/// FFmpeg service for audio conversion and remuxing +class FFmpegService { + /// Convert M4A (DASH segments) to FLAC + /// Returns the output file path on success, null on failure + static Future convertM4aToFlac(String inputPath) async { + final outputPath = inputPath.replaceAll('.m4a', '.flac'); + + // FFmpeg command to remux M4A to FLAC + final command = + '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y'; + + final session = await FFmpegKit.execute(command); + final returnCode = await session.getReturnCode(); + + if (ReturnCode.isSuccess(returnCode)) { + // Delete original M4A file + try { + await File(inputPath).delete(); + } catch (_) {} + return outputPath; + } + + // Log error for debugging + final logs = await session.getLogs(); + for (final log in logs) { + print('[FFmpeg] ${log.getMessage()}'); + } + + return null; + } + + /// Convert FLAC to MP3 + static Future convertFlacToMp3( + String inputPath, { + String bitrate = '320k', + }) async { + final dir = File(inputPath).parent.path; + final baseName = + inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', ''); + final outputDir = '$dir${Platform.pathSeparator}MP3'; + + // Create output directory + await Directory(outputDir).create(recursive: true); + + final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3'; + + final command = + '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y'; + + final session = await FFmpegKit.execute(command); + final returnCode = await session.getReturnCode(); + + if (ReturnCode.isSuccess(returnCode)) { + return outputPath; + } + + return null; + } + + /// Convert FLAC to M4A (AAC or ALAC) + static Future convertFlacToM4a( + String inputPath, { + String codec = 'aac', + String bitrate = '256k', + }) async { + final dir = File(inputPath).parent.path; + final baseName = + inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', ''); + final outputDir = '$dir${Platform.pathSeparator}M4A'; + + // Create output directory + await Directory(outputDir).create(recursive: true); + + final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a'; + + String command; + if (codec == 'alac') { + // ALAC - lossless + command = + '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y'; + } else { + // AAC - lossy + command = + '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y'; + } + + final session = await FFmpegKit.execute(command); + final returnCode = await session.getReturnCode(); + + if (ReturnCode.isSuccess(returnCode)) { + return outputPath; + } + + return null; + } + + /// Check if FFmpeg is available + static Future isAvailable() async { + try { + final session = await FFmpegKit.execute('-version'); + final returnCode = await session.getReturnCode(); + return ReturnCode.isSuccess(returnCode); + } catch (e) { + return false; + } + } + + /// Get FFmpeg version info + static Future getVersion() async { + try { + final session = await FFmpegKit.execute('-version'); + final output = await session.getOutput(); + return output; + } catch (e) { + return null; + } + } +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart new file mode 100644 index 00000000..bc0a6af2 --- /dev/null +++ b/lib/services/platform_bridge.dart @@ -0,0 +1,198 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; + +/// Bridge to communicate with Go backend via platform channels +class PlatformBridge { + static const _channel = MethodChannel('com.zarz.spotiflac/backend'); + + /// Parse and validate Spotify URL + static Future> parseSpotifyUrl(String url) async { + final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); + return jsonDecode(result as String) as Map; + } + + /// Get Spotify metadata from URL + static Future> getSpotifyMetadata(String url) async { + final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url}); + return jsonDecode(result as String) as Map; + } + + /// Search Spotify + static Future> searchSpotify(String query, {int limit = 10}) async { + final result = await _channel.invokeMethod('searchSpotify', { + 'query': query, + 'limit': limit, + }); + return jsonDecode(result as String) as Map; + } + + /// Check track availability on streaming services + static Future> checkAvailability(String spotifyId, String isrc) async { + final result = await _channel.invokeMethod('checkAvailability', { + 'spotify_id': spotifyId, + 'isrc': isrc, + }); + return jsonDecode(result as String) as Map; + } + + /// Download a track from specific service + static Future> downloadTrack({ + required String isrc, + required String service, + required String spotifyId, + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + bool embedLyrics = true, + bool embedMaxQualityCover = true, + int trackNumber = 1, + int discNumber = 1, + int totalTracks = 1, + String? releaseDate, + }) async { + final request = jsonEncode({ + 'isrc': isrc, + 'service': service, + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + 'album_name': albumName, + 'album_artist': albumArtist ?? artistName, + 'cover_url': coverUrl, + 'output_dir': outputDir, + 'filename_format': filenameFormat, + 'embed_lyrics': embedLyrics, + 'embed_max_quality_cover': embedMaxQualityCover, + 'track_number': trackNumber, + 'disc_number': discNumber, + 'total_tracks': totalTracks, + 'release_date': releaseDate ?? '', + }); + + final result = await _channel.invokeMethod('downloadTrack', request); + return jsonDecode(result as String) as Map; + } + + /// Download with automatic fallback to other services + static Future> downloadWithFallback({ + required String isrc, + required String spotifyId, + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + bool embedLyrics = true, + bool embedMaxQualityCover = true, + int trackNumber = 1, + int discNumber = 1, + int totalTracks = 1, + String? releaseDate, + String preferredService = 'tidal', + }) async { + final request = jsonEncode({ + 'isrc': isrc, + 'service': preferredService, + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + 'album_name': albumName, + 'album_artist': albumArtist ?? artistName, + 'cover_url': coverUrl, + 'output_dir': outputDir, + 'filename_format': filenameFormat, + 'embed_lyrics': embedLyrics, + 'embed_max_quality_cover': embedMaxQualityCover, + 'track_number': trackNumber, + 'disc_number': discNumber, + 'total_tracks': totalTracks, + 'release_date': releaseDate ?? '', + }); + + final result = await _channel.invokeMethod('downloadWithFallback', request); + return jsonDecode(result as String) as Map; + } + + /// Get download progress + static Future> getDownloadProgress() async { + final result = await _channel.invokeMethod('getDownloadProgress'); + return jsonDecode(result as String) as Map; + } + + /// Set download directory + static Future setDownloadDirectory(String path) async { + await _channel.invokeMethod('setDownloadDirectory', {'path': path}); + } + + /// Check if file with ISRC already exists + static Future> checkDuplicate(String outputDir, String isrc) async { + final result = await _channel.invokeMethod('checkDuplicate', { + 'output_dir': outputDir, + 'isrc': isrc, + }); + return jsonDecode(result as String) as Map; + } + + /// Build filename from template + static Future buildFilename(String template, Map metadata) async { + final result = await _channel.invokeMethod('buildFilename', { + 'template': template, + 'metadata': jsonEncode(metadata), + }); + return result as String; + } + + /// Sanitize filename + static Future sanitizeFilename(String filename) async { + final result = await _channel.invokeMethod('sanitizeFilename', { + 'filename': filename, + }); + return result as String; + } + + /// Fetch lyrics for a track + static Future> fetchLyrics( + String spotifyId, + String trackName, + String artistName, + ) async { + final result = await _channel.invokeMethod('fetchLyrics', { + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + }); + return jsonDecode(result as String) as Map; + } + + /// Get lyrics in LRC format + static Future getLyricsLRC( + String spotifyId, + String trackName, + String artistName, + ) async { + final result = await _channel.invokeMethod('getLyricsLRC', { + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + }); + return result as String; + } + + /// Embed lyrics into an existing FLAC file + static Future> embedLyricsToFile( + String filePath, + String lyrics, + ) async { + final result = await _channel.invokeMethod('embedLyricsToFile', { + 'file_path': filePath, + 'lyrics': lyrics, + }); + return jsonDecode(result as String) as Map; + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 00000000..3dff5566 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; + +/// App theme configuration for Material Expressive 3 +class AppTheme { + /// Default seed color (Spotify green) + static const Color defaultSeedColor = Color(kDefaultSeedColor); + + /// Create light theme + static ThemeData light({ + ColorScheme? dynamicScheme, + Color? seedColor, + }) { + final scheme = dynamicScheme ?? + ColorScheme.fromSeed( + seedColor: seedColor ?? defaultSeedColor, + brightness: Brightness.light, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + appBarTheme: _appBarTheme(scheme), + cardTheme: _cardTheme(scheme), + elevatedButtonTheme: _elevatedButtonTheme(scheme), + filledButtonTheme: _filledButtonTheme(scheme), + outlinedButtonTheme: _outlinedButtonTheme(scheme), + textButtonTheme: _textButtonTheme(scheme), + floatingActionButtonTheme: _fabTheme(scheme), + inputDecorationTheme: _inputDecorationTheme(scheme), + listTileTheme: _listTileTheme(scheme), + dialogTheme: _dialogTheme(scheme), + navigationBarTheme: _navigationBarTheme(scheme), + snackBarTheme: _snackBarTheme(scheme), + progressIndicatorTheme: _progressIndicatorTheme(scheme), + switchTheme: _switchTheme(scheme), + chipTheme: _chipTheme(scheme), + dividerTheme: _dividerTheme(scheme), + ); + } + + /// Create dark theme + static ThemeData dark({ + ColorScheme? dynamicScheme, + Color? seedColor, + }) { + final scheme = dynamicScheme ?? + ColorScheme.fromSeed( + seedColor: seedColor ?? defaultSeedColor, + brightness: Brightness.dark, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + appBarTheme: _appBarTheme(scheme), + cardTheme: _cardTheme(scheme), + elevatedButtonTheme: _elevatedButtonTheme(scheme), + filledButtonTheme: _filledButtonTheme(scheme), + outlinedButtonTheme: _outlinedButtonTheme(scheme), + textButtonTheme: _textButtonTheme(scheme), + floatingActionButtonTheme: _fabTheme(scheme), + inputDecorationTheme: _inputDecorationTheme(scheme), + listTileTheme: _listTileTheme(scheme), + dialogTheme: _dialogTheme(scheme), + navigationBarTheme: _navigationBarTheme(scheme), + snackBarTheme: _snackBarTheme(scheme), + progressIndicatorTheme: _progressIndicatorTheme(scheme), + switchTheme: _switchTheme(scheme), + chipTheme: _chipTheme(scheme), + dividerTheme: _dividerTheme(scheme), + ); + } + + /// AppBar theme + static AppBarTheme _appBarTheme(ColorScheme scheme) => AppBarTheme( + elevation: 0, + scrolledUnderElevation: 3, + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + surfaceTintColor: scheme.surfaceTint, + centerTitle: true, + titleTextStyle: TextStyle( + color: scheme.onSurface, + fontSize: 22, + fontWeight: FontWeight.w500, + ), + ); + + /// Card theme + static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + color: scheme.surfaceContainerLow, + surfaceTintColor: scheme.surfaceTint, + ); + + /// Elevated button theme + static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) => + ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ); + + /// Filled button theme + static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) => + FilledButtonThemeData( + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ); + + /// Outlined button theme + static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) => + OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ); + + /// Text button theme + static TextButtonThemeData _textButtonTheme(ColorScheme scheme) => + TextButtonThemeData( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ); + + /// FAB theme + static FloatingActionButtonThemeData _fabTheme(ColorScheme scheme) => + FloatingActionButtonThemeData( + elevation: 3, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: scheme.primaryContainer, + foregroundColor: scheme.onPrimaryContainer, + ); + + /// Input decoration theme + static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) => + InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: scheme.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: scheme.error, width: 1), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ); + + /// List tile theme + static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + ); + + /// Dialog theme + static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData( + elevation: 6, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + backgroundColor: scheme.surfaceContainerHigh, + surfaceTintColor: scheme.surfaceTint, + ); + + /// Navigation bar theme + static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme) => + NavigationBarThemeData( + elevation: 0, + backgroundColor: scheme.surfaceContainer, + indicatorColor: scheme.secondaryContainer, + surfaceTintColor: scheme.surfaceTint, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + ); + + /// SnackBar theme + static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + backgroundColor: scheme.inverseSurface, + contentTextStyle: TextStyle(color: scheme.onInverseSurface), + ); + + /// Progress indicator theme + static ProgressIndicatorThemeData _progressIndicatorTheme(ColorScheme scheme) => + ProgressIndicatorThemeData( + color: scheme.primary, + linearTrackColor: scheme.surfaceContainerHighest, + circularTrackColor: scheme.surfaceContainerHighest, + ); + + /// Switch theme + static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.onPrimary; + } + return scheme.outline; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary; + } + return scheme.surfaceContainerHighest; + }), + ); + + /// Chip theme + static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + backgroundColor: scheme.surfaceContainerLow, + selectedColor: scheme.secondaryContainer, + ); + + /// Divider theme + static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData( + color: scheme.outlineVariant, + thickness: 1, + space: 1, + ); +} diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart new file mode 100644 index 00000000..d74cc1f3 --- /dev/null +++ b/lib/theme/dynamic_color_wrapper.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; + +/// Wrapper widget that provides dynamic color support from device wallpaper +class DynamicColorWrapper extends ConsumerWidget { + final Widget Function(ThemeData light, ThemeData dark, ThemeMode mode) builder; + + const DynamicColorWrapper({ + super.key, + required this.builder, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeSettings = ref.watch(themeProvider); + + return DynamicColorBuilder( + builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { + // Determine which color scheme to use + ColorScheme lightScheme; + ColorScheme darkScheme; + + if (themeSettings.useDynamicColor && lightDynamic != null && darkDynamic != null) { + // Use dynamic colors from wallpaper (Android 12+) + lightScheme = lightDynamic; + darkScheme = darkDynamic; + debugPrint('Using dynamic color from wallpaper'); + } else { + // Fallback to seed color + final seedColor = themeSettings.seedColor; + lightScheme = ColorScheme.fromSeed( + seedColor: seedColor, + brightness: Brightness.light, + ); + darkScheme = ColorScheme.fromSeed( + seedColor: seedColor, + brightness: Brightness.dark, + ); + debugPrint('Using fallback seed color: ${seedColor.toARGB32().toRadixString(16)}'); + } + + // Build themes + final lightTheme = AppTheme.light(dynamicScheme: lightScheme); + final darkTheme = AppTheme.dark(dynamicScheme: darkScheme); + + return builder(lightTheme, darkTheme, themeSettings.themeMode); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 00000000..012e56ea --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1322 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_buffer: + dependency: transitive + description: + name: analyzer_buffer + sha256: f7833bee67c03c37241c67f8741b17cc501b69d9758df7a5a4a13ed6c947be43 + url: "https://pub.dev" + source: hosted + version: "0.1.10" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048" + url: "https://pub.dev" + source: hosted + version: "9.2.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + url: "https://pub.dev" + source: hosted + version: "8.12.1" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c" + url: "https://pub.dev" + source: hosted + version: "1.8.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + ffmpeg_kit_flutter_new: + dependency: "direct main" + description: + name: ffmpeg_kit_flutter_new + sha256: d127635f27e93a7f21f0a14ce0a1a148e80919c402dac4a2118d73bfb17ce841 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + ffmpeg_kit_flutter_platform_interface: + dependency: transitive + description: + name: ffmpeg_kit_flutter_platform_interface + sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee + url: "https://pub.dev" + source: hosted + version: "0.2.1" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: d974b6ba2606371ac71dd94254beefb6fa81185bde0b59bdc1df09885da85fde + url: "https://pub.dev" + source: hosted + version: "10.3.8" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a + url: "https://pub.dev" + source: hosted + version: "17.0.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.dev" + source: hosted + version: "4.7.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + 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: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3 + url: "https://pub.dev" + source: hosted + version: "6.11.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: "direct main" + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: transitive + description: + name: mockito + sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" + url: "https://pub.dev" + source: hosted + version: "5.5.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0" + url: "https://pub.dev" + source: hosted + version: "1.0.0-dev.8" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: e43b1537229cc8f487f09b0c20d15dba840acbadcf5fc6dad7ad5e8ab75950dc + url: "https://pub.dev" + source: hosted + version: "4.0.0+1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.dev" + source: hosted + version: "1.3.7" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..cc8c7dfd --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,78 @@ +name: spotiflac_android +description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.10.0 + +dependencies: + flutter: + sdk: flutter + + # State Management + flutter_riverpod: ^3.1.0 + riverpod_annotation: ^4.0.0 + + # Navigation + go_router: ^17.0.1 + + # Storage & Persistence + shared_preferences: ^2.5.3 + path_provider: ^2.1.5 + + # HTTP & Network + http: ^1.4.0 + dio: ^5.8.0 + + # UI Components + cupertino_icons: ^1.0.8 + cached_network_image: ^3.4.1 + flutter_svg: ^2.1.0 + + # Material Expressive 3 / Dynamic Color + dynamic_color: ^1.7.0 + material_color_utilities: ^0.11.1 + + # Permissions + permission_handler: ^12.0.1 + + # File Picker + file_picker: ^10.3.0 + + # JSON Serialization + json_annotation: ^4.9.0 + + # Utils + url_launcher: ^6.3.1 + device_info_plus: ^12.3.0 + share_plus: ^10.1.4 + + # FFmpeg for audio conversion + ffmpeg_kit_flutter_new: ^4.1.0 + open_filex: ^4.7.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + build_runner: ^2.4.15 + riverpod_generator: ^4.0.0 + json_serializable: ^6.11.2 + flutter_launcher_icons: ^0.14.3 + +flutter_launcher_icons: + android: true + ios: true + image_path: "icon.png" + adaptive_icon_background: "#1a1a2e" + adaptive_icon_foreground: "icon.png" + ios_content_mode: scaleAspectFill + remove_alpha_ios: true + +flutter: + uses-material-design: true + + assets: + - assets/images/ + - assets/icons/ diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh new file mode 100644 index 00000000..cb5ce68d --- /dev/null +++ b/scripts/build_ios.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Build script for iOS XCFramework +# This script compiles the Go backend to XCFramework for iOS +# Must be run on macOS with Xcode installed + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +GO_BACKEND_DIR="$PROJECT_DIR/go_backend" +IOS_DIR="$PROJECT_DIR/ios" +OUTPUT_DIR="$IOS_DIR/Frameworks" + +echo "=== SpotiFLAC iOS Build Script ===" +echo "Project directory: $PROJECT_DIR" +echo "Go backend directory: $GO_BACKEND_DIR" +echo "Output directory: $OUTPUT_DIR" + +# Check if running on macOS +if [[ "$(uname)" != "Darwin" ]]; then + echo "Error: This script must be run on macOS" + exit 1 +fi + +# Check if Go is installed +if ! command -v go &> /dev/null; then + echo "Error: Go is not installed. Please install Go first." + exit 1 +fi + +echo "Go version: $(go version)" + +# Check if gomobile is installed +if ! command -v gomobile &> /dev/null; then + echo "Installing gomobile..." + go install golang.org/x/mobile/cmd/gomobile@latest + go install golang.org/x/mobile/cmd/gobind@latest +fi + +# Initialize gomobile (required for iOS builds) +echo "Initializing gomobile..." +gomobile init + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Navigate to Go backend directory +cd "$GO_BACKEND_DIR" + +# Download dependencies +echo "Downloading Go dependencies..." +go mod download +go mod tidy + +# Build XCFramework for iOS +echo "Building XCFramework for iOS..." +gomobile bind -target=ios -o "$OUTPUT_DIR/Gobackend.xcframework" . + +# Verify output +if [ -d "$OUTPUT_DIR/Gobackend.xcframework" ]; then + echo "✅ Successfully built Gobackend.xcframework" + echo "Output: $OUTPUT_DIR/Gobackend.xcframework" + + # List architectures + echo "" + echo "Architectures included:" + ls -la "$OUTPUT_DIR/Gobackend.xcframework/" +else + echo "❌ Failed to build XCFramework" + exit 1 +fi + +echo "" +echo "=== Build Complete ===" +echo "Next steps:" +echo "1. Open ios/Runner.xcworkspace in Xcode" +echo "2. Add Gobackend.xcframework to the project" +echo "3. Build and run the app" diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 00000000..d722c58a --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:spotiflac_android/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}