commit 3fdcf6d5b7052c3e595a936b507b9544b3f6c0a8 Author: zarzet Date: Thu Jan 1 19:28:15 2026 +0700 Initial commit: SpotiFLAC Android/iOS app 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 00000000..a8723f0a Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..9ada302b Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + 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 00000000..dbc85fc9 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..01a02ad0 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..0ac68e26 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ 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 00000000..dda12f92 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ 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 00000000..0693c329 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ 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 00000000..56c9d37c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ 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 00000000..cd349198 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ 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 00000000..8159a20a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + 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 00000000..ee37f150 Binary files /dev/null and b/assets/images/logo.png differ diff --git a/go_backend/amazon.go b/go_backend/amazon.go new file mode 100644 index 00000000..b2199bff --- /dev/null +++ b/go_backend/amazon.go @@ -0,0 +1,363 @@ +package gobackend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC) +type AmazonDownloader struct { + client *http.Client + regions []string // us, eu regions for DoubleDouble service +} + +// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint +type DoubleDoubleSubmitResponse struct { + Success bool `json:"success"` + ID string `json:"id"` +} + +// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint +type DoubleDoubleStatusResponse struct { + Status string `json:"status"` + FriendlyStatus string `json:"friendlyStatus"` + URL string `json:"url"` + Current struct { + Name string `json:"name"` + Artist string `json:"artist"` + } `json:"current"` +} + +// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service +func NewAmazonDownloader() *AmazonDownloader { + return &AmazonDownloader{ + client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC + regions: []string{"us", "eu"}, // Same regions as PC + } +} + +// GetAvailableAPIs returns list of available DoubleDouble regions +// Uses same service as PC version (doubledouble.top) +func (a *AmazonDownloader) GetAvailableAPIs() []string { + // DoubleDouble service regions (same as PC) + // Format: https://{region}.doubledouble.top + var apis []string + for _, region := range a.regions { + apis = append(apis, fmt.Sprintf("https://%s.doubledouble.top", region)) + } + return apis +} + + +// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC) +// This uses submit → poll → download mechanism +// Internal function - not exported to gomobile +func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) { + var lastError error + + for _, region := range a.regions { + fmt.Printf("[Amazon] Trying region: %s...\n", region) + + // Build base URL for DoubleDouble service + // Decode base64 service URL (same as PC) + serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https:// + serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top + baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) + + // Step 1: Submit download request + encodedURL := url.QueryEscape(amazonURL) + submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) + + req, err := http.NewRequest("GET", submitURL, nil) + if err != nil { + lastError = fmt.Errorf("failed to create request: %w", err) + continue + } + + req.Header.Set("User-Agent", getRandomUserAgent()) + + fmt.Println("[Amazon] Submitting download request...") + resp, err := a.client.Do(req) + if err != nil { + lastError = fmt.Errorf("failed to submit request: %w", err) + continue + } + + if resp.StatusCode != 200 { + resp.Body.Close() + lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode) + continue + } + + var submitResp DoubleDoubleSubmitResponse + if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil { + resp.Body.Close() + lastError = fmt.Errorf("failed to decode submit response: %w", err) + continue + } + resp.Body.Close() + + if !submitResp.Success || submitResp.ID == "" { + lastError = fmt.Errorf("submit request failed") + continue + } + + downloadID := submitResp.ID + fmt.Printf("[Amazon] Download ID: %s\n", downloadID) + + // Step 2: Poll for completion + statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID) + fmt.Println("[Amazon] Waiting for download to complete...") + + maxWait := 300 * time.Second // 5 minutes max wait + elapsed := time.Duration(0) + pollInterval := 3 * time.Second + + for elapsed < maxWait { + time.Sleep(pollInterval) + elapsed += pollInterval + + statusReq, err := http.NewRequest("GET", statusURL, nil) + if err != nil { + continue + } + + statusReq.Header.Set("User-Agent", getRandomUserAgent()) + + statusResp, err := a.client.Do(statusReq) + if err != nil { + fmt.Printf("\r[Amazon] Status check failed, retrying...") + continue + } + + if statusResp.StatusCode != 200 { + statusResp.Body.Close() + fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode) + continue + } + + var status DoubleDoubleStatusResponse + if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil { + statusResp.Body.Close() + fmt.Printf("\r[Amazon] Invalid JSON response, retrying...") + continue + } + statusResp.Body.Close() + + if status.Status == "done" { + fmt.Println("\n[Amazon] Download ready!") + + // Build download URL + fileURL := status.URL + if strings.HasPrefix(fileURL, "./") { + fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:]) + } else if strings.HasPrefix(fileURL, "/") { + fileURL = fmt.Sprintf("%s%s", baseURL, fileURL) + } + + trackName := status.Current.Name + artist := status.Current.Artist + + fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName) + return fileURL, trackName, artist, nil + + } else if status.Status == "error" { + errorMsg := status.FriendlyStatus + if errorMsg == "" { + errorMsg = "Unknown error" + } + lastError = fmt.Errorf("processing failed: %s", errorMsg) + break + } else { + // Still processing + friendlyStatus := status.FriendlyStatus + if friendlyStatus == "" { + friendlyStatus = status.Status + } + fmt.Printf("\r[Amazon] %s...", friendlyStatus) + } + } + + if elapsed >= 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 00000000..ee37f150 Binary files /dev/null and b/icon.png differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..1dc6cf76 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + 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 00000000..dc9ada47 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..7353c41e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ 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 00000000..797d452e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ 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 00000000..6ed2d933 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ 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 00000000..4cd7b009 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ 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 00000000..fe730945 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..321773cd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ 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 00000000..502f463a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ 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 00000000..0ec30343 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ 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 00000000..e9f5fea2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..84ac32ae Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..8953cba0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ 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 00000000..0467bf12 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ 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 00000000..9da19eac Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ 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 00000000..9da19eac Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ 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 00000000..9da19eac Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ 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); + }); +}