diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d74f509..44fde7f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,14 +4,82 @@ jobs: build: name: Build APK runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v1 - - uses: actions/setup-java@v1 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: - java-version: '12.x' - - uses: subosito/flutter-action@v1 + distribution: 'zulu' + java-version: '17' + - uses: subosito/flutter-action@v2 with: - flutter-version: '1.20.1' - - run: flutter build apk --release --target-platform android-arm,android-arm64,android-x64 - - name: Create a Release APK - run: curl -F chat_id="-467829930" -F caption="$(git log -1)" -F document=@"$PWD/build/app/outputs/apk/release/app-release.apk" https://ci.sumanjay.ga/ + flutter-version: '3.35.6' + channel: 'stable' + - run: flutter pub get + - run: flutter build apk --release --split-per-abi + - name: Upload Universal APK Artifact + uses: actions/upload-artifact@v4 + with: + name: app-universal-release + path: build/app/outputs/flutter-apk/app-release.apk + retention-days: 720 + - name: Upload ARM64 APK Artifact + uses: actions/upload-artifact@v4 + with: + name: app-arm64-v8a-release + path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk + retention-days: 720 + - name: Upload ARMv7 APK Artifact + uses: actions/upload-artifact@v4 + with: + name: app-armeabi-v7a-release + path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk + retention-days: 720 + - name: Upload x86_64 APK Artifact + uses: actions/upload-artifact@v4 + with: + name: app-x86_64-release + path: build/app/outputs/flutter-apk/app-x86_64-release.apk + retention-days: 720 + - name: Create GitHub Release + uses: ncipollo/release-action@v1 + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + with: + artifacts: "build/app/outputs/flutter-apk/*.apk" + tag: v1.0.${{ github.run_number }} + name: Release v1.0.${{ github.run_number }} + body: | + ## 🎵 Musify Release + + **Commit:** ${{ github.event.head_commit.message }} + + ### Features: + - 🎵 Background playback with notifications + - 🔒 Lock screen controls + - 📱 Album queue system + - 🔁 Loop/repeat functionality + - ⬇️ Song download with permissions + - 📐 Responsive UI for all screen sizes + - 🚀 Dart 3 Compatible + + ### Available APKs: + + **📦 Universal APK** (`app-release.apk`) - ~65MB + - Works on all devices (recommended if unsure) + + **📱 ARM64 APK** (`app-arm64-v8a-release.apk`) - ~23MB + - For modern devices (2019+): Pixel, Samsung Galaxy S10+, OnePlus 7+, etc. + + **📱 ARMv7 APK** (`app-armeabi-v7a-release.apk`) - ~21MB + - For older devices: Samsung Galaxy S6-S9, older budget phones + + **💻 x86_64 APK** (`app-x86_64-release.apk`) - ~25MB + - For emulators and Intel-based devices + + ### How to choose: + - **Not sure?** → Download Universal APK + - **Want smaller size?** → Check your device's CPU architecture in Settings > About Phone + token: ${{ secrets.GITHUB_TOKEN }} + allowUpdates: true + makeLatest: true diff --git a/README.md b/README.md index 9dd3ede..bd5fcd6 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@

- GitHub Readme Stats + Musify Logo

Musify

Music Streaming and Downloading app made in Flutter!

-

Show some :heart: and :star: the Repo

+

Show some ❤️ and ⭐ the Repo

--- -[![made-with-flutter](https://img.shields.io/badge/Made%20with-Flutter-1f425f.svg)](https://flutter.dev/) ![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https://github.com/Harsh-23/Musify&title=Views) ![Release](https://img.shields.io/github/v/release/Harsh-23/Musify) ![Stars](https://img.shields.io/github/stars/Harsh-23/Musify) ![Forks](https://img.shields.io/github/forks/Harsh-23/Musify) ![Contributors](https://img.shields.io/github/contributors/Harsh-23/Musify) +[![made-with-flutter](https://img.shields.io/badge/Made%20with-Flutter-1f425f.svg)](https://flutter.dev/) ![Release](https://img.shields.io/github/v/release/kunal7236/Musify) ![Stars](https://img.shields.io/github/stars/kunal7236/Musify) ![Forks](https://img.shields.io/github/forks/kunal7236/Musify) --- @@ -15,29 +15,117 @@

Online Song Search :mag:
Streaming Support :musical_note:
+ Background Playback :headphones:
+ Lock Screen Controls :lock:
Offline Download Support :arrow_down:
- 320Kbps m4a/mp3 Format :fire:
+ 320Kbps m4a Format :fire:
ID3 Tags Attached :notes:
Lyrics Support :pencil:
+ Loop/Repeat Mode :repeat:
+ Album Queue System :cd:
+ Responsive UI :iphone:
--- -

Screenshots

+

Download

+

+ +--- + +

Which APK Should I Download?

- Homepage | Now Playing -:-------------------------:|:-------------------------: -![](https://telegra.ph/file/902d4e7951a58ffd3d31c.png) | ![](https://telegra.ph/file/912e91bc24753c3d8fc46.png) +

+ We provide multiple APK versions to give you the best experience! 🎯 +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
APK TypeFile SizeBest For
📦 Universal APK
app-release.apk
~61 MB + ✅ Recommended for most users
+ • Works on ALL Android devices
+ • Don't know your phone's specs? Choose this!
+ • Slightly larger but guaranteed to work +
📱 ARM64 APK
app-arm64-v8a-release.apk
~23 MB + ✅ For Modern Phones (2019 onwards)
+ • Samsung Galaxy S10, S20, S21, S22, S23, S24
+ • Google Pixel 3, 4, 5, 6, 7, 8
+ • OnePlus 7, 8, 9, 10, 11, 12
+ • Xiaomi Redmi Note 8 Pro and newer
+ • Realme 5 Pro and newer
+ • Most phones from 2019 or later +
📱 ARMv7 APK
app-armeabi-v7a-release.apk
~21 MB + ✅ For Older Phones (2014-2019)
+ • Samsung Galaxy S6, S7, S8, S9
+ • Older budget smartphones
+ • Most 32-bit Android devices +
💻 x86_64 APK
app-x86_64-release.apk
~25 MB + ✅ For Special Cases
+ • Android emulators (BlueStacks, etc.)
+ • Intel/AMD based Android devices (rare)
+ • Some tablets +
- Lyrics | About -:-------------------------:|:-------------------------: -![](https://telegra.ph/file/5182d60b3483c5abb60d9.png) | ![](https://telegra.ph/file/f54155f803bbfe9271321.png) +

🤔 How to Check Your Phone's Architecture

+

+ Method 1 (Easy): Just download the Universal APK - it works on everything!

+ Method 2 (For smaller file size):
+ 1. Install CPU-Z app from Play Store
+ 2. Open it and check the "SoC" or "CPU" tab
+ 3. Look for the architecture (ARM64/ARMv7/x86)

+ Method 3 (Settings):
+ Go to Settings → About Phone → Look for "Processor" or "Chipset" +

+ +

💡 Quick Decision Guide

+

+ 🟢 Phone bought after 2019? → Download ARM64 APK
+ 🟡 Phone bought between 2014-2019? → Download ARMv7 APK
+ 🔵 Not sure or installation fails? → Download Universal APK
+ 🟣 Using emulator? → Download x86_64 APK +

--- -

Download

-

+

Credits

+

+ This project is a fork of Musify by Harsh-23.
+ Special thanks to the original author and all contributors for their work.

+ Enhancements in this fork:
+ • Background playback with audio_service
+ • Lock screen & notification controls
+ • Album-based queue system
+ • Loop/Repeat functionality
+ • Responsive UI for all screen sizes
+ • Updated dependencies & build system
+ • Dart 3.0 Compatibility
+ • and many more..
+

--- +

License

-

Licensed under GPL-3.0 License

+

Licensed under GPL-3.0 License

diff --git a/android/app/build.gradle b/android/app/build.gradle index 58881a2..f8a3bb7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -21,44 +27,65 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { - compileSdkVersion 28 + namespace 'me.musify' + compileSdk 36 - sourceSets { - main.java.srcDirs += 'src/main/kotlin' + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = '11' + freeCompilerArgs += ['-Xlint:-options'] + } + + // Suppress Java compilation warnings for obsolete options + tasks.withType(JavaCompile) { + options.compilerArgs += ['-Xlint:-options'] } - lintOptions { - disable 'InvalidPackage' + sourceSets { + main.java.srcDirs += 'src/main/kotlin' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "me.musify" - minSdkVersion 16 - targetSdkVersion 28 + minSdkVersion flutter.minSdkVersion // Required for audio_service + targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } + // Enable APK splits by ABI to generate separate APKs for different architectures + splits { + abi { + enable true + reset() + include 'armeabi-v7a', 'arm64-v8a', 'x86_64' + universalApk true // Generate universal APK as well + } + } + buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug + + // Disable minification - audio_service notifications don't work with R8/ProGuard + // This ensures background playback and lock screen controls work properly + minifyEnabled false shrinkResources false + + // ProGuard rules kept for future reference if needed + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } } -flutter { - source '../..' -} - dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0" } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..83f7802 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,152 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. + +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Critical attributes for reflection and annotations +-keepattributes *Annotation* +-keepattributes Signature +-keepattributes InnerClasses +-keepattributes EnclosingMethod + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Flutter specific rules +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } +-dontwarn io.flutter.embedding.** + +# Flutter embedding +-keep class io.flutter.embedding.** { *; } + +# audio_service plugin (CRITICAL - prevent R8 from stripping these classes) +-keep class com.ryanheise.audioservice.** { *; } +-keepclassmembers class com.ryanheise.audioservice.** { *; } +-dontwarn com.ryanheise.audioservice.** + +# Keep the AudioServiceActivity specifically +-keep public class * extends com.ryanheise.audioservice.AudioServiceActivity + +# MediaSession and MediaBrowser (required for notifications and lock screen) +-keep class android.support.v4.media.** { *; } +-keep interface android.support.v4.media.** { *; } +-keep class androidx.media.** { *; } +-keep interface androidx.media.** { *; } +-keepclassmembers class androidx.media.** { *; } +-dontwarn android.support.v4.media.** +-dontwarn androidx.media.** + +# MediaSession compatibility +-keep class androidx.media.session.** { *; } +-keep class android.support.v4.media.session.** { *; } + +# Keep all Service classes (critical for background playback) +-keep public class * extends android.app.Service +-keep public class * extends androidx.media.MediaBrowserServiceCompat + +# Keep BroadcastReceiver for media buttons +-keep public class * extends android.content.BroadcastReceiver + +# Keep MainActivity (extends AudioServiceActivity) +-keep class me.musify.MainActivity { *; } + +# Keep all classes referenced in AndroidManifest.xml +-keep class * extends android.app.Activity +-keepclassmembers class * extends android.app.Activity { + public void *(android.view.View); +} + +# just_audio plugin +-keep class com.ryanheise.just_audio.** { *; } +-dontwarn com.ryanheise.just_audio.** + +# ExoPlayer (used by just_audio) +-keep class com.google.android.exoplayer2.** { *; } +-dontwarn com.google.android.exoplayer2.** + +# Notification and notification channels (Android 8.0+) +-keep class android.app.Notification { *; } +-keep class android.app.NotificationChannel { *; } +-keep class android.app.NotificationManager { *; } +-keep class androidx.core.app.NotificationCompat { *; } +-keep class androidx.core.app.NotificationCompat$* { *; } + +# Keep notification builder and style classes +-keep class androidx.core.app.NotificationCompat$Builder { *; } +-keep class androidx.core.app.NotificationCompat$MediaStyle { *; } +-keep class androidx.media.app.NotificationCompat$MediaStyle { *; } + +# HTTP and networking +-keep class okhttp3.** { *; } +-keep class retrofit2.** { *; } +-dontwarn okhttp3.** +-dontwarn retrofit2.** + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} + +# Keep enums +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +# Keep Parcelable implementations +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} + +# Keep Serializable classes +-keepnames class * implements java.io.Serializable +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# Remove logging +-assumenosideeffects class android.util.Log { + public static boolean isLoggable(java.lang.String, int); + public static int v(...); + public static int i(...); + public static int w(...); + public static int d(...); + public static int e(...); +} + +# Flutter embedding +-keep class io.flutter.embedding.** { *; } + +# Gson (if used) +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn sun.misc.** +-keep class com.google.gson.** { *; } +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# General optimizations +-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* +-optimizationpasses 5 +-allowaccessmodification +-dontpreverify +-dontusemixedcaseclassnames +-dontskipnonpubliclibraryclasses +-verbose \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fd32be2..64bbb75 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,23 @@ - + - - + + - + + + - + - - + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/me/musify/MainActivity.kt b/android/app/src/main/kotlin/me/musify/MainActivity.kt index 2aa6a42..7c11492 100644 --- a/android/app/src/main/kotlin/me/musify/MainActivity.kt +++ b/android/app/src/main/kotlin/me/musify/MainActivity.kt @@ -1,6 +1,6 @@ package me.musify -import io.flutter.embedding.android.FlutterActivity +import com.ryanheise.audioservice.AudioServiceActivity -class MainActivity: FlutterActivity() { +class MainActivity: AudioServiceActivity() { } diff --git a/android/app/src/main/res/drawable/ic_action_pause.xml b/android/app/src/main/res/drawable/ic_action_pause.xml new file mode 100644 index 0000000..d0a7951 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_action_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_action_play_arrow.xml b/android/app/src/main/res/drawable/ic_action_play_arrow.xml new file mode 100644 index 0000000..c85bcf3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_action_play_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_action_skip_next.xml b/android/app/src/main/res/drawable/ic_action_skip_next.xml new file mode 100644 index 0000000..0406e1c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_action_skip_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_action_skip_previous.xml b/android/app/src/main/res/drawable/ic_action_skip_previous.xml new file mode 100644 index 0000000..a5d7ca7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_action_skip_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_action_stop.xml b/android/app/src/main/res/drawable/ic_action_stop.xml new file mode 100644 index 0000000..172430c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_action_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..30e509a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/build.gradle b/android/build.gradle index 3100ad2..ad89b89 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,20 +1,7 @@ -buildscript { - ext.kotlin_version = '1.3.50' - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() - jcenter() + mavenCentral() } } @@ -26,6 +13,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register("clean", Delete) { + delete rootProject.layout.buildDirectory } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 296b146..3c85cfe 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 5a2f14f..161f9ed 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,15 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -def plugins = new Properties() -def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') -if (pluginsFile.exists()) { - pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } } -plugins.each { name, path -> - def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() - include ":$name" - project(":$name").projectDir = pluginDirectory +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.6.1" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } + +include ':app' diff --git a/assets/image.png b/assets/image.png new file mode 100644 index 0000000..3e6c6f6 Binary files /dev/null and b/assets/image.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/Flutter/ephemeral/flutter_lldb_helper.py b/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 0000000..a88caf9 --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/ios/Flutter/ephemeral/flutter_lldbinit b/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 0000000..e3ba6fb --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/lib/API/des_helper.dart b/lib/API/des_helper.dart new file mode 100644 index 0000000..c18952c --- /dev/null +++ b/lib/API/des_helper.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:dart_des/dart_des.dart'; + +/// DES helper class that uses dart_des (pyDES port) for exact Python behavior +class DESHelper { + static const String _key = "38346591"; + + /// Main decryption function using dart_des (pyDES port) - exactly matching Python behavior + static String decryptUrl(String encryptedUrl) { + try { + if (encryptedUrl.isEmpty) return ""; + + debugPrint('🔐 DES Decrypting with dart_des (pyDES port): $encryptedUrl'); + + // Step 1: Base64 decode (matching Python base64.b64decode) + List encryptedBytes; + try { + encryptedBytes = base64.decode(encryptedUrl); + debugPrint('🔍 Base64 decoded ${encryptedBytes.length} bytes'); + } catch (e) { + debugPrint('❌ Base64 decode failed: $e'); + return ""; + } + + // Step 2: Use dart_des (pyDES port) for exact Python behavior + try { + // Create DES instance exactly like Python: pyDes.des("38346591", pyDes.ECB, pad=None, padmode=pyDes.PAD_PKCS5) + DES desDecryptor = DES( + key: _key.codeUnits, // "38346591" as bytes + mode: DESMode.ECB, // ECB mode like Python + // Note: dart_des handles padding automatically + ); + + debugPrint('🔧 DES decryptor initialized (ECB mode, key: $_key)'); + + // Decrypt the bytes (exactly like Python des_cipher.decrypt()) + List decryptedBytes = desDecryptor.decrypt(encryptedBytes); + + debugPrint( + '✅ DES decryption successful, got ${decryptedBytes.length} bytes'); + + // Convert to string and extract URL + String decryptedText = + utf8.decode(decryptedBytes, allowMalformed: true); + + debugPrint( + '🔍 Decrypted text: ${decryptedText.length > 100 ? decryptedText.substring(0, 100) : decryptedText}...'); + + // Apply transformations (as per your Python helper.py) + String processedUrl = _extractValidUrl(decryptedText, "dart_des DES"); + + if (processedUrl.isNotEmpty) { + // Apply quality transformation + processedUrl = processedUrl.replaceAll("_96.mp4", "_320.mp4"); + debugPrint('✅ Successfully decrypted URL: $processedUrl'); + return processedUrl; + } + } catch (e) { + debugPrint('❌ dart_des DES decryption failed: $e'); + } + + } catch (e) { + debugPrint('❌ DES decryption failed: $e'); + return ""; + } + return ""; + } + + /// Extract valid URL from decrypted text + static String _extractValidUrl(String text, String approach) { + try { + if (text.isEmpty) return ""; + + debugPrint( + '🔍 $approach checking: ${text.length > 50 ? text.substring(0, 50) : text}...'); + + // Clean the text first + text = text.replaceAll( + RegExp(r'[^\x20-\x7E]'), ''); // Remove non-printable chars + + // Look for complete HTTP URLs + RegExp httpPattern = + RegExp(r'https?://[^\s]+\.mp4', caseSensitive: false); + Match? httpMatch = httpPattern.firstMatch(text); + + if (httpMatch != null) { + String url = httpMatch.group(0)!; + debugPrint('✅ $approach found HTTP URL: $url'); + return url; + } + + // Look for saavncdn patterns and construct URL + if (text.contains('saavncdn') || text.contains('saavn')) { + debugPrint('🔍 $approach found saavn pattern, extracting...'); + + // Try to extract path parts + List parts = text.split(RegExp(r'[\s\x00-\x1F\x7F-\xFF]+')); + for (String part in parts) { + if (part.contains('.mp4') && part.length > 10) { + String url = part; + if (!url.startsWith('http')) { + url = 'https://aac.saavncdn.com/' + + url.replaceFirst(RegExp(r'^[^\w]*'), ''); + } + + if (url.contains('saavncdn.com') && url.contains('.mp4')) { + debugPrint('✅ $approach constructed URL: $url'); + return url; + } + } + } + } + + // Look for URL patterns without protocol + RegExp pathPattern = RegExp(r'[a-zA-Z0-9/]+\.mp4', caseSensitive: false); + Match? pathMatch = pathPattern.firstMatch(text); + + if (pathMatch != null) { + String path = pathMatch.group(0)!; + if (path.length > 10) { + String url = 'https://aac.saavncdn.com/' + path; + debugPrint('✅ $approach constructed from path: $url'); + return url; + } + } + + return ""; + } catch (e) { + debugPrint('❌ $approach URL extraction failed: $e'); + return ""; + } + } +} diff --git a/lib/API/saavn.dart b/lib/API/saavn.dart index bdd8cce..d369bc2 100644 --- a/lib/API/saavn.dart +++ b/lib/API/saavn.dart @@ -1,142 +1,347 @@ import 'dart:convert'; - -import 'package:des_plugin/des_plugin.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'des_helper.dart'; List searchedList = []; List topSongsList = []; String kUrl = "", - checker, + checker = "", image = "", title = "", album = "", artist = "", - lyrics, - has_320, - rawkUrl; -String key = "38346591"; -String decrypt = ""; - -Future fetchSongsList(searchQuery) async { - String searchUrl = - "https://www.jiosaavn.com/api.php?app_version=5.18.3&api_version=4&readable_version=5.18.3&v=79&_format=json&query=" + - searchQuery + - "&__call=autocomplete.get"; - var res = await http.get(searchUrl, headers: {"Accept": "application/json"}); - var resEdited = (res.body).split("-->"); - var getMain = json.decode(resEdited[1]); - - searchedList = getMain["songs"]["data"]; - for (int i = 0; i < searchedList.length; i++) { - searchedList[i]['title'] = searchedList[i]['title'] - .toString() - .replaceAll("&", "&") - .replaceAll("'", "'") - .replaceAll(""", "\""); - - searchedList[i]['more_info']['singers'] = searchedList[i]['more_info'] - ['singers'] - .toString() - .replaceAll("&", "&") - .replaceAll("'", "'") - .replaceAll(""", "\""); - } - return searchedList; + lyrics = "", + has_lyrics = "", + has_320 = "", + albumId = "", + rawkUrl = ""; + +// API Endpoints (exactly as in your Python endpoints.py) +const String searchBaseUrl = + "https://www.jiosaavn.com/api.php?__call=autocomplete.get&_format=json&_marker=0&cc=in&includeMetaTags=1&query="; +const String songDetailsBaseUrl = + "https://www.jiosaavn.com/api.php?__call=song.getDetails&cc=in&_marker=0%3F_marker%3D0&_format=json&pids="; +const String albumDetailsBaseUrl = + "https://www.jiosaavn.com/api.php?__call=content.getAlbumDetails&_format=json&cc=in&_marker=0%3F_marker%3D0&albumid="; +const String playlistDetailsBaseUrl = + "https://www.jiosaavn.com/api.php?__call=playlist.getDetails&_format=json&cc=in&_marker=0%3F_marker%3D0&listid="; +const String lyricsBaseUrl = + "https://www.jiosaavn.com/api.php?__call=lyrics.getLyrics&ctx=web6dot0&api_version=4&_format=json&_marker=0%3F_marker%3D0&lyrics_id="; + +// DES Decryption function (Python pyDes equivalent implementation) +String decryptUrl(String encryptedUrl) { + return DESHelper.decryptUrl(encryptedUrl); } -Future topSongs() async { - String topSongsUrl = - "https://www.jiosaavn.com/api.php?__call=webapi.get&token=8MT-LQlP35c_&type=playlist&p=1&n=20&includeMetaTags=0&ctx=web6dot0&api_version=4&_format=json&_marker=0"; - var songsListJSON = - await http.get(topSongsUrl, headers: {"Accept": "application/json"}); - var songsList = json.decode(songsListJSON.body); - topSongsList = songsList["list"]; - for (int i = 0; i < topSongsList.length; i++) { - topSongsList[i]['title'] = topSongsList[i]['title'] - .toString() - .replaceAll("&", "&") - .replaceAll("'", "'") - .replaceAll(""", "\""); - topSongsList[i]["more_info"]["artistMap"]["primary_artists"][0]["name"] = - topSongsList[i]["more_info"]["artistMap"]["primary_artists"][0]["name"] +// Format string function (as per your Python helper.py) +String formatString(String input) { + return input + .replaceAll(""", "'") + .replaceAll("&", "&") + .replaceAll("'", "'"); +} + +// Search for songs (exactly as per your Python jiosaavn.py search_for_song function) +Future fetchSongsList(String searchQuery) async { + try { + String searchUrl = searchBaseUrl + Uri.encodeComponent(searchQuery); + debugPrint('🔍 Search URL: $searchUrl'); + + var response = await http.get(Uri.parse(searchUrl)); + + if (response.statusCode != 200) { + throw Exception('Search API failed with status: ${response.statusCode}'); + } + + // Handle response format (as per your Python code) + String responseBody = response.body; + if (responseBody.contains("-->")) { + var resEdited = responseBody.split("-->"); + if (resEdited.length < 2) { + throw Exception('Invalid search API response format'); + } + responseBody = resEdited[1]; + } + + // Parse JSON response + dynamic responseJson = json.decode(responseBody); + + // Process songs data (as per your Python logic) + List songResponse = responseJson['songs']['data']; + searchedList = songResponse; + + // Format song data (as per your Python helper.py) + for (int i = 0; i < searchedList.length; i++) { + searchedList[i]['title'] = formatString(searchedList[i]['title'] ?? ''); + searchedList[i]['music'] = formatString(searchedList[i]['music'] ?? ''); + searchedList[i]['singers'] = + formatString(searchedList[i]['singers'] ?? ''); + searchedList[i]['album'] = formatString(searchedList[i]['album'] ?? ''); + + // Enhance image quality + if (searchedList[i]['image'] != null) { + searchedList[i]['image'] = searchedList[i]['image'] .toString() - .replaceAll("&", "&") - .replaceAll("'", "'") - .replaceAll(""", "\""); - topSongsList[i]['image'] = - topSongsList[i]['image'].toString().replaceAll("150x150", "500x500"); + .replaceAll("150x150", "500x500"); + } + } + + return searchedList; + } catch (e) { + debugPrint('❌ Search failed: $e'); + return []; } - return topSongsList; } -Future fetchSongDetails(songId) async { - String songUrl = - "https://www.jiosaavn.com/api.php?app_version=5.18.3&api_version=4&readable_version=5.18.3&v=79&_format=json&__call=song.getDetails&pids=" + - songId; - var res = await http.get(songUrl, headers: {"Accept": "application/json"}); - var resEdited = (res.body).split("-->"); - var getMain = json.decode(resEdited[1]); - - title = (getMain[songId]["title"]) - .toString() - .split("(")[0] - .replaceAll("&", "&") - .replaceAll("'", "'") - .replaceAll(""", "\""); - image = (getMain[songId]["image"]).replaceAll("150x150", "500x500"); - album = (getMain[songId]["more_info"]["album"]) - .toString() - .replaceAll(""", "\"") - .replaceAll("'", "'") - .replaceAll("&", "&"); +// Get song details (exactly as per your Python jiosaavn.py get_song+get_lyrics function) +Future fetchSongDetails(String songId) async { + try { + debugPrint('🎵 Getting song details for ID: $songId'); + + // Use the exact API endpoint from your Python endpoints.py + String songDetailsUrl = songDetailsBaseUrl + songId; + + debugPrint('📡 API URL: $songDetailsUrl'); + + var response = await http.get(Uri.parse(songDetailsUrl)); + + if (response.statusCode != 200) { + debugPrint('❌ API failed with status: ${response.statusCode}'); + checker = "something went wrong"; + return false; + } + + // Handle response format (as per your Python code) + String responseBody = response.body; + if (responseBody.contains("-->")) { + var resEdited = responseBody.split("-->"); + if (resEdited.length < 2) { + debugPrint('❌ Invalid API response format'); + checker = "something went wrong"; + return false; + } + responseBody = resEdited[1]; + } + + // Parse JSON + dynamic songResponse = json.decode(responseBody); + + if (!songResponse.containsKey(songId)) { + debugPrint('❌ Song ID not found in response'); + checker = "something went wrong"; + return false; + } + + var songData = songResponse[songId]; + + // Extract song information (following your Python format_song function) + title = formatString(songData["song"] ?? ""); + album = formatString(songData["album"] ?? ""); + artist = formatString(songData["singers"] ?? ""); + albumId = songData["albumid"] ?? ""; + has_lyrics = songData["has_lyrics"] ?? "false"; + image = + (songData["image"] ?? "").toString().replaceAll("150x150", "500x500"); + has_320 = songData["320kbps"] ?? "false"; + + debugPrint('🎼 Song: $title'); + debugPrint('🎤 Artist: $artist'); + debugPrint('💿 Album: $album'); + debugPrint('💿 Album ID: $albumId'); + debugPrint('🔊 320kbps: $has_320'); + debugPrint('🔊 has lyrics: $has_lyrics'); + + if (has_lyrics == "true") { + String lyricsUrl = lyricsBaseUrl + songId; + debugPrint('📡 API URL: $lyricsUrl'); + var resLyrics = await http.get(Uri.parse(lyricsUrl)); + if (resLyrics.statusCode != 200) { + debugPrint('❌ API failed with status: ${resLyrics.statusCode}'); + checker = "something went wrong"; + } + dynamic lyricsResponse = json.decode(resLyrics.body); + lyrics = lyricsResponse["lyrics"]; + lyrics = lyrics.replaceAll("
", "\n"); + } + + // Debug: Print all available fields in songData + debugPrint('🔍 Available songData fields: ${songData.keys.toList()}'); + if (songData["more_info"] != null) { + debugPrint( + '🔍 Available more_info fields: ${songData["more_info"].keys.toList()}'); + } + + // URL processing (following your Python helper.py format_song logic) + String mediaUrl = ""; + String encryptedMediaUrl = songData["encrypted_media_url"] ?? ""; + + try { + // Try to decrypt encrypted_media_url (main method from your Python code) + if (encryptedMediaUrl.isNotEmpty) { + debugPrint('🔐 Found encrypted_media_url, decrypting...'); + mediaUrl = decryptUrl(encryptedMediaUrl); + if (mediaUrl.isNotEmpty) { + debugPrint('✅ Successfully decrypted URL'); + + // Apply quality selection (as per your Python logic) + if (has_320 != "true") { + mediaUrl = mediaUrl.replaceAll("_320.mp4", "_160.mp4"); + debugPrint('📶 Using 160kbps quality'); + } else { + debugPrint('📶 Using 320kbps quality'); + } + } + } + } catch (e) { + debugPrint('❌ Decryption failed: $e'); + } + + + if (mediaUrl.isEmpty) { + debugPrint('❌ Failed to get any working media URL'); + checker = "something went wrong"; + return false; + } + + kUrl = mediaUrl; + rawkUrl = mediaUrl; + + debugPrint('🎯 Final media URL: $kUrl'); + checker = "successfully done"; + return true; + } catch (e) { + debugPrint('❌ fetchSongDetails failed: $e'); + checker = "something went wrong"; + return false; + } +} + +// Top songs function +Future topSongs() async { try { - artist = - getMain[songId]['more_info']['artistMap']['primary_artists'][0]['name']; + String topSongsUrl = + "https://www.jiosaavn.com/api.php?__call=webapi.get&token=8MT-LQlP35c_&type=playlist&p=1&n=20&includeMetaTags=0&ctx=web6dot0&api_version=4&_format=json&_marker=0"; + + // Add headers to mimic browser behavior and handle SSL issues + Map headers = { + "Accept": "application/json", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + "Pragma": "no-cache" + }; + + var songsListJSON = + await http.get(Uri.parse(topSongsUrl), headers: headers); + + if (songsListJSON.statusCode == 200) { + var songsList = json.decode(songsListJSON.body); + topSongsList = songsList["list"]; + + for (int i = 0; i < topSongsList.length; i++) { + topSongsList[i]['title'] = + formatString(topSongsList[i]['title'].toString()); + + if (topSongsList[i]["more_info"]["artistMap"]["primary_artists"] != + null && + topSongsList[i]["more_info"]["artistMap"]["primary_artists"] + .length > + 0) { + topSongsList[i]["more_info"]["artistMap"]["primary_artists"][0] + ["name"] = formatString(topSongsList[i]["more_info"]["artistMap"] + ["primary_artists"][0]["name"] + .toString()); + } + + topSongsList[i]['image'] = topSongsList[i]['image'] + .toString() + .replaceAll("150x150", "500x500"); + } + + debugPrint('✅ Successfully fetched ${topSongsList.length} top songs'); + return topSongsList; + } else { + debugPrint( + '❌ Top songs API failed with status: ${songsListJSON.statusCode}'); + return []; + } } catch (e) { - artist = "-"; + debugPrint('❌ Failed to fetch top songs: $e'); + // Return empty list instead of throwing error + return []; } - print(getMain[songId]["more_info"]["has_lyrics"]); - if (getMain[songId]["more_info"]["has_lyrics"] == "true") { - String lyricsUrl = - "https://www.jiosaavn.com/api.php?__call=lyrics.getLyrics&lyrics_id=" + - songId + - "&ctx=web6dot0&api_version=4&_format=json"; - var lyricsRes = - await http.get(lyricsUrl, headers: {"Accept": "application/json"}); - var lyricsEdited = (lyricsRes.body).split("-->"); - var fetchedLyrics = json.decode(lyricsEdited[1]); - lyrics = fetchedLyrics["lyrics"].toString().replaceAll("
", "\n"); +} + +bool isVpnConnected() { + if (kUrl.isNotEmpty) { + return kUrl.contains('150x150'); } else { - lyrics = "null"; - String lyricsApiUrl = - "https://musifydev.vercel.app/lyrics/" + artist + "/" + title; - var lyricsApiRes = - await http.get(lyricsApiUrl, headers: {"Accept": "application/json"}); - var lyricsResponse = json.decode(lyricsApiRes.body); - if (lyricsResponse['status'] == true && lyricsResponse['lyrics'] != null) { - lyrics = lyricsResponse['lyrics']; - } + return false; } +} + +// Get album details (for fetching album song IDs) +// Returns minimal info - just song IDs and basic metadata +// Actual song details (including playback URLs) will be fetched on-demand +Future>> fetchAlbumDetails(String albumId) async { + try { + debugPrint('💿 Fetching album details for ID: $albumId'); - has_320 = getMain[songId]["more_info"]["320kbps"]; - kUrl = await DesPlugin.decrypt( - key, getMain[songId]["more_info"]["encrypted_media_url"]); - - rawkUrl = kUrl; - - final client = http.Client(); - final request = http.Request('HEAD', Uri.parse(kUrl)) - ..followRedirects = false; - final response = await client.send(request); - print(response); - kUrl = (response.headers['location']); - artist = (getMain[songId]["more_info"]["artistMap"]["primary_artists"][0] - ["name"]) - .toString() - .replaceAll(""", "\"") - .replaceAll("'", "'") - .replaceAll("&", "&"); - debugPrint(kUrl); + String albumUrl = albumDetailsBaseUrl + albumId; + debugPrint('📡 Album API URL: $albumUrl'); + + var response = await http.get(Uri.parse(albumUrl)); + + if (response.statusCode != 200) { + debugPrint('❌ Album API failed with status: ${response.statusCode}'); + return []; + } + + // Handle response format + String responseBody = response.body; + if (responseBody.contains("-->")) { + var resEdited = responseBody.split("-->"); + if (resEdited.length < 2) { + debugPrint('❌ Invalid album API response format'); + return []; + } + responseBody = resEdited[1]; + } + + // Parse JSON + dynamic albumResponse = json.decode(responseBody); + + // Check if songs array exists + if (albumResponse == null || albumResponse['songs'] == null) { + debugPrint('❌ No songs found in album'); + return []; + } + + List albumSongs = albumResponse['songs']; + List> songIds = []; + + debugPrint('💿 Album: ${albumResponse['title'] ?? 'Unknown'}'); + debugPrint('💿 Found ${albumSongs.length} songs in album'); + + // Extract just the song IDs and basic info + // Don't filter anything - let the actual fetchSongDetails handle DRM/availability + for (var song in albumSongs) { + songIds.add({ + 'id': song['id'] ?? '', + 'title': formatString(song['song'] ?? ''), + 'artist': formatString(song['singers'] ?? ''), + 'image': + (song['image'] ?? '').toString().replaceAll('150x150', '500x500'), + }); + } + + debugPrint('✅ Extracted ${songIds.length} song IDs from album'); + return songIds; + } catch (e) { + debugPrint('❌ fetchAlbumDetails failed: $e'); + return []; + } } diff --git a/lib/core/constants/app_colors.dart b/lib/core/constants/app_colors.dart new file mode 100644 index 0000000..6b07555 --- /dev/null +++ b/lib/core/constants/app_colors.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +/// Centralized color constants for the Musify app +/// Eliminates color code duplication across the application +class AppColors { + // Private constructor to prevent instantiation + AppColors._(); + + // Primary Colors + static const Color primary = Color(0xff384850); + static const Color primaryDark = Color(0xff263238); + static const Color primaryLight = Color(0xff4db6ac); + + // Accent Colors + static const Color accent = Color(0xff61e88a); + static const Color accentSecondary = Color(0xff4db6ac); + + // Background Colors + static const Color backgroundPrimary = Color(0xff384850); + static const Color backgroundSecondary = Color(0xff263238); + static const Color backgroundTertiary = Color(0xff1c252a); + static const Color backgroundModal = Color(0xff212c31); + + // UI Colors + static const Color cardBackground = Colors.black12; + static const Color textPrimary = Colors.white; + static const Color textSecondary = Colors.white70; + static const Color iconPrimary = Colors.white; + + // Status Colors + static const Color success = Color(0xff61e88a); + static const Color warning = Colors.orange; + static const Color error = Colors.red; + static const Color info = Color(0xff4db6ac); + + // Gradients + static const LinearGradient primaryGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + backgroundPrimary, + backgroundSecondary, + backgroundSecondary, + ], + ); + + static const LinearGradient accentGradient = LinearGradient( + colors: [ + accentSecondary, + accent, + ], + ); + + static const LinearGradient buttonGradient = LinearGradient( + colors: [ + accentSecondary, + accent, + ], + ); + + // Transparency variations + static Color get primaryWithOpacity => primary.withValues(alpha: 0.8); + static Color get accentWithOpacity => accent.withValues(alpha: 0.8); + static Color get backgroundWithOpacity => + backgroundPrimary.withValues(alpha: 0.9); +} + +/// Legacy color support for backward compatibility +/// @deprecated Use AppColors instead +const Color accent = AppColors.accent; diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..cef58e0 --- /dev/null +++ b/lib/core/constants/app_constants.dart @@ -0,0 +1,65 @@ +/// Application-wide constants +/// Centralizes magic numbers, strings, and configuration values +class AppConstants { + // Private constructor to prevent instantiation + AppConstants._(); + + // App Metadata + static const String appName = 'Musify'; + static const String appVersion = '1.0.0'; + + // API Configuration + static const Duration apiTimeout = Duration(seconds: 30); + static const Duration searchDebounceDelay = Duration(milliseconds: 500); + static const int maxSearchResults = 50; + static const int maxTopSongs = 20; + + // UI Constants + static const double defaultPadding = 12.0; + static const double smallPadding = 8.0; + static const double largePadding = 20.0; + static const double borderRadius = 8.0; + static const double buttonBorderRadius = 18.0; + static const double cardBorderRadius = 10.0; + + // Player Configuration + static const double playerControlSize = 40.0; + static const double albumArtSize = 350.0; + static const double miniPlayerHeight = 75.0; + static const double progressBarHeight = 4.0; + + // Image Configuration + static const double thumbnailSize = 60.0; + static const int imageCacheWidth = 500; + static const int imageCacheHeight = 500; + static const int thumbnailCacheSize = 150; + + // Audio Configuration + static const double defaultVolume = 1.0; + static const double volumeStep = 0.1; + + // Animation Durations + static const Duration shortAnimation = Duration(milliseconds: 200); + static const Duration mediumAnimation = Duration(milliseconds: 300); + static const Duration longAnimation = Duration(milliseconds: 500); + + // Error Messages + static const String noInternetError = 'No internet connection'; + static const String songLoadError = 'Failed to load song'; + static const String searchError = 'Search failed'; + static const String permissionError = 'Permission denied'; + + // Success Messages + static const String downloadSuccess = 'Downloaded successfully'; + static const String songAddedSuccess = 'Song added to playlist'; + + // File Paths + static const String downloadFolderName = 'Musify'; + static const String cacheFileName = 'musify_cache'; + + // Feature Flags + static const bool enableLyrics = true; + static const bool enableDownload = true; + static const bool enableNotifications = true; + static const bool enableAnalytics = false; +} diff --git a/lib/core/core.dart b/lib/core/core.dart new file mode 100644 index 0000000..5fdc839 --- /dev/null +++ b/lib/core/core.dart @@ -0,0 +1,4 @@ +// Core exports - constants and utilities +export 'constants/app_colors.dart'; +export 'constants/app_constants.dart'; +export 'utils/app_utils.dart'; diff --git a/lib/core/utils/app_utils.dart b/lib/core/utils/app_utils.dart new file mode 100644 index 0000000..3e46b6d --- /dev/null +++ b/lib/core/utils/app_utils.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../constants/app_constants.dart'; + +/// Navigation utilities to eliminate repetitive navigation patterns +class AppNavigation { + // Private constructor to prevent instantiation + AppNavigation._(); + + /// Navigate to a new page with slide transition + static Future push(BuildContext context, Widget page) { + return Navigator.push( + context, + MaterialPageRoute(builder: (context) => page), + ); + } + + /// Navigate to a new page and replace current + static Future pushReplacement(BuildContext context, Widget page) { + return Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => page), + ); + } + + /// Navigate back + static void pop(BuildContext context, [T? result]) { + Navigator.pop(context, result); + } + + /// Navigate to a new page with custom transition + static Future pushWithTransition( + BuildContext context, + Widget page, { + Duration duration = AppConstants.mediumAnimation, + }) { + return Navigator.push( + context, + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: animation.drive( + Tween(begin: const Offset(1.0, 0.0), end: Offset.zero), + ), + child: child, + ); + }, + ), + ); + } +} + +/// UI utilities for common UI operations +class AppUtils { + // Private constructor to prevent instantiation + AppUtils._(); + + /// Show snackbar with consistent styling + static void showSnackBar( + BuildContext context, + String message, { + Color? backgroundColor, + Duration duration = const Duration(seconds: 3), + }) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: duration, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + ), + ); + } + + /// Set system UI overlay style consistently + static void setSystemUIStyle({ + Color? systemNavigationBarColor, + Color? statusBarColor, + Brightness? statusBarBrightness, + }) { + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + systemNavigationBarColor: + systemNavigationBarColor ?? const Color(0xff1c252a), + statusBarColor: statusBarColor ?? Colors.transparent, + statusBarBrightness: statusBarBrightness ?? Brightness.dark, + ), + ); + } + + /// Format duration to readable string + static String formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + String minutes = twoDigits(duration.inMinutes.remainder(60)); + String seconds = twoDigits(duration.inSeconds.remainder(60)); + + if (duration.inHours > 0) { + String hours = twoDigits(duration.inHours); + return '$hours:$minutes:$seconds'; + } else { + return '$minutes:$seconds'; + } + } + + /// Validate if string is not empty + static bool isNotEmpty(String? value) { + return value != null && value.trim().isNotEmpty; + } + + /// Safe string parsing + static String safeString(dynamic value, [String defaultValue = '']) { + if (value == null) return defaultValue; + return value.toString().trim(); + } + + /// Debounce function calls + static Timer? _debounceTimer; + static void debounce( + VoidCallback callback, { + Duration delay = AppConstants.searchDebounceDelay, + }) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(delay, callback); + } + + /// Show loading dialog + static void showLoadingDialog(BuildContext context, {String? message}) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + content: Row( + children: [ + const CircularProgressIndicator(), + const SizedBox(width: 16), + Text(message ?? 'Loading...'), + ], + ), + ), + ); + } + + /// Hide loading dialog + static void hideLoadingDialog(BuildContext context) { + Navigator.of(context, rootNavigator: true).pop(); + } +} + +/// String extensions for common operations +extension StringExtensions on String { + /// Capitalize first letter + String get capitalize { + if (isEmpty) return this; + return this[0].toUpperCase() + substring(1); + } + + /// Check if string is valid URL + bool get isValidUrl { + return Uri.tryParse(this) != null && startsWith('http'); + } + + /// Remove special characters + String get sanitized { + return replaceAll(RegExp(r'[^\w\s]'), ''); + } +} + +/// Import for Timer diff --git a/lib/features/download/download.dart b/lib/features/download/download.dart new file mode 100644 index 0000000..c4c4957 --- /dev/null +++ b/lib/features/download/download.dart @@ -0,0 +1,2 @@ +// Download feature exports +export 'download_service.dart'; diff --git a/lib/features/download/download_service.dart b/lib/features/download/download_service.dart new file mode 100644 index 0000000..1eee776 --- /dev/null +++ b/lib/features/download/download_service.dart @@ -0,0 +1,234 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:http/http.dart' as http; +import 'package:audiotags/audiotags.dart'; + +import 'package:Musify/API/saavn.dart' as saavn; +import 'package:Musify/core/constants/app_colors.dart'; + +class DownloadService { + static Future downloadSong(String id) async { + String? filepath; + + // Check Android version and request appropriate permissions + bool permissionGranted = false; + + try { + // For Android 13+ (API 33+), use media permissions + // Check if permission is NOT granted (includes: denied, not determined, restricted, etc.) + if (!await Permission.audio.isGranted) { + Map statuses = await [ + Permission.audio, + // Permission.manageExternalStorage, + Permission.storage, + ].request(); + + permissionGranted = statuses[Permission.audio]?.isGranted == true || + // statuses[Permission.manageExternalStorage]?.isGranted == true || + statuses[Permission.storage]?.isGranted == true; + } else { + permissionGranted = await Permission.audio.isGranted || + // await Permission.manageExternalStorage.isGranted || + await Permission.storage.isGranted; + } + + // Try to get MANAGE_EXTERNAL_STORAGE for Android 11+ for full access + if (!permissionGranted && Platform.isAndroid) { + var manageStorageStatus = + await Permission.manageExternalStorage.request(); + permissionGranted = manageStorageStatus.isGranted; + } + } catch (e) { + debugPrint('Permission error: $e'); + // Fallback to storage permission + var storageStatus = await Permission.storage.request(); + permissionGranted = storageStatus.isGranted; + } + + if (!permissionGranted) { + Fluttertoast.showToast( + msg: + "Storage Permission Required!\nPlease grant storage access to download songs", + toastLength: Toast.LENGTH_LONG, + gravity: ToastGravity.BOTTOM, + timeInSecForIosWeb: 3, + backgroundColor: AppColors.backgroundModal, + textColor: AppColors.textPrimary, + fontSize: 14.0); + return; + } + + // Proceed with download + await _fetchSongDetails(id); + EasyLoading.show(status: 'Downloading ${saavn.title}...'); + + try { + final filename = + saavn.title.replaceAll(RegExp(r'[^\w\s-]'), '').trim() + ".m4a"; + + // Use multiple fallback strategies for file storage + Directory? musicDir; + String dlPath; + String locationDescription; + + if (Platform.isAndroid) { + // Strategy 1: Try Downloads/Musify directory (most reliable) + try { + musicDir = Directory('/storage/emulated/0/Download/Musify'); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + // Test write access + final testFile = File('${musicDir.path}/.test'); + await testFile.writeAsString('test'); + await testFile.delete(); + + dlPath = musicDir.path; + locationDescription = "Downloads/Musify folder"; + debugPrint('✓ Using Downloads/Musify directory: $dlPath'); + } catch (e) { + debugPrint('✗ Downloads directory failed: $e'); + + // Strategy 2: Try app-specific external directory + try { + musicDir = await getExternalStorageDirectory(); + if (musicDir != null) { + dlPath = "${musicDir.path}/Music"; + await Directory(dlPath).create(recursive: true); + locationDescription = "App Music folder"; + debugPrint('✓ Using app-specific directory: $dlPath'); + } else { + throw Exception('External storage not available'); + } + } catch (e2) { + debugPrint('✗ App-specific directory failed: $e2'); + + // Strategy 3: Use internal app directory + musicDir = await getApplicationDocumentsDirectory(); + dlPath = "${musicDir.path}/Music"; + await Directory(dlPath).create(recursive: true); + locationDescription = "App Documents folder"; + debugPrint('✓ Using internal app directory: $dlPath'); + } + } + } else { + // Fallback for other platforms + musicDir = await getApplicationDocumentsDirectory(); + dlPath = "${musicDir.path}/Music"; + await Directory(dlPath).create(recursive: true); + locationDescription = "Documents/Music folder"; + } + + filepath = "$dlPath/$filename"; + + debugPrint('Audio path: $filepath'); + + // Check if file already exists + bool fileExists = await File(filepath).exists(); + if (fileExists) { + EasyLoading.dismiss(); + Fluttertoast.showToast( + msg: "✓ ${saavn.title} already downloaded!", + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + timeInSecForIosWeb: 1, + backgroundColor: AppColors.backgroundModal, + textColor: AppColors.accent, + fontSize: 14.0); + return; + } + + // Download audio file + debugPrint('Downloading audio from: ${saavn.rawkUrl}'); + var request = await http.get(Uri.parse(saavn.rawkUrl)); + var bytes = request.bodyBytes; + await File(filepath).writeAsBytes(bytes); + debugPrint('✓ Audio file saved to: $filepath'); + + // Download artwork to memory (not saved to disk) + Uint8List? artworkBytes; + if (saavn.image.isNotEmpty) { + try { + debugPrint('Downloading artwork from: ${saavn.image}'); + var imageRequest = await http.get(Uri.parse(saavn.image)); + artworkBytes = Uint8List.fromList(imageRequest.bodyBytes); + debugPrint( + '✓ Artwork downloaded to memory (${artworkBytes.length} bytes)'); + } catch (e) { + debugPrint('✗ Artwork download failed: $e'); + artworkBytes = null; + } + } + + // Write metadata tags using audiotags package + try { + debugPrint('Writing metadata tags...'); + final tag = Tag( + title: + saavn.title.replaceAll(""", "\"").replaceAll("&", "&"), + trackArtist: + saavn.artist.replaceAll(""", "\"").replaceAll("&", "&"), + album: + saavn.album.replaceAll(""", "\"").replaceAll("&", "&"), + pictures: artworkBytes != null + ? [ + Picture( + bytes: artworkBytes, + mimeType: MimeType.jpeg, + pictureType: PictureType.coverFront, + ), + ] + : [], + ); + + await AudioTags.write(filepath, tag); + debugPrint('✓ Metadata written successfully'); + } catch (e) { + debugPrint('✗ Metadata write failed: $e'); + } + + EasyLoading.dismiss(); + Fluttertoast.showToast( + msg: + "✓ ${saavn.title} downloaded successfully!\nSaved in: $locationDescription", + toastLength: Toast.LENGTH_LONG, + gravity: ToastGravity.BOTTOM, + timeInSecForIosWeb: 3, + backgroundColor: AppColors.backgroundModal, + textColor: AppColors.accent, + fontSize: 14.0); + } catch (e) { + EasyLoading.dismiss(); + debugPrint('✗ Download error: $e'); + Fluttertoast.showToast( + msg: "✗ Download failed: $e", + toastLength: Toast.LENGTH_LONG, + gravity: ToastGravity.BOTTOM, + timeInSecForIosWeb: 3, + backgroundColor: AppColors.backgroundModal, + textColor: AppColors.error, + fontSize: 14.0); + } + } + + static Future _fetchSongDetails(String id) async { + try { + debugPrint('Fetching song details for ID: $id'); + bool success = await saavn.fetchSongDetails(id); + + if (success) { + // Global variables are set by fetchSongDetails + debugPrint('✓ Fetched song details: ${saavn.title} by ${saavn.artist}'); + } else { + throw Exception('Failed to fetch song details'); + } + } catch (e) { + debugPrint('✗ Error fetching song details: $e'); + throw e; + } + } +} diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart new file mode 100644 index 0000000..1d79ed3 --- /dev/null +++ b/lib/features/home/home.dart @@ -0,0 +1,4 @@ +// Home feature exports +export 'widgets/search_bar_widget.dart'; +export 'widgets/top_songs_grid.dart'; +export 'widgets/home_header.dart'; diff --git a/lib/features/home/widgets/home_header.dart b/lib/features/home/widgets/home_header.dart new file mode 100644 index 0000000..e365791 --- /dev/null +++ b/lib/features/home/widgets/home_header.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:gradient_widgets_plus/gradient_widgets_plus.dart'; +import 'package:provider/provider.dart'; + +import 'package:Musify/providers/search_provider.dart'; +import 'package:Musify/core/constants/app_colors.dart'; +import 'package:Musify/ui/aboutPage.dart'; + +class HomeHeader extends StatelessWidget { + final TextEditingController searchController; + final VoidCallback onClearSearch; + + const HomeHeader({ + super.key, + required this.searchController, + required this.onClearSearch, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, searchProvider, child) { + return Column( + children: [ + Padding(padding: EdgeInsets.only(top: 30, bottom: 20.0)), + RepaintBoundary( + child: Center( + child: Row( + children: [ + // Back button when showing search results + if (searchProvider.showSearchResults) + IconButton( + icon: Icon( + Icons.arrow_back, + color: AppColors.accent, + size: 28, + ), + onPressed: onClearSearch, + ) + else + SizedBox( + width: + 48), // Placeholder to maintain consistent spacing + + // Centered Musify text + Expanded( + child: Center( + child: RepaintBoundary( + child: GradientText( + "Musify.", + shaderRect: Rect.fromLTWH(13.0, 0.0, 100.0, 50.0), + gradient: AppColors.buttonGradient, + style: TextStyle( + fontSize: 35, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ), + + // Settings button (always visible) + IconButton( + iconSize: 26, + alignment: Alignment.center, + icon: Icon(MdiIcons.dotsVertical), + color: AppColors.accent, + onPressed: () => { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AboutPage(), + ), + ), + }, + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/home/widgets/search_bar_widget.dart b/lib/features/home/widgets/search_bar_widget.dart new file mode 100644 index 0000000..10d31f6 --- /dev/null +++ b/lib/features/home/widgets/search_bar_widget.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:Musify/providers/search_provider.dart'; +import 'package:Musify/core/constants/app_colors.dart'; + +class SearchBarWidget extends StatelessWidget { + final TextEditingController controller; + final VoidCallback onSearch; + + const SearchBarWidget({ + super.key, + required this.controller, + required this.onSearch, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 20), + child: TextField( + onSubmitted: (String value) { + onSearch(); + }, + controller: controller, + style: TextStyle( + fontSize: 16, + color: AppColors.accent, + ), + cursorColor: Colors.green[50], + decoration: InputDecoration( + fillColor: AppColors.backgroundSecondary, + filled: true, + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(100), + ), + borderSide: BorderSide( + color: AppColors.backgroundSecondary, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(100), + ), + borderSide: BorderSide(color: AppColors.accent), + ), + suffixIcon: Consumer( + builder: (context, searchProvider, child) { + return IconButton( + icon: Icon( + Icons.search, + color: AppColors.accent, + ), + color: AppColors.accent, + onPressed: searchProvider.isSearching + ? null + : () { + onSearch(); + }, + ); + }, + ), + border: InputBorder.none, + hintText: "Search...", + hintStyle: TextStyle( + color: AppColors.accent, + ), + contentPadding: const EdgeInsets.only( + left: 18, + right: 20, + top: 14, + bottom: 14, + ), + ), + ), + ); + } +} diff --git a/lib/features/home/widgets/top_songs_grid.dart b/lib/features/home/widgets/top_songs_grid.dart new file mode 100644 index 0000000..41a726f --- /dev/null +++ b/lib/features/home/widgets/top_songs_grid.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import 'package:Musify/providers/search_provider.dart'; +import 'package:Musify/core/constants/app_colors.dart'; +import 'package:Musify/shared/widgets/app_widgets.dart'; + +class TopSongsGrid extends StatelessWidget { + final Function(String songId, BuildContext context) onSongTap; + final Function(String songId) onDownload; + + const TopSongsGrid({ + super.key, + required this.onSongTap, + required this.onDownload, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, searchProvider, child) { + if (searchProvider.topSongs.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top 20 Songs Heading + Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + bottom: 16.0, + top: 8.0, + ), + child: Text( + "Top 20 songs of the week", + style: TextStyle( + color: AppColors.accent, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + // Grid View + RepaintBoundary( + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 12.0), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, // 2 columns + crossAxisSpacing: 12.0, + mainAxisSpacing: 12.0, + childAspectRatio: 0.8, // Adjust for card proportions + ), + itemCount: searchProvider.topSongs.length, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, // We handle this manually + itemBuilder: (BuildContext context, int index) { + final song = searchProvider.topSongs[index]; + return RepaintBoundary( + child: Card( + color: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + elevation: 2, + child: InkWell( + borderRadius: BorderRadius.circular(12.0), + onTap: () { + onSongTap(song.id, context); + }, + splashColor: AppColors.accent, + hoverColor: AppColors.accent, + focusColor: AppColors.accent, + highlightColor: AppColors.accent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Album Art Image + Expanded( + flex: 3, + child: Container( + width: double.infinity, + color: Colors + .black12, // Match the card background color + child: ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12.0), + topRight: Radius.circular(12.0), + ), + child: song.imageUrl.isNotEmpty + ? AppImageWidgets.albumArt( + imageUrl: song.imageUrl, + width: double.infinity, + height: double.infinity, + ) + : Container( + color: AppColors.backgroundSecondary, + child: Center( + child: Icon( + MdiIcons.musicNoteOutline, + size: 40, + color: AppColors.accent, + ), + ), + ), + ), + ), + ), + // Song Info + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + song.title + .split("(")[0] + .replaceAll(""", "\"") + .replaceAll("&", "&"), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 4), + Text( + song.artist, + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Spacer(), + // Download button + Align( + alignment: Alignment.centerRight, + child: IconButton( + color: AppColors.accent, + icon: Icon( + MdiIcons.downloadOutline, + size: 20, + ), + onPressed: () => onDownload(song.id), + tooltip: 'Download', + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/player/player.dart b/lib/features/player/player.dart new file mode 100644 index 0000000..524bc4a --- /dev/null +++ b/lib/features/player/player.dart @@ -0,0 +1,6 @@ +// Player feature exports +export 'widgets/player_controls.dart'; +export 'widgets/bottom_player.dart'; +export 'widgets/album_art_widget.dart'; +export 'widgets/lyrics_modal.dart'; +export 'widgets/player_layout.dart'; diff --git a/lib/features/player/widgets/album_art_widget.dart b/lib/features/player/widgets/album_art_widget.dart new file mode 100644 index 0000000..13fb330 --- /dev/null +++ b/lib/features/player/widgets/album_art_widget.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:gradient_widgets_plus/gradient_widgets_plus.dart'; + +import 'package:Musify/core/constants/app_colors.dart'; +import 'package:Musify/core/constants/app_constants.dart'; +import 'package:Musify/shared/widgets/app_widgets.dart'; + +class MusicPlayerAlbumArt extends StatelessWidget { + final String imageUrl; + final double size; + + const MusicPlayerAlbumArt({ + super.key, + required this.imageUrl, + this.size = AppConstants.albumArtSize, + }); + + @override + Widget build(BuildContext context) { + return AppImageWidgets.albumArt( + imageUrl: imageUrl, + width: size, + height: size, + backgroundColor: AppColors.backgroundSecondary, + accentColor: AppColors.accent, + ); + } +} + +class MusicPlayerSongInfo extends StatelessWidget { + final String title; + final String artist; + final String album; + + const MusicPlayerSongInfo({ + super.key, + required this.title, + required this.artist, + required this.album, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 35.0, bottom: 35), + child: Column( + children: [ + GradientText( + title, + shaderRect: Rect.fromLTWH(13.0, 0.0, 100.0, 50.0), + gradient: LinearGradient(colors: [ + Color(0xff4db6ac), + Color(0xff61e88a), + ]), + textScaleFactor: 2.5, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + "$album | $artist", + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/player/widgets/bottom_player.dart b/lib/features/player/widgets/bottom_player.dart new file mode 100644 index 0000000..36222bd --- /dev/null +++ b/lib/features/player/widgets/bottom_player.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import 'package:Musify/providers/music_player_provider.dart'; +import 'package:Musify/models/app_models.dart'; +import 'package:Musify/core/constants/app_colors.dart'; +import 'package:Musify/shared/widgets/app_widgets.dart'; +import 'package:Musify/music.dart' as music; + +class BottomPlayer extends StatelessWidget { + const BottomPlayer({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, musicPlayer, child) { + return musicPlayer.currentSong != null + ? RepaintBoundary( + child: Container( + height: 75, + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + ), + color: AppColors.backgroundSecondary, + border: Border( + top: BorderSide( + color: AppColors.accent.withValues(alpha: 0.3), + width: 1.0, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(top: 5.0, bottom: 2), + child: Row( + children: [ + // Up arrow button - opens music player + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: IconButton( + icon: Icon( + MdiIcons.appleKeyboardControl, + size: 22, + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RepaintBoundary( + child: const music.AudioApp(), + ), + ), + ); + }, + disabledColor: AppColors.accent, + ), + ), + // Album art - also opens music player when tapped + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RepaintBoundary( + child: const music.AudioApp(), + ), + ), + ); + }, + child: Container( + width: 60, + height: 60, + padding: const EdgeInsets.only( + left: 0.0, + top: 7, + bottom: 7, + right: 15, + ), + child: AppImageWidgets.albumArt( + imageUrl: musicPlayer.currentSong?.imageUrl ?? '', + width: 60, + height: 60, + ), + ), + ), + // Song title and artist - tappable to open player + Expanded( + child: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RepaintBoundary( + child: const music.AudioApp(), + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.only( + top: 0.0, + left: 8.0, + right: 8.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + musicPlayer.currentSong?.title ?? 'Unknown', + style: TextStyle( + color: AppColors.accent, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + musicPlayer.currentSong?.artist ?? + 'Unknown Artist', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 15, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + ], + ), + ), + ), + ), + // Play/Pause button - DOES NOT open music player + Consumer( + builder: (context, musicPlayer, child) { + // Show loading indicator while song is loading + if (musicPlayer.playbackState == + PlaybackState.loading) { + return Padding( + padding: const EdgeInsets.all(12.0), + child: SizedBox( + width: 21, + height: 21, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation( + AppColors.accent, + ), + ), + ), + ); + } + + // Show play/pause button based on playback state + return IconButton( + icon: musicPlayer.playbackState == + PlaybackState.playing + ? Icon(MdiIcons.pause) + : Icon(MdiIcons.playOutline), + color: AppColors.accent, + splashColor: Colors.transparent, + onPressed: () async { + try { + if (musicPlayer.playbackState == + PlaybackState.playing) { + await musicPlayer.pause(); + } else if (musicPlayer.playbackState == + PlaybackState.paused) { + await musicPlayer.resume(); + } else if (musicPlayer.currentSong != null) { + await musicPlayer + .playSong(musicPlayer.currentSong!); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('No song selected'), + backgroundColor: Colors.orange, + ), + ); + } + } catch (e) { + debugPrint('❌ Audio control error: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Audio error: $e'), + backgroundColor: Colors.red, + ), + ); + } + }, + iconSize: 45, + ); + }, + ) + ], + ), + ), + ), + ) + : const SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/features/player/widgets/lyrics_modal.dart b/lib/features/player/widgets/lyrics_modal.dart new file mode 100644 index 0000000..68cd6ae --- /dev/null +++ b/lib/features/player/widgets/lyrics_modal.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import 'package:Musify/core/constants/app_colors.dart'; +import 'package:Musify/models/app_models.dart'; +import 'package:Musify/API/saavn.dart' as saavn; + +class LyricsBottomSheet extends StatelessWidget { + final Song song; + + const LyricsBottomSheet({ + super.key, + required this.song, + }); + + @override + Widget build(BuildContext context) { + // Calculate height to start from below play/pause button + // This positions the modal to cover only the lower portion of the screen + final screenHeight = MediaQuery.of(context).size.height; + final modalHeight = screenHeight * 0.5; // Cover bottom 50% of screen + + return Container( + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(18.0), + topRight: const Radius.circular(18.0), + ), + ), + height: modalHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: Row( + children: [ + IconButton( + icon: Icon( + Icons.arrow_back_ios, + color: AppColors.accent, + size: 20, + ), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 42.0), + child: Center( + child: Text( + "Lyrics", + style: TextStyle( + color: AppColors.accent, + fontSize: 30, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Center( + child: SingleChildScrollView( + child: Container( + child: saavn.has_lyrics == "true" + ? Text( + saavn.lyrics, + style: TextStyle( + fontSize: 16.0, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ) + : Text( + "Lyrics not available", + style: TextStyle( + fontSize: 16.0, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + static void show(BuildContext context, Song song) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => LyricsBottomSheet(song: song), + ); + } +} diff --git a/lib/features/player/widgets/player_controls.dart b/lib/features/player/widgets/player_controls.dart new file mode 100644 index 0000000..276aa48 --- /dev/null +++ b/lib/features/player/widgets/player_controls.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import '../../../core/constants/app_colors.dart'; +import '../../../core/constants/app_constants.dart'; +import '../../../shared/widgets/app_widgets.dart'; + +/// Reusable player control widgets +/// Eliminates duplication in player controls across different screens +class PlayerControls extends StatelessWidget { + final bool isPlaying; + final bool isPaused; + final VoidCallback? onPlay; + final VoidCallback? onPause; + final VoidCallback? onNext; + final VoidCallback? onPrevious; + final bool hasNext; + final bool hasPrevious; + final double iconSize; + final Color? iconColor; + final Gradient? gradient; + + const PlayerControls({ + super.key, + required this.isPlaying, + required this.isPaused, + this.onPlay, + this.onPause, + this.onNext, + this.onPrevious, + this.hasNext = false, + this.hasPrevious = false, + this.iconSize = AppConstants.playerControlSize, + this.iconColor, + this.gradient, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Previous button + if (onPrevious != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: IconButton( + iconSize: iconSize * 0.7, + icon: Icon( + MdiIcons.skipPrevious, + color: hasPrevious + ? AppColors.accent + : AppColors.textSecondary.withValues(alpha: 0.5), + ), + onPressed: hasPrevious ? onPrevious : null, + ), + ), + + // Play/Pause button + if (!isPlaying) _buildPlayButton(), + if (isPlaying) _buildPauseButton(), + + // Next button + if (onNext != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: IconButton( + iconSize: iconSize * 0.7, + icon: Icon( + MdiIcons.skipNext, + color: hasNext + ? AppColors.accent + : AppColors.textSecondary.withValues(alpha: 0.5), + ), + onPressed: hasNext ? onNext : null, + ), + ), + ], + ); + } + + Widget _buildPlayButton() { + return AppContainerWidgets.gradientButton( + gradient: gradient ?? AppColors.buttonGradient, + onPressed: onPlay ?? () {}, + child: Icon( + MdiIcons.play, + size: iconSize, + color: iconColor ?? AppColors.backgroundSecondary, + ), + ); + } + + Widget _buildPauseButton() { + return AppContainerWidgets.gradientButton( + gradient: gradient ?? AppColors.buttonGradient, + onPressed: onPause ?? () {}, + child: Icon( + MdiIcons.pause, + size: iconSize, + color: iconColor ?? AppColors.backgroundSecondary, + ), + ); + } +} + +/// Progress bar widget for audio playback +class PlayerProgressBar extends StatelessWidget { + final Duration position; + final Duration duration; + final ValueChanged? onChanged; + final Color? activeColor; + final Color? inactiveColor; + + const PlayerProgressBar({ + super.key, + required this.position, + required this.duration, + this.onChanged, + this.activeColor, + this.inactiveColor, + }); + + @override + Widget build(BuildContext context) { + // Clamp position to duration to prevent slider errors when song completes + final clampedPosition = position.inMilliseconds.toDouble().clamp( + 0.0, + duration.inMilliseconds.toDouble(), + ); + + return Column( + children: [ + SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: AppConstants.progressBarHeight, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Slider( + value: clampedPosition, + onChanged: onChanged, + min: 0.0, + max: duration.inMilliseconds.toDouble(), + activeColor: activeColor ?? AppColors.accent, + inactiveColor: inactiveColor ?? AppColors.textSecondary, + ), + ), + _buildTimeDisplay(), + ], + ); + } + + Widget _buildTimeDisplay() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(position), + style: const TextStyle( + fontSize: 14.0, + color: AppColors.textSecondary, + ), + ), + Text( + _formatDuration(duration), + style: const TextStyle( + fontSize: 14.0, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } + + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + String minutes = twoDigits(duration.inMinutes.remainder(60)); + String seconds = twoDigits(duration.inSeconds.remainder(60)); + return '$minutes:$seconds'; + } +} + +/// Mini player widget for bottom navigation +class MiniPlayer extends StatelessWidget { + final String? title; + final String? artist; + final String? imageUrl; + final bool isPlaying; + final VoidCallback? onTap; + final VoidCallback? onPlayPause; + + const MiniPlayer({ + super.key, + this.title, + this.artist, + this.imageUrl, + required this.isPlaying, + this.onTap, + this.onPlayPause, + }); + + @override + Widget build(BuildContext context) { + if (title == null || imageUrl == null) { + return const SizedBox.shrink(); + } + + return RepaintBoundary( + child: Container( + height: AppConstants.miniPlayerHeight, + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(AppConstants.buttonBorderRadius), + topRight: Radius.circular(AppConstants.buttonBorderRadius), + ), + color: AppColors.backgroundTertiary, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.defaultPadding, + vertical: AppConstants.smallPadding, + ), + child: Row( + children: [ + AppImageWidgets.thumbnail( + imageUrl: imageUrl!, + size: AppConstants.thumbnailSize, + ), + const SizedBox(width: AppConstants.defaultPadding), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title!, + style: const TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (artist != null) + Text( + artist!, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: onPlayPause, + icon: Icon( + isPlaying ? MdiIcons.pause : MdiIcons.play, + color: AppColors.accent, + size: 24, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/player_layout.dart b/lib/features/player/widgets/player_layout.dart new file mode 100644 index 0000000..7a3443c --- /dev/null +++ b/lib/features/player/widgets/player_layout.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:gradient_widgets_plus/gradient_widgets_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; + +import 'package:Musify/providers/music_player_provider.dart'; +import 'package:Musify/providers/app_state_provider.dart'; +import 'package:Musify/core/constants/app_colors.dart'; +import 'package:Musify/features/player/player.dart'; + +class MusicPlayerLayout extends StatelessWidget { + const MusicPlayerLayout({super.key}); + + @override + Widget build(BuildContext context) { + // Get screen dimensions using MediaQuery for responsive layout + final screenHeight = MediaQuery.of(context).size.height; + final screenWidth = MediaQuery.of(context).size.width; + final isSmallScreen = screenHeight < 700; // Phones with small screens + + return Consumer2( + builder: (context, musicPlayer, appState, child) { + final currentSong = musicPlayer.currentSong; + final songInfo = musicPlayer.getCurrentSongInfo(); + + if (currentSong == null) { + return Scaffold( + appBar: AppBar( + title: Text('No Song Selected'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: Text('No song is currently loaded'), + ), + ); + } + + return Container( + decoration: const BoxDecoration( + gradient: AppColors.primaryGradient, + ), + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + title: GradientText( + "Now Playing", + shaderRect: Rect.fromLTWH(13.0, 0.0, 100.0, 50.0), + gradient: AppColors.accentGradient, + style: TextStyle( + color: AppColors.accent, + fontSize: 25, + fontWeight: FontWeight.w700, + ), + ), + leading: Padding( + padding: const EdgeInsets.only(left: 14.0), + child: IconButton( + icon: Icon( + Icons.keyboard_arrow_down, + size: 32, + color: AppColors.accent, + ), + onPressed: () => Navigator.pop(context, false), + ), + ), + ), + body: SafeArea( + bottom: true, // Ensure bottom safe area is respected + child: LayoutBuilder( + builder: (context, constraints) { + // Calculate responsive spacing + final availableHeight = constraints.maxHeight; + final topSpacing = + isSmallScreen ? 5.0 : 20.0; // Reduced for small screens + + return SingleChildScrollView( + // Always enable scrolling to prevent content from being cut off + physics: const ClampingScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: availableHeight, + ), + child: IntrinsicHeight( + child: Column( + children: [ + SizedBox(height: topSpacing), + + // Album Art with responsive size + Center( + child: MusicPlayerAlbumArt( + imageUrl: songInfo['imageUrl']!, + ), + ), + + // Song Info + MusicPlayerSongInfo( + title: songInfo['title']!, + artist: songInfo['artist']!, + album: songInfo['album']!, + ), + + // Flexible spacing that adapts + Expanded( + child: SizedBox( + height: isSmallScreen ? 10.0 : 20.0, + ), + ), + + // Player Controls + // Player Controls + Material( + child: _buildPlayer( + context, + musicPlayer, + appState, + screenHeight, + screenWidth, + isSmallScreen, + ), + ), + + // Extra bottom padding to ensure visibility on all devices + SizedBox( + height: + MediaQuery.of(context).padding.bottom + 16), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); + }, + ); + } + + Widget _buildPlayer( + BuildContext context, + MusicPlayerProvider musicPlayer, + AppStateProvider appState, + double screenHeight, + double screenWidth, + bool isSmallScreen, + ) { + // Responsive padding and spacing + final horizontalPadding = screenWidth * 0.04; // 4% of screen width + final verticalPadding = isSmallScreen ? 4.0 : 12.0; // Reduced padding + final controlSpacing = isSmallScreen ? 8.0 : 14.0; // Reduced spacing + final lyricsButtonTopPadding = + isSmallScreen ? 12.0 : 30.0; // Reduced spacing + + return Container( + padding: EdgeInsets.only( + top: 10.0, // Reduced from 15.0 + left: horizontalPadding, + right: horizontalPadding, + bottom: + verticalPadding, // Removed extra bottom padding since we added it to Column + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Loop/Repeat Button (above slider) + Padding( + padding: EdgeInsets.only(bottom: isSmallScreen ? 4.0 : 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: IconButton( + icon: Icon( + musicPlayer.isLoopEnabled + ? MdiIcons.repeat + : MdiIcons.repeatOff, + ), + color: musicPlayer.isLoopEnabled + ? AppColors.accent + : Colors.white54, + iconSize: 24, + onPressed: () { + musicPlayer.toggleLoop(); + // Show a snackbar with app theme + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + musicPlayer.isLoopEnabled + ? MdiIcons.repeat + : MdiIcons.repeatOff, + color: AppColors.accent, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + musicPlayer.isLoopEnabled + ? 'Loop ON - Current song will repeat' + : 'Loop OFF - Will play next song', + style: const TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + backgroundColor: AppColors.backgroundModal, + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.all(16), + ), + ); + }, + tooltip: musicPlayer.isLoopEnabled + ? 'Loop: ON (Repeat current song)' + : 'Loop: OFF (Play next song)', + ), + ), + ], + ), + ), + + // Progress Slider with loading state + AnimatedOpacity( + opacity: musicPlayer.duration.inMilliseconds > 0 ? 1.0 : 0.5, + duration: const Duration(milliseconds: 300), + child: PlayerProgressBar( + position: musicPlayer.position, + duration: musicPlayer.duration.inMilliseconds > 0 + ? musicPlayer.duration + : const Duration(milliseconds: 1), // Prevent division by zero + onChanged: musicPlayer.duration.inMilliseconds > 0 + ? (double value) { + musicPlayer.seek(Duration(milliseconds: value.round())); + } + : null, // Disable interaction when duration is not loaded + ), + ), + + // Play/Pause Button and Lyrics + Padding( + padding: EdgeInsets.only(top: controlSpacing), + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + PlayerControls( + isPlaying: musicPlayer.isPlaying, + isPaused: musicPlayer.isPaused, + hasNext: musicPlayer.hasNextSong, + hasPrevious: musicPlayer.hasPreviousSong, + onPlay: () { + if (musicPlayer.isPaused) { + musicPlayer.resume(); + } else { + if (musicPlayer.currentSong != null) { + musicPlayer.playSong(musicPlayer.currentSong!); + } + } + }, + onPause: () => musicPlayer.pause(), + onNext: () => musicPlayer.playNext(), + onPrevious: () => musicPlayer.playPrevious(), + iconSize: + isSmallScreen ? 35.0 : 40.0, // Responsive icon size + ), + ], + ), + + // Lyrics Button + if (appState.showLyrics && musicPlayer.currentSong != null) + Padding( + padding: EdgeInsets.only(top: lyricsButtonTopPadding), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18.0), + ), + ), + onPressed: () { + LyricsBottomSheet.show( + context, musicPlayer.currentSong!); + }, + child: Text( + "Lyrics", + style: TextStyle(color: AppColors.accent), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/search/search.dart b/lib/features/search/search.dart new file mode 100644 index 0000000..aec5607 --- /dev/null +++ b/lib/features/search/search.dart @@ -0,0 +1,2 @@ +// Search feature exports +export 'widgets/search_widgets.dart'; diff --git a/lib/features/search/widgets/search_results_list.dart b/lib/features/search/widgets/search_results_list.dart new file mode 100644 index 0000000..27f4bfc --- /dev/null +++ b/lib/features/search/widgets/search_results_list.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart'; + +import 'package:Musify/providers/search_provider.dart'; +import 'package:Musify/core/constants/app_colors.dart'; + +class SearchResultsList extends StatelessWidget { + final Function(String songId, BuildContext context) onSongTap; + final Function(String songId) onDownload; + final VoidCallback onLongPress; + + const SearchResultsList({ + super.key, + required this.onSongTap, + required this.onDownload, + required this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, searchProvider, child) { + if (!searchProvider.showSearchResults) { + return const SizedBox.shrink(); + } + + return RepaintBoundary( + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: searchProvider.searchResults.length, + itemBuilder: (BuildContext context, int index) { + final song = searchProvider.searchResults[index]; + return Padding( + padding: const EdgeInsets.only(top: 5, bottom: 5), + child: Card( + color: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + elevation: 0, + child: InkWell( + borderRadius: BorderRadius.circular(10.0), + onTap: () { + onSongTap(song.id, context); + }, + onLongPress: onLongPress, + splashColor: AppColors.accent, + hoverColor: AppColors.accent, + focusColor: AppColors.accent, + highlightColor: AppColors.accent, + child: Column( + children: [ + ListTile( + leading: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + MdiIcons.musicNoteOutline, + size: 30, + color: AppColors.accent, + ), + ), + title: Text( + song.title + .split("(")[0] + .replaceAll(""", "\"") + .replaceAll("&", "&"), + style: TextStyle(color: Colors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + song.artist, + style: TextStyle(color: Colors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + color: AppColors.accent, + icon: Icon(MdiIcons.downloadOutline), + onPressed: () => onDownload(song.id), + tooltip: 'Download', + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/features/search/widgets/search_widgets.dart b/lib/features/search/widgets/search_widgets.dart new file mode 100644 index 0000000..96e7dd6 --- /dev/null +++ b/lib/features/search/widgets/search_widgets.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import '../../../core/constants/app_colors.dart'; +import '../../../core/constants/app_constants.dart'; +import '../../../shared/widgets/app_widgets.dart'; +import '../../../models/app_models.dart'; + +/// Reusable search widgets +/// Eliminates duplication in search UI across the application +class AppSearchBar extends StatefulWidget { + final TextEditingController controller; + final ValueChanged? onChanged; + final VoidCallback? onClear; + final String hintText; + final bool autofocus; + + const AppSearchBar({ + super.key, + required this.controller, + this.onChanged, + this.onClear, + this.hintText = 'Search songs...', + this.autofocus = false, + }); + + @override + State createState() => _AppSearchBarState(); +} + +class _AppSearchBarState extends State { + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: AppConstants.defaultPadding, + vertical: AppConstants.smallPadding, + ), + child: TextField( + controller: widget.controller, + onChanged: widget.onChanged, + autofocus: widget.autofocus, + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: const TextStyle(color: AppColors.textSecondary), + prefixIcon: const Icon( + Icons.search, + color: AppColors.textSecondary, + ), + suffixIcon: widget.controller.text.isNotEmpty + ? IconButton( + onPressed: () { + widget.controller.clear(); + widget.onClear?.call(); + }, + icon: const Icon( + Icons.clear, + color: AppColors.textSecondary, + ), + ) + : null, + fillColor: AppColors.backgroundSecondary, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + borderSide: BorderSide.none, + ), + ), + ), + ); + } +} + +/// Song list item widget +class SongListItem extends StatelessWidget { + final Song song; + final VoidCallback? onTap; + final Widget? trailing; + final bool showImage; + + const SongListItem({ + super.key, + required this.song, + this.onTap, + this.trailing, + this.showImage = true, + }); + + @override + Widget build(BuildContext context) { + return AppContainerWidgets.appCard( + child: ListTile( + onTap: onTap, + leading: showImage + ? AppImageWidgets.thumbnail( + imageUrl: song.imageUrl, + size: 50, + ) + : null, + title: Text( + song.title, + style: const TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + song.artist, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: trailing ?? + const Icon( + Icons.play_arrow, + color: AppColors.accent, + ), + ), + ); + } +} + +/// Search results list widget +class SearchResultsList extends StatelessWidget { + final List songs; + final Function(Song) onSongTap; + final bool isLoading; + final String? emptyMessage; + + const SearchResultsList({ + super.key, + required this.songs, + required this.onSongTap, + this.isLoading = false, + this.emptyMessage, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.accent), + ), + ); + } + + if (songs.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + MdiIcons.musicNoteOff, + size: 64, + color: AppColors.textSecondary, + ), + const SizedBox(height: AppConstants.defaultPadding), + Text( + emptyMessage ?? 'No songs found', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 16, + ), + ), + ], + ), + ); + } + + return RepaintBoundary( + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: songs.length, + itemBuilder: (context, index) { + return SongListItem( + song: songs[index], + onTap: () => onSongTap(songs[index]), + ); + }, + ), + ); + } +} + +/// Loading indicator widget +class LoadingIndicator extends StatelessWidget { + final String? message; + final Color? color; + + const LoadingIndicator({ + super.key, + this.message, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + color ?? AppColors.accent, + ), + ), + if (message != null) ...[ + const SizedBox(height: AppConstants.defaultPadding), + Text( + message!, + style: const TextStyle( + color: AppColors.textSecondary, + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/helper/contact_widget.dart b/lib/helper/contact_widget.dart new file mode 100644 index 0000000..efaaa87 --- /dev/null +++ b/lib/helper/contact_widget.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:Musify/core/constants/app_colors.dart'; + +class ContactCard extends StatelessWidget { + final String name; + final String subtitle; + final String imageUrl; + final String? telegramUrl; + final String? xUrl; + final Color textColor; + + const ContactCard({ + Key? key, + required this.name, + required this.subtitle, + required this.imageUrl, + this.telegramUrl, + this.xUrl, + this.textColor = Colors.white, + }) : super(key: key); + + // Future _launchOnTap(String url) async { + // final Uri uri = Uri.parse(url); + // if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + // throw 'Could not launch $url'; + // } + // } + Future _launchOnTap(String url) async { + final Uri uri = Uri.parse(url); + if (!await launchUrl( + uri, + mode: LaunchMode.platformDefault, + )) { + throw 'Could not launch $url'; + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, left: 8, right: 8, bottom: 6), + child: Card( + color: AppColors.backgroundSecondary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + elevation: 2.3, + child: ListTile( + leading: Container( + width: 50.0, + height: 50.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + fit: BoxFit.fill, + image: NetworkImage(imageUrl), + ), + ), + ), + title: Text( + name, + style: TextStyle(color: textColor), + ), + subtitle: Text( + subtitle, + style: TextStyle(color: textColor), + ), + trailing: Wrap( + children: [ + if (telegramUrl != null) + IconButton( + icon: Icon(MdiIcons.send, color: textColor), + tooltip: 'Contact on Telegram', + onPressed: () async { + await _launchOnTap(telegramUrl!); + }, + ), + if (xUrl != null) + IconButton( + icon: Icon(MdiIcons.twitter, color: textColor), + tooltip: 'Contact on x', + onPressed: () async { + await _launchOnTap(xUrl!); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/helper/utils.dart b/lib/helper/utils.dart deleted file mode 100644 index 06d388a..0000000 --- a/lib/helper/utils.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:url_launcher/url_launcher.dart'; - -launchURL(url) async { - if (await canLaunch(url)) { - await launch(url); - } else { - throw 'Could not launch $url'; - } -} diff --git a/lib/main.dart b/lib/main.dart index 0c300cc..e275efb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,148 @@ import 'package:flutter/material.dart'; -import 'package:Musify/style/appColors.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:permission_handler/permission_handler.dart'; + import 'package:Musify/ui/homePage.dart'; +import 'package:Musify/services/audio_player_service.dart'; +import 'package:Musify/services/background_audio_handler.dart'; +import 'package:Musify/providers/music_player_provider.dart'; +import 'package:Musify/providers/search_provider.dart'; +import 'package:Musify/providers/app_state_provider.dart'; + +// Global audio handler instance +late MusifyAudioHandler audioHandler; + +/// Request notification permission for Android 13+ (API 33+) +/// This is required for notifications and lock screen controls to work +Future _requestNotificationPermission() async { + debugPrint('🔔 Requesting notification permission...'); + try { + final status = await Permission.notification.request(); + if (status.isGranted) { + debugPrint('✅ Notification permission granted'); + } else if (status.isDenied) { + debugPrint('⚠️ Notification permission denied'); + } else if (status.isPermanentlyDenied) { + debugPrint('❌ Notification permission permanently denied'); + debugPrint('💡 User needs to enable it in Settings'); + } + } catch (e) { + debugPrint('⚠️ Error requesting notification permission: $e'); + } +} + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Request notification permission for Android 13+ (required for lock screen controls) + await _requestNotificationPermission(); + + // Initialize audio_service with custom handler + debugPrint('🎵 Initializing audio_service...'); + try { + audioHandler = await AudioService.init( + builder: () => MusifyAudioHandler(), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.gokadzev.musify.channel.audio', + androidNotificationChannelName: 'Musify Audio', + androidNotificationChannelDescription: 'Music playback controls', + androidStopForegroundOnPause: false, // Keep notification when paused + androidNotificationIcon: + 'drawable/ic_notification', // Custom notification icon + androidShowNotificationBadge: true, + ), + ); + debugPrint('✅ audio_service initialized successfully'); + debugPrint('✅ Notification icon: drawable/ic_notification'); + debugPrint('✅ Notification channel: com.gokadzev.musify.channel.audio'); + } catch (e) { + debugPrint('⚠️ Failed to initialize audio_service: $e'); + debugPrint('⚠️ Background playback will not be available'); + } + + runApp(const MusifyApp()); +} + +class MusifyApp extends StatefulWidget { + const MusifyApp({super.key}); + + @override + _MusifyAppState createState() => _MusifyAppState(); +} + +class _MusifyAppState extends State with WidgetsBindingObserver { + late final AudioPlayerService _audioService; + + @override + void initState() { + super.initState(); + _audioService = AudioPlayerService(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + switch (state) { + case AppLifecycleState.paused: + // App is in background - audio_service handles background playback + debugPrint('🎵 App backgrounded - audio continues via audio_service'); + break; + case AppLifecycleState.resumed: + // App is back in foreground + debugPrint('🎵 App resumed - audio ready'); + break; + case AppLifecycleState.detached: + // App is being terminated - cleanup audio resources + _audioService.dispose(); + debugPrint('🧹 AudioPlayerService disposed - app terminated'); + break; + default: + break; + } + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + /// AppStateProvider - Global app state, theme, preferences + ChangeNotifierProvider( + create: (_) => AppStateProvider(), + ), + + /// MusicPlayerProvider - Audio playback state and controls + ChangeNotifierProvider( + create: (_) => MusicPlayerProvider(), + ), -main() async { - runApp( - MaterialApp( - theme: ThemeData( - fontFamily: "DMSans", - accentColor: accent, - primaryColor: accent, - canvasColor: Colors.transparent, + /// SearchProvider - Search state, results, and top songs + ChangeNotifierProvider( + create: (_) => SearchProvider(), + ), + ], + child: Consumer( + builder: (context, appState, child) { + return MaterialApp( + title: 'Musify', + theme: appState.getLightThemeData(), + darkTheme: appState.getDarkThemeData(), + themeMode: appState.themeMode, + home: const Musify(), + builder: EasyLoading.init(), + debugShowCheckedModeBanner: false, + ); + }, ), - home: Musify(), - ), - ); + ); + } } diff --git a/lib/models/app_models.dart b/lib/models/app_models.dart new file mode 100644 index 0000000..ff5ba79 --- /dev/null +++ b/lib/models/app_models.dart @@ -0,0 +1,221 @@ +/// Data models for type-safe state management +/// Following industry standards for immutable data structures + +import 'package:flutter/foundation.dart'; + +class Song { + final String id; + final String title; + final String artist; + final String album; + final String imageUrl; + final String audioUrl; + final String albumId; + final bool hasLyrics; + final String lyrics; + final bool has320Quality; + final Duration? duration; + + const Song({ + required this.id, + required this.title, + required this.artist, + required this.album, + required this.imageUrl, + required this.audioUrl, + this.albumId = '', + this.hasLyrics = false, + this.lyrics = '', + this.has320Quality = false, + this.duration, + }); + + /// Create Song from API response data + factory Song.fromJson(Map json) { + return Song( + id: json['id'] ?? '', + title: _formatString(json['title'] ?? 'Unknown'), + artist: _formatString(json['singers'] ?? 'Unknown Artist'), + album: _formatString(json['album'] ?? 'Unknown Album'), + imageUrl: _enhanceImageQuality(json['image'] ?? ''), + audioUrl: '', // Will be set after decryption + hasLyrics: json['has_lyrics'] == 'true', + lyrics: json['lyrics'] ?? '', + has320Quality: json['320kbps'] == 'true', + ); + } + + /// Create Song from search result + factory Song.fromSearchResult(Map json) { + return Song( + id: json['id'] ?? '', + title: _formatString(json['title'] ?? 'Unknown'), + artist: _formatString(json['more_info']?['singers'] ?? 'Unknown Artist'), + album: _formatString(json['album'] ?? 'Unknown Album'), + imageUrl: _enhanceImageQuality(json['image'] ?? ''), + audioUrl: '', // Will be set after fetching details + hasLyrics: false, // Will be updated after fetching details + lyrics: '', + has320Quality: false, // Will be updated after fetching details + ); + } + + /// Create Song from top songs list + factory Song.fromTopSong(Map json) { + final artistName = json['more_info']?['artistMap']?['primary_artists']?[0] + ?['name'] ?? + 'Unknown Artist'; + + return Song( + id: json['id'] ?? '', + title: _formatString(json['title'] ?? 'Unknown'), + artist: _formatString(artistName), + album: _formatString(json['album'] ?? 'Unknown Album'), + imageUrl: _enhanceImageQuality(json['image'] ?? ''), + audioUrl: '', // Will be set after fetching details + hasLyrics: false, + lyrics: '', + has320Quality: false, + ); + } + + /// Create a copy of Song with updated fields + Song copyWith({ + String? id, + String? title, + String? artist, + String? album, + String? imageUrl, + String? audioUrl, + String? albumId, + bool? hasLyrics, + String? lyrics, + bool? has320Quality, + Duration? duration, + }) { + return Song( + id: id ?? this.id, + title: title ?? this.title, + artist: artist ?? this.artist, + album: album ?? this.album, + imageUrl: imageUrl ?? this.imageUrl, + audioUrl: audioUrl ?? this.audioUrl, + albumId: albumId ?? this.albumId, + hasLyrics: hasLyrics ?? this.hasLyrics, + lyrics: lyrics ?? this.lyrics, + has320Quality: has320Quality ?? this.has320Quality, + duration: duration ?? this.duration, + ); + } + + /// Helper method to format strings (same as in saavn.dart) + static String _formatString(String input) { + return input + .replaceAll(""", "'") + .replaceAll("&", "&") + .replaceAll("'", "'"); + } + + /// Enhance image quality by replacing size parameters with highest quality + static String _enhanceImageQuality(String imageUrl) { + if (imageUrl.isEmpty) return imageUrl; + + final originalUrl = imageUrl; + + // Try different resolution patterns for maximum quality + final enhancedUrl = imageUrl + .replaceAll('150x150', '500x500') + .replaceAll('50x50', '500x500') + .replaceAll('200x200', '500x500') + .replaceAll('250x250', '500x500') + .replaceAll('300x300', '500x500'); + + // Debug logging to track URL transformations + if (enhancedUrl != originalUrl) { + debugPrint('🖼️ Image quality enhanced:'); + debugPrint(' Original: $originalUrl'); + debugPrint(' Enhanced: $enhancedUrl'); + } + + return enhancedUrl; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Song && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() { + return 'Song{id: $id, title: $title, artist: $artist}'; + } +} + +/// Enum for player states +enum PlaybackState { + stopped, + loading, + playing, + paused, + error, + completed, +} + +/// Enum for app states +enum AppLoadingState { + idle, + loading, + success, + error, +} + +/// Error class for better error handling +class AppError { + final String message; + final String? details; + final ErrorType type; + + const AppError({ + required this.message, + this.details, + this.type = ErrorType.unknown, + }); + + factory AppError.network(String message, [String? details]) { + return AppError( + message: message, + details: details, + type: ErrorType.network, + ); + } + + factory AppError.audio(String message, [String? details]) { + return AppError( + message: message, + details: details, + type: ErrorType.audio, + ); + } + + factory AppError.permission(String message, [String? details]) { + return AppError( + message: message, + details: details, + type: ErrorType.permission, + ); + } + + @override + String toString() => message; +} + +enum ErrorType { + network, + audio, + permission, + storage, + unknown, +} diff --git a/lib/music.dart b/lib/music.dart index 498538e..405136f 100644 --- a/lib/music.dart +++ b/lib/music.dart @@ -1,446 +1,22 @@ -import 'dart:async'; - -import 'package:audioplayer/audioplayer.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:gradient_widgets/gradient_widgets.dart'; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; -import 'package:Musify/style/appColors.dart'; -import 'API/saavn.dart'; +// New modular imports +import 'package:Musify/features/player/player.dart'; String status = 'hidden'; -AudioPlayer audioPlayer; -PlayerState playerState; typedef void OnError(Exception exception); -enum PlayerState { stopped, playing, paused } - class AudioApp extends StatefulWidget { + const AudioApp({super.key}); + @override AudioAppState createState() => AudioAppState(); } -@override class AudioAppState extends State { - Duration duration; - Duration position; - - get isPlaying => playerState == PlayerState.playing; - - get isPaused => playerState == PlayerState.paused; - - get durationText => - duration != null ? duration.toString().split('.').first : ''; - - get positionText => - position != null ? position.toString().split('.').first : ''; - - bool isMuted = false; - - StreamSubscription _positionSubscription; - StreamSubscription _audioPlayerStateSubscription; - - @override - void initState() { - super.initState(); - - initAudioPlayer(); - } - - @override - void dispose() { - super.dispose(); - } - - void initAudioPlayer() { - if (audioPlayer == null) { - audioPlayer = AudioPlayer(); - } - setState(() { - if (checker == "Haa") { - stop(); - play(); - } - if (checker == "Nahi") { - if (playerState == PlayerState.playing) { - play(); - } else { - //Using (Hack) Play() here Else UI glitch is being caused, Will try to find better solution. - play(); - pause(); - } - } - }); - - _positionSubscription = audioPlayer.onAudioPositionChanged - .listen((p) => {if (mounted) setState(() => position = p)}); - - _audioPlayerStateSubscription = - audioPlayer.onPlayerStateChanged.listen((s) { - if (s == AudioPlayerState.PLAYING) { - { - if (mounted) setState(() => duration = audioPlayer.duration); - } - } else if (s == AudioPlayerState.STOPPED) { - onComplete(); - if (mounted) - setState(() { - position = duration; - }); - } - }, onError: (msg) { - if (mounted) - setState(() { - playerState = PlayerState.stopped; - duration = Duration(seconds: 0); - position = Duration(seconds: 0); - }); - }); - } - - Future play() async { - await audioPlayer.play(kUrl); - if (mounted) - setState(() { - playerState = PlayerState.playing; - }); - } - - Future pause() async { - await audioPlayer.pause(); - setState(() { - playerState = PlayerState.paused; - }); - } - - Future stop() async { - await audioPlayer.stop(); - if (mounted) - setState(() { - playerState = PlayerState.stopped; - position = Duration(); - }); - } - - Future mute(bool muted) async { - await audioPlayer.mute(muted); - if (mounted) - setState(() { - isMuted = muted; - }); - } - - void onComplete() { - if (mounted) setState(() => playerState = PlayerState.stopped); - } - @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xff384850), - Color(0xff263238), - Color(0xff263238), - //Color(0xff61e88a), - ], - ), - ), - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - brightness: Brightness.dark, - backgroundColor: Colors.transparent, - elevation: 0, - //backgroundColor: Color(0xff384850), - centerTitle: true, - title: GradientText( - "Now Playing", - shaderRect: Rect.fromLTWH(13.0, 0.0, 100.0, 50.0), - gradient: LinearGradient(colors: [ - Color(0xff4db6ac), - Color(0xff61e88a), - ]), - style: TextStyle( - color: accent, - fontSize: 25, - fontWeight: FontWeight.w700, - ), - ), - leading: Padding( - padding: const EdgeInsets.only(left: 14.0), - child: IconButton( - icon: Icon( - Icons.keyboard_arrow_down, - size: 32, - color: accent, - ), - onPressed: () => Navigator.pop(context, false), - ), - ), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(top: 35.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 350, - height: 350, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - shape: BoxShape.rectangle, - image: DecorationImage( - fit: BoxFit.fill, - image: CachedNetworkImageProvider(image), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 35.0, bottom: 35), - child: Column( - children: [ - GradientText( - title, - shaderRect: Rect.fromLTWH(13.0, 0.0, 100.0, 50.0), - gradient: LinearGradient(colors: [ - Color(0xff4db6ac), - Color(0xff61e88a), - ]), - textScaleFactor: 2.5, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, fontWeight: FontWeight.w700), - ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - album + " | " + artist, - textAlign: TextAlign.center, - style: TextStyle( - color: accentLight, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - Material(child: _buildPlayer()), - ], - ), - ), - ), - ), - ); + return const MusicPlayerLayout(); } - - Widget _buildPlayer() => Container( - padding: EdgeInsets.only(top: 15.0, left: 16, right: 16, bottom: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (duration != null) - Slider( - activeColor: accent, - inactiveColor: Colors.green[50], - value: position?.inMilliseconds?.toDouble() ?? 0.0, - onChanged: (double value) { - return audioPlayer.seek((value / 1000).roundToDouble()); - }, - min: 0.0, - max: duration.inMilliseconds.toDouble()), - if (position != null) _buildProgressView(), - Padding( - padding: const EdgeInsets.only(top: 18.0), - child: Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - isPlaying - ? Container() - : Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Color(0xff4db6ac), - //Color(0xff00c754), - Color(0xff61e88a), - ], - ), - borderRadius: BorderRadius.circular(100)), - child: IconButton( - onPressed: isPlaying ? null : () => play(), - iconSize: 40.0, - icon: Padding( - padding: const EdgeInsets.only(left: 2.2), - child: Icon(MdiIcons.playOutline), - ), - color: Color(0xff263238), - ), - ), - isPlaying - ? Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Color(0xff4db6ac), - //Color(0xff00c754), - Color(0xff61e88a), - ], - ), - borderRadius: BorderRadius.circular(100)), - child: IconButton( - onPressed: isPlaying ? () => pause() : null, - iconSize: 40.0, - icon: Icon(MdiIcons.pause), - color: Color(0xff263238), - ), - ) - : Container() - ], - ), - Padding( - padding: const EdgeInsets.only(top: 40.0), - child: Builder(builder: (context) { - return FlatButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18.0)), - color: Colors.black12, - onPressed: () { - showBottomSheet( - context: context, - builder: (context) => Container( - decoration: BoxDecoration( - color: Color(0xff212c31), - borderRadius: BorderRadius.only( - topLeft: - const Radius.circular(18.0), - topRight: - const Radius.circular(18.0))), - height: 400, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only( - top: 10.0), - child: Row( - children: [ - IconButton( - icon: Icon( - Icons.arrow_back_ios, - color: accent, - size: 20, - ), - onPressed: () => { - Navigator.pop(context) - }), - Expanded( - child: Padding( - padding: - const EdgeInsets.only( - right: 42.0), - child: Center( - child: Text( - "Lyrics", - style: TextStyle( - color: accent, - fontSize: 30, - fontWeight: - FontWeight.w500, - ), - ), - ), - ), - ), - ], - ), - ), - lyrics != "null" - ? Expanded( - flex: 1, - child: Padding( - padding: - const EdgeInsets.all( - 6.0), - child: Center( - child: - SingleChildScrollView( - child: Text( - lyrics, - style: TextStyle( - fontSize: 16.0, - color: - accentLight, - ), - textAlign: TextAlign - .center, - ), - ), - )), - ) - : Padding( - padding: - const EdgeInsets.only( - top: 120.0), - child: Center( - child: Container( - child: Text( - "No Lyrics available ;(", - style: TextStyle( - color: accentLight, - fontSize: 25), - ), - ), - ), - ), - ], - ), - )); - }, - child: Text( - "Lyrics", - style: TextStyle(color: accent), - )); - }), - ) - ], - ), - ), - ], - ), - ); - - Row _buildProgressView() => Row(mainAxisSize: MainAxisSize.min, children: [ - Text( - position != null - ? "${positionText ?? ''} ".replaceFirst("0:0", "0") - : duration != null - ? durationText - : '', - style: TextStyle(fontSize: 18.0, color: Colors.green[50]), - ), - Spacer(), - Text( - position != null - ? "${durationText ?? ''}".replaceAll("0:", "") - : duration != null - ? durationText - : '', - style: TextStyle(fontSize: 18.0, color: Colors.green[50]), - ) - ]); -} +} \ No newline at end of file diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart new file mode 100644 index 0000000..703d92c --- /dev/null +++ b/lib/providers/app_state_provider.dart @@ -0,0 +1,355 @@ +import 'package:flutter/material.dart'; +import 'package:Musify/models/app_models.dart'; + +/// AppStateProvider following industry standards for global app state management +/// Manages theme, settings, navigation state, and app-wide configurations +/// Uses Provider pattern with ChangeNotifier for reactive UI updates +class AppStateProvider extends ChangeNotifier { + // Private fields + ThemeMode _themeMode = ThemeMode.dark; + bool _isFirstLaunch = true; + String _appVersion = '2.1.0'; + bool _isDeveloperMode = false; + AppError? _globalError; + bool _isNetworkAvailable = true; + Map _userPreferences = {}; + + // Audio quality preferences + bool _preferHighQuality = true; + bool _autoPlayNext = false; + double _defaultVolume = 1.0; + + // Download preferences + String _downloadQuality = '320'; // '96', '160', '320' + bool _downloadOverWifiOnly = true; + String _downloadPath = ''; + + // UI preferences + bool _showLyrics = true; + bool _enableAnimations = true; + Color _accentColor = const Color(0xff61e88a); + + /// Constructor + AppStateProvider() { + // Load preferences asynchronously to avoid blocking UI + Future.microtask(() => _loadPreferences()); + } + + // Public getters + ThemeMode get themeMode => _themeMode; + bool get isFirstLaunch => _isFirstLaunch; + String get appVersion => _appVersion; + bool get isDeveloperMode => _isDeveloperMode; + AppError? get globalError => _globalError; + bool get isNetworkAvailable => _isNetworkAvailable; + Map get userPreferences => + Map.unmodifiable(_userPreferences); + + // Audio preferences + bool get preferHighQuality => _preferHighQuality; + bool get autoPlayNext => _autoPlayNext; + double get defaultVolume => _defaultVolume; + + // Download preferences + String get downloadQuality => _downloadQuality; + bool get downloadOverWifiOnly => _downloadOverWifiOnly; + String get downloadPath => _downloadPath; + + // UI preferences + bool get showLyrics => _showLyrics; + bool get enableAnimations => _enableAnimations; + Color get accentColor => _accentColor; + + // Computed properties + bool get isDarkMode => _themeMode == ThemeMode.dark; + bool get isLightMode => _themeMode == ThemeMode.light; + bool get isSystemMode => _themeMode == ThemeMode.system; + bool get hasGlobalError => _globalError != null; + String get downloadQualityDisplay => '${_downloadQuality}kbps'; + + /// Load user preferences (in a real app, this would come from SharedPreferences) + Future _loadPreferences() async { + try { + debugPrint('📱 Loading user preferences...'); + + // Simulate loading from persistent storage + // In a real implementation, use SharedPreferences or secure storage + // Reduced delay to 10ms to minimize startup blocking + await Future.delayed(const Duration(milliseconds: 10)); + + // Default preferences loaded + debugPrint('✅ User preferences loaded'); + notifyListeners(); + } catch (e) { + debugPrint('❌ Failed to load preferences: $e'); + _setGlobalError(AppError( + message: 'Failed to load user preferences', + details: e.toString(), + )); + } + } + + /// Save user preferences + Future _savePreferences() async { + try { + // In a real implementation, save to SharedPreferences + await Future.delayed(Duration(milliseconds: 50)); + debugPrint('✅ User preferences saved'); + } catch (e) { + debugPrint('❌ Failed to save preferences: $e'); + } + } + + /// Set theme mode + Future setThemeMode(ThemeMode mode) async { + if (_themeMode != mode) { + _themeMode = mode; + await _savePreferences(); + notifyListeners(); + debugPrint('🎨 Theme mode changed to: $mode'); + } + } + + /// Toggle dark mode + Future toggleDarkMode() async { + final newMode = isDarkMode ? ThemeMode.light : ThemeMode.dark; + await setThemeMode(newMode); + } + + /// Set first launch completed + Future setFirstLaunchCompleted() async { + if (_isFirstLaunch) { + _isFirstLaunch = false; + await _savePreferences(); + notifyListeners(); + debugPrint('📱 First launch completed'); + } + } + + /// Toggle developer mode + Future toggleDeveloperMode() async { + _isDeveloperMode = !_isDeveloperMode; + await _savePreferences(); + notifyListeners(); + debugPrint('🔧 Developer mode: $_isDeveloperMode'); + } + + /// Set network availability + void setNetworkAvailability(bool isAvailable) { + if (_isNetworkAvailable != isAvailable) { + _isNetworkAvailable = isAvailable; + notifyListeners(); + debugPrint('🌐 Network available: $isAvailable'); + } + } + + /// Set global error + void _setGlobalError(AppError error) { + _globalError = error; + notifyListeners(); + debugPrint('🚨 Global error set: ${error.message}'); + } + + /// Clear global error + void clearGlobalError() { + if (_globalError != null) { + _globalError = null; + notifyListeners(); + debugPrint('✅ Global error cleared'); + } + } + + /// Audio preference setters + Future setPreferHighQuality(bool prefer) async { + if (_preferHighQuality != prefer) { + _preferHighQuality = prefer; + await _savePreferences(); + notifyListeners(); + } + } + + Future setAutoPlayNext(bool autoPlay) async { + if (_autoPlayNext != autoPlay) { + _autoPlayNext = autoPlay; + await _savePreferences(); + notifyListeners(); + } + } + + Future setDefaultVolume(double volume) async { + volume = volume.clamp(0.0, 1.0); + if (_defaultVolume != volume) { + _defaultVolume = volume; + await _savePreferences(); + notifyListeners(); + } + } + + /// Download preference setters + Future setDownloadQuality(String quality) async { + if (['96', '160', '320'].contains(quality) && _downloadQuality != quality) { + _downloadQuality = quality; + await _savePreferences(); + notifyListeners(); + } + } + + Future setDownloadOverWifiOnly(bool wifiOnly) async { + if (_downloadOverWifiOnly != wifiOnly) { + _downloadOverWifiOnly = wifiOnly; + await _savePreferences(); + notifyListeners(); + } + } + + Future setDownloadPath(String path) async { + if (_downloadPath != path) { + _downloadPath = path; + await _savePreferences(); + notifyListeners(); + } + } + + /// UI preference setters + Future setShowLyrics(bool show) async { + if (_showLyrics != show) { + _showLyrics = show; + await _savePreferences(); + notifyListeners(); + } + } + + Future setEnableAnimations(bool enable) async { + if (_enableAnimations != enable) { + _enableAnimations = enable; + await _savePreferences(); + notifyListeners(); + } + } + + Future setAccentColor(Color color) async { + if (_accentColor != color) { + _accentColor = color; + await _savePreferences(); + notifyListeners(); + } + } + + /// Get user preference by key + T? getUserPreference(String key, [T? defaultValue]) { + return _userPreferences[key] as T? ?? defaultValue; + } + + /// Set user preference + Future setUserPreference(String key, T value) async { + _userPreferences[key] = value; + await _savePreferences(); + notifyListeners(); + } + + /// Remove user preference + Future removeUserPreference(String key) async { + if (_userPreferences.containsKey(key)) { + _userPreferences.remove(key); + await _savePreferences(); + notifyListeners(); + } + } + + /// Reset all preferences to defaults + Future resetToDefaults() async { + _themeMode = ThemeMode.dark; + _preferHighQuality = true; + _autoPlayNext = false; + _defaultVolume = 1.0; + _downloadQuality = '320'; + _downloadOverWifiOnly = true; + _downloadPath = ''; + _showLyrics = true; + _enableAnimations = true; + _accentColor = const Color(0xff61e88a); + _userPreferences.clear(); + + await _savePreferences(); + notifyListeners(); + debugPrint('🔄 Preferences reset to defaults'); + } + + /// Get theme data based on current settings + ThemeData getLightThemeData() { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: _accentColor, + brightness: Brightness.light, + ), + fontFamily: "DMSans", + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + ); + } + + ThemeData getDarkThemeData() { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: _accentColor, + brightness: Brightness.dark, + ), + fontFamily: "DMSans", + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + canvasColor: Colors.transparent, + ); + } + + /// Get app info + Map getAppInfo() { + return { + 'version': _appVersion, + 'name': 'Musify', + 'description': 'Music Streaming and Downloading app made in Flutter!', + }; + } + + /// Performance monitoring + Map getPerformanceInfo() { + return { + 'enableAnimations': _enableAnimations, + 'preferHighQuality': _preferHighQuality, + 'isDeveloperMode': _isDeveloperMode, + }; + } + + /// Debug information + Map getDebugInfo() { + return { + 'themeMode': _themeMode.toString(), + 'isFirstLaunch': _isFirstLaunch, + 'appVersion': _appVersion, + 'isDeveloperMode': _isDeveloperMode, + 'isNetworkAvailable': _isNetworkAvailable, + 'preferHighQuality': _preferHighQuality, + 'autoPlayNext': _autoPlayNext, + 'downloadQuality': _downloadQuality, + 'downloadOverWifiOnly': _downloadOverWifiOnly, + 'showLyrics': _showLyrics, + 'enableAnimations': _enableAnimations, + 'accentColor': _accentColor.toString(), + 'hasGlobalError': _globalError != null, + 'globalError': _globalError?.toString(), + 'userPreferencesCount': _userPreferences.length, + }; + } + + /// Cleanup resources + @override + void dispose() { + debugPrint('🧹 Disposing AppStateProvider...'); + super.dispose(); + } +} diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart new file mode 100644 index 0000000..9b452e4 --- /dev/null +++ b/lib/providers/music_player_provider.dart @@ -0,0 +1,605 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:Musify/models/app_models.dart'; +import 'package:Musify/services/audio_player_service.dart'; +import 'package:Musify/API/saavn.dart' as saavn_api; + +/// MusicPlayerProvider following industry standards for state management +/// Manages audio playback state, current song, and player controls +/// Uses Provider pattern with ChangeNotifier for reactive UI updates +class MusicPlayerProvider extends ChangeNotifier { + // Private fields + late final AudioPlayerService _audioService; + Song? _currentSong; + PlaybackState _playbackState = PlaybackState.stopped; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + AppError? _error; + bool _isInitialized = false; + double _volume = 1.0; + bool _isMuted = false; + bool _isLoopEnabled = false; // Loop/repeat mode + + // Track last processing state for completion detection + ProcessingState _lastProcessingState = ProcessingState.idle; + + // Album queue management + List> _albumQueue = []; + int _currentSongIndexInAlbum = -1; + String _currentAlbumId = ''; + bool _isLoadingAlbumQueue = false; + + // Stream subscriptions for cleanup + StreamSubscription? _stateSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _durationSubscription; + StreamSubscription? _errorSubscription; + + // Throttling for position updates to reduce UI redraws + DateTime _lastPositionUpdate = DateTime.now(); + + /// Constructor + MusicPlayerProvider() { + _audioService = AudioPlayerService(); + // Delay initialization to avoid race condition with JustAudioBackground.init() + Future.microtask(() => _initializeService()); + } + + // Public getters + Song? get currentSong => _currentSong; + PlaybackState get playbackState => _playbackState; + Duration get position => _position; + Duration get duration => _duration; + AppError? get error => _error; + bool get isInitialized => _isInitialized; + double get volume => _volume; + bool get isMuted => _isMuted; + bool get isLoopEnabled => _isLoopEnabled; + bool get hasNextSong => + _currentSongIndexInAlbum >= 0 && + _currentSongIndexInAlbum < _albumQueue.length - 1; + bool get hasPreviousSong => _currentSongIndexInAlbum > 0; + bool get isLoadingAlbumQueue => _isLoadingAlbumQueue; + + // Computed properties + bool get isPlaying => _playbackState == PlaybackState.playing; + bool get isPaused => _playbackState == PlaybackState.paused; + bool get isLoading => _playbackState == PlaybackState.loading; + bool get isError => _playbackState == PlaybackState.error; + bool get isStopped => _playbackState == PlaybackState.stopped; + bool get hasCurrentSong => _currentSong != null; + + String get positionText => _formatDuration(_position); + String get durationText => _formatDuration(_duration); + double get progress => _duration.inMilliseconds > 0 + ? _position.inMilliseconds / _duration.inMilliseconds + : 0.0; + + /// Initialize the audio service and set up listeners + Future _initializeService() async { + try { + debugPrint('🎵 Initializing MusicPlayerProvider...'); + + // Initialize audio service + if (!_audioService.isInitialized) { + await _audioService.initialize(); + } + + // Set up stream listeners + _setupStreamListeners(); + + // Register notification callbacks for next/previous + _audioService.setOnSkipToNext(() { + debugPrint('⏭️ Next triggered from notification'); + playNext(); + }); + _audioService.setOnSkipToPrevious(() { + debugPrint('⏮️ Previous triggered from notification'); + playPrevious(); + }); + + // Update initial state + _playbackState = _mapPlayerState(_audioService.playerState); + _position = _audioService.position; + _duration = _audioService.duration; + _isInitialized = true; + + debugPrint('✅ MusicPlayerProvider initialized successfully'); + notifyListeners(); + } catch (e) { + debugPrint('❌ Failed to initialize MusicPlayerProvider: $e'); + _setError( + AppError.audio('Failed to initialize audio player', e.toString())); + } + } + + /// Set up stream listeners for reactive updates + void _setupStreamListeners() { + _stateSubscription = _audioService.stateStream.listen( + (PlayerState state) { + final oldProcessingState = _lastProcessingState; + _playbackState = _mapPlayerState(state); + _lastProcessingState = + state.processingState; // Update last processing state + _clearError(); // Clear error on successful state change + + // Handle song completion based on loop mode + // Check if processingState changed to completed (regardless of playing state) + if (oldProcessingState != ProcessingState.completed && + state.processingState == ProcessingState.completed) { + // Log the current state for debugging + debugPrint('🎵 === SONG COMPLETED ==='); + debugPrint(' Old Processing State: $oldProcessingState'); + debugPrint(' New Processing State: ${state.processingState}'); + debugPrint(' Loop Enabled: $_isLoopEnabled'); + debugPrint(' Has Next Song: $hasNextSong'); + debugPrint(' Current Song: ${_currentSong?.title}'); + debugPrint(' Playing: ${state.playing}'); + + if (_isLoopEnabled) { + // Loop mode: Restart the current song + debugPrint('🔁 Loop mode ON - Restarting current song...'); + Future.delayed(const Duration(milliseconds: 400), () async { + if (_currentSong != null) { + debugPrint('🔁 Replaying: ${_currentSong!.title}'); + try { + // For just_audio, after completion we need to seek then play (not resume) + final seekSuccess = await _audioService.seek(Duration.zero); + debugPrint(' Seek to start: ${seekSuccess ? "✅" : "❌"}'); + + // Small delay to let seek complete + await Future.delayed(const Duration(milliseconds: 100)); + + final playSuccess = await _audioService.resume(); + debugPrint(' Resume playback: ${playSuccess ? "✅" : "❌"}'); + + if (seekSuccess && playSuccess) { + debugPrint('✅ Loop playback started successfully'); + } else { + debugPrint('❌ Loop playback failed - trying full replay'); + // Fallback: replay the entire song if seek+resume fails + await playSong(_currentSong!); + } + } catch (e) { + debugPrint('❌ Loop playback error: $e'); + // Fallback: replay the entire song + await playSong(_currentSong!); + } + } else { + debugPrint('❌ Cannot loop - no current song'); + } + }); + } else if (hasNextSong) { + // Normal mode: Play next song in album + debugPrint('⏭️ Loop mode OFF - Playing next song in album...'); + Future.delayed(const Duration(milliseconds: 500), () { + playNext(); + }); + } else { + debugPrint('⏹️ Song completed - No loop, no next song available'); + } + } + + notifyListeners(); + }, + onError: (error) { + debugPrint('❌ Player state stream error: $error'); + _setError(AppError.audio('Player state error', error.toString())); + }, + ); + + _positionSubscription = _audioService.positionStream.listen( + (Duration position) { + _position = position; + + // Throttle position updates to max once per 200ms to reduce UI redraws + final now = DateTime.now(); + if (now.difference(_lastPositionUpdate).inMilliseconds >= 200) { + _lastPositionUpdate = now; + notifyListeners(); + } + }, + onError: (error) { + debugPrint('❌ Position stream error: $error'); + }, + ); + + _durationSubscription = _audioService.durationStream.listen( + (Duration duration) { + _duration = duration; + // Update current song with duration if available + if (_currentSong != null && _currentSong!.duration == null) { + _currentSong = _currentSong!.copyWith(duration: duration); + } + notifyListeners(); + }, + onError: (error) { + debugPrint('❌ Duration stream error: $error'); + }, + ); + + _errorSubscription = _audioService.errorStream.listen( + (String error) { + _setError(AppError.audio('Audio playback error', error)); + }, + ); + } + + /// Play a song + Future playSong(Song song) async { + try { + debugPrint('🎵 Playing song: ${song.title} by ${song.artist}'); + + // Set loading state temporarily until audio handler starts + // This provides immediate UI feedback + _playbackState = PlaybackState.loading; + _currentSong = song; + _clearError(); + notifyListeners(); + + // Validate audio URL + if (song.audioUrl.isEmpty || Uri.tryParse(song.audioUrl) == null) { + throw Exception('Invalid audio URL for song: ${song.title}'); + } + + // Play the song using audio service with metadata for background playback + final success = await _audioService.play( + song.audioUrl, + title: song.title, + artist: song.artist, + album: song.album, + artworkUrl: song.imageUrl, + songId: song.id, + ); + + if (!success) { + throw Exception('Failed to start playback'); + } + + debugPrint('✅ Song playback started successfully'); + + // Load album queue in background if album ID is available + if (song.albumId.isNotEmpty) { + _loadAlbumQueueInBackground(song.albumId, song.id); + } + + // Note: The actual playing state will be set by the stream listener + } catch (e) { + debugPrint('❌ Failed to play song: $e'); + _playbackState = PlaybackState.error; + _setError(AppError.audio('Failed to play ${song.title}', e.toString())); + } + } + + /// Load album queue in background + Future _loadAlbumQueueInBackground( + String albumId, String currentSongId) async { + // Don't reload if we already have this album + if (_currentAlbumId == albumId && _albumQueue.isNotEmpty) { + debugPrint('💿 Album queue already loaded for: $albumId'); + // Just update current song index + _updateCurrentSongIndex(currentSongId); + return; + } + + try { + _isLoadingAlbumQueue = true; + _currentAlbumId = albumId; + notifyListeners(); + + debugPrint('💿 Loading album queue for: $albumId'); + _albumQueue = await saavn_api.fetchAlbumDetails(albumId); + + if (_albumQueue.isNotEmpty) { + debugPrint('✅ Loaded ${_albumQueue.length} songs from album'); + _updateCurrentSongIndex(currentSongId); + } else { + debugPrint('⚠️ No songs found in album'); + _currentSongIndexInAlbum = -1; + } + } catch (e) { + debugPrint('❌ Failed to load album queue: $e'); + _albumQueue = []; + _currentSongIndexInAlbum = -1; + } finally { + _isLoadingAlbumQueue = false; + notifyListeners(); + } + } + + /// Update current song index in album queue + void _updateCurrentSongIndex(String songId) { + _currentSongIndexInAlbum = + _albumQueue.indexWhere((song) => song['id'] == songId); + if (_currentSongIndexInAlbum >= 0) { + debugPrint( + '📍 Current song index in album: $_currentSongIndexInAlbum/${_albumQueue.length}'); + } + } + + /// Play next song in album + Future playNext() async { + if (!hasNextSong) { + debugPrint('⚠️ No next song available'); + return; + } + + try { + final nextIndex = _currentSongIndexInAlbum + 1; + final nextSongData = _albumQueue[nextIndex]; + + debugPrint('⏭️ Playing next song: ${nextSongData['title']}'); + + // Fetch full song details + final searchProvider = await _getSongFromId(nextSongData['id']); + if (searchProvider != null) { + await playSong(searchProvider); + } + } catch (e) { + debugPrint('❌ Failed to play next song: $e'); + _setError(AppError.audio('Failed to play next song', e.toString())); + } + } + + /// Play previous song in album + Future playPrevious() async { + if (!hasPreviousSong) { + debugPrint('⚠️ No previous song available'); + return; + } + + try { + final previousIndex = _currentSongIndexInAlbum - 1; + final previousSongData = _albumQueue[previousIndex]; + + debugPrint('⏮️ Playing previous song: ${previousSongData['title']}'); + + // Fetch full song details + final song = await _getSongFromId(previousSongData['id']); + if (song != null) { + await playSong(song); + } + } catch (e) { + debugPrint('❌ Failed to play previous song: $e'); + _setError(AppError.audio('Failed to play previous song', e.toString())); + } + } + + /// Helper method to get full song details from ID + Future _getSongFromId(String songId) async { + try { + final success = await saavn_api.fetchSongDetails(songId); + if (success) { + return Song( + id: songId, + title: saavn_api.title, + artist: saavn_api.artist, + album: saavn_api.album, + imageUrl: saavn_api.image, + audioUrl: saavn_api.kUrl, + albumId: saavn_api.albumId, + duration: Duration.zero, + ); + } + return null; + } catch (e) { + debugPrint('❌ Failed to get song details: $e'); + return null; + } + } + + /// Pause playback + Future pause() async { + try { + final success = await _audioService.pause(); + if (!success) { + debugPrint('⚠️ Pause operation failed'); + } + } catch (e) { + debugPrint('❌ Failed to pause: $e'); + _setError(AppError.audio('Failed to pause playback', e.toString())); + } + } + + /// Resume playback + Future resume() async { + try { + final success = await _audioService.resume(); + if (!success) { + debugPrint('⚠️ Resume operation failed'); + } + } catch (e) { + debugPrint('❌ Failed to resume: $e'); + _setError(AppError.audio('Failed to resume playback', e.toString())); + } + } + + /// Stop playback + Future stop() async { + try { + final success = await _audioService.stop(); + if (!success) { + debugPrint('⚠️ Stop operation failed'); + } + // Don't clear current song on stop, just reset position + _position = Duration.zero; + notifyListeners(); + } catch (e) { + debugPrint('❌ Failed to stop: $e'); + _setError(AppError.audio('Failed to stop playback', e.toString())); + } + } + + /// Seek to position + Future seek(Duration position) async { + try { + // Validate position + if (position.isNegative || position > _duration) { + debugPrint('⚠️ Invalid seek position: $position'); + return; + } + + final success = await _audioService.seek(position); + if (!success) { + debugPrint('⚠️ Seek operation failed'); + } + } catch (e) { + debugPrint('❌ Failed to seek: $e'); + _setError(AppError.audio('Failed to seek to position', e.toString())); + } + } + + /// Set volume (0.0 to 1.0) + Future setVolume(double volume) async { + try { + volume = volume.clamp(0.0, 1.0); + final success = await _audioService.setVolume(volume); + + if (success) { + _volume = volume; + _isMuted = volume == 0.0; + notifyListeners(); + } + } catch (e) { + debugPrint('❌ Failed to set volume: $e'); + _setError(AppError.audio('Failed to adjust volume', e.toString())); + } + } + + /// Toggle mute + Future toggleMute() async { + if (_isMuted) { + await setVolume(_volume > 0 ? _volume : 1.0); + } else { + await setVolume(0.0); + } + } + + /// Toggle loop/repeat mode + void toggleLoop() { + _isLoopEnabled = !_isLoopEnabled; + debugPrint('🔁 ========================'); + debugPrint('🔁 Loop button toggled!'); + debugPrint('🔁 New state: ${_isLoopEnabled ? 'ON ✅' : 'OFF ❌'}'); + debugPrint('🔁 Current song: ${_currentSong?.title ?? 'None'}'); + debugPrint('🔁 ========================'); + notifyListeners(); + } + + /// Clear current song + void clearCurrentSong() { + _currentSong = null; + _position = Duration.zero; + _duration = Duration.zero; + notifyListeners(); + } + + /// Clear error + void _clearError() { + if (_error != null) { + _error = null; + notifyListeners(); + } + } + + /// Set error + void _setError(AppError error) { + _error = error; + _playbackState = PlaybackState.error; + notifyListeners(); + } + + /// Map just_audio PlayerState to our PlaybackState + PlaybackState _mapPlayerState(PlayerState playerState) { + // just_audio PlayerState has: playing (bool) and processingState (ProcessingState enum) + if (playerState.processingState == ProcessingState.loading || + playerState.processingState == ProcessingState.buffering) { + return PlaybackState.loading; + } else if (playerState.processingState == ProcessingState.completed) { + return PlaybackState.completed; + } else if (playerState.playing) { + return PlaybackState.playing; + } else if (playerState.processingState == ProcessingState.ready) { + return PlaybackState.paused; + } else { + return PlaybackState.stopped; + } + } + + /// Format duration for display + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60)); + String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60)); + + if (duration.inHours > 0) { + return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds"; + } else { + return "$twoDigitMinutes:$twoDigitSeconds"; + } + } + + /// Toggle play/pause + Future togglePlayPause() async { + if (isPlaying) { + await pause(); + } else if (isPaused) { + await resume(); + } else if (_currentSong != null) { + await playSong(_currentSong!); + } + } + + /// Get current song info for UI + Map getCurrentSongInfo() { + if (_currentSong == null) { + return { + 'title': 'No song selected', + 'artist': '', + 'album': '', + 'imageUrl': '', + }; + } + + return { + 'title': _currentSong!.title, + 'artist': _currentSong!.artist, + 'album': _currentSong!.album, + 'imageUrl': _currentSong!.imageUrl, + }; + } + + /// Cleanup resources + @override + void dispose() { + debugPrint('🧹 Disposing MusicPlayerProvider...'); + + // Cancel stream subscriptions + _stateSubscription?.cancel(); + _positionSubscription?.cancel(); + _durationSubscription?.cancel(); + _errorSubscription?.cancel(); + + // Note: Don't dispose AudioPlayerService as it's a singleton + // It will be managed by the service itself + + super.dispose(); + } + + /// Debug information + Map getDebugInfo() { + return { + 'isInitialized': _isInitialized, + 'playbackState': _playbackState.toString(), + 'currentSong': _currentSong?.title ?? 'None', + 'position': positionText, + 'duration': durationText, + 'progress': progress, + 'volume': _volume, + 'isMuted': _isMuted, + 'hasError': _error != null, + 'error': _error?.toString(), + }; + } +} diff --git a/lib/providers/search_provider.dart b/lib/providers/search_provider.dart new file mode 100644 index 0000000..bec0d05 --- /dev/null +++ b/lib/providers/search_provider.dart @@ -0,0 +1,351 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:Musify/models/app_models.dart'; +import 'package:Musify/API/saavn.dart' as saavn_api; + +/// Helper function to parse search results in background isolate +List _parseSearchResults(List rawResults) { + return rawResults + .map((json) => Song.fromSearchResult(json as Map)) + .toList(); +} + +/// Helper function to parse top songs in background isolate +List _parseTopSongs(List rawSongs) { + return rawSongs + .map((json) => Song.fromTopSong(json as Map)) + .toList(); +} + +/// SearchProvider following industry standards for state management +/// Manages search state, results, loading states, and top songs +/// Uses Provider pattern with ChangeNotifier for reactive UI updates +class SearchProvider extends ChangeNotifier { + // Private fields + List _searchResults = []; + List _topSongs = []; + String _searchQuery = ''; + AppLoadingState _searchLoadingState = AppLoadingState.idle; + AppLoadingState _topSongsLoadingState = AppLoadingState.idle; + AppError? _searchError; + AppError? _topSongsError; + Timer? _searchDebounceTimer; + + // Search configuration + static const Duration _searchDebounceDelay = Duration(milliseconds: 500); + static const int _maxSearchResults = 50; + + /// Constructor + SearchProvider() { + // Delay top songs loading to avoid blocking UI during app startup + // Use 2 second delay to let the UI fully render and settle + Future.delayed(const Duration(milliseconds: 2000), () { + _loadTopSongs(); + }); + } + + // Public getters + List get searchResults => List.unmodifiable(_searchResults); + List get topSongs => List.unmodifiable(_topSongs); + String get searchQuery => _searchQuery; + AppLoadingState get searchLoadingState => _searchLoadingState; + AppLoadingState get topSongsLoadingState => _topSongsLoadingState; + AppError? get searchError => _searchError; + AppError? get topSongsError => _topSongsError; + + // Computed properties + bool get isSearching => _searchLoadingState == AppLoadingState.loading; + bool get isLoadingTopSongs => + _topSongsLoadingState == AppLoadingState.loading; + bool get hasSearchResults => _searchResults.isNotEmpty; + bool get hasTopSongs => _topSongs.isNotEmpty; + bool get hasSearchError => _searchError != null; + bool get hasTopSongsError => _topSongsError != null; + bool get showSearchResults => _searchQuery.isNotEmpty && hasSearchResults; + bool get showTopSongs => _searchQuery.isEmpty && hasTopSongs; + + /// Search for songs with debouncing + Future searchSongs(String query) async { + // Cancel previous search timer + _searchDebounceTimer?.cancel(); + + // Update query immediately for UI + _searchQuery = query.trim(); + + // Clear results if query is empty + if (_searchQuery.isEmpty) { + _clearSearchResults(); + return; + } + + // Set loading state immediately + _searchLoadingState = AppLoadingState.loading; + _clearSearchError(); + notifyListeners(); + + // Debounce search requests + _searchDebounceTimer = Timer(_searchDebounceDelay, () { + _performSearch(_searchQuery); + }); + } + + /// Perform the actual search + Future _performSearch(String query) async { + try { + debugPrint('🔍 Searching for: $query'); + + // Call the existing API function + List rawResults = await saavn_api.fetchSongsList(query); + + // Parse JSON in background isolate to avoid blocking UI + List songs = await compute( + _parseSearchResults, + rawResults.take(_maxSearchResults).toList(), + ); + + // Update state + _searchResults = songs; + _searchLoadingState = AppLoadingState.success; + _clearSearchError(); + + debugPrint('✅ Search completed: ${songs.length} results'); + notifyListeners(); + } catch (e) { + debugPrint('❌ Search failed: $e'); + _searchLoadingState = AppLoadingState.error; + _setSearchError(AppError.network('Search failed', e.toString())); + } + } + + /// Load top songs + Future _loadTopSongs() async { + try { + debugPrint('🎵 Loading top songs...'); + + _topSongsLoadingState = AppLoadingState.loading; + _clearTopSongsError(); + notifyListeners(); + + // Call the existing API function + List rawTopSongs = await saavn_api.topSongs(); + + // Parse JSON in background isolate to avoid blocking UI + List songs = await compute(_parseTopSongs, rawTopSongs); + + // Update state + _topSongs = songs; + _topSongsLoadingState = AppLoadingState.success; + _clearTopSongsError(); + + debugPrint('✅ Top songs loaded: ${songs.length} songs'); + notifyListeners(); + } catch (e) { + debugPrint('❌ Failed to load top songs: $e'); + _topSongsLoadingState = AppLoadingState.error; + _setTopSongsError( + AppError.network('Failed to load top songs', e.toString())); + } + } + + /// Refresh top songs (pull to refresh) + Future refreshTopSongs() async { + await _loadTopSongs(); + } + + /// Clear search results + void clearSearch() { + _searchQuery = ''; + _clearSearchResults(); + } + + /// Clear search results (private) + void _clearSearchResults() { + _searchResults = []; + _searchLoadingState = AppLoadingState.idle; + _clearSearchError(); + notifyListeners(); + } + + /// Get song details (for playing) + Future getSongDetails(Song song) async { + try { + debugPrint('🎵 Getting details for song: ${song.title}'); + + // Call the existing API function to get full song details + bool success = await saavn_api.fetchSongDetails(song.id); + + if (!success) { + throw Exception('Failed to fetch song details'); + } + + // Create updated song with audio URL from global variables + // Note: This uses the existing global variables temporarily + // until we fully refactor the API layer + Song updatedSong = song.copyWith( + audioUrl: saavn_api.kUrl, + albumId: saavn_api.albumId, + lyrics: saavn_api.lyrics, + hasLyrics: saavn_api.has_lyrics == 'true', + has320Quality: saavn_api.has_320 == 'true', + ); + + debugPrint('✅ Song details obtained: ${updatedSong.audioUrl}'); + return updatedSong; + } catch (e) { + debugPrint('❌ Failed to get song details: $e'); + return null; + } + } + + /// Search and get song ready for playing + Future searchAndPrepareSong(String songId) async { + // Find song in current results + Song? song = _findSongById(songId); + + if (song == null) { + debugPrint('⚠️ Song not found in current results: $songId'); + return null; + } + + // Get full details with audio URL + return await getSongDetails(song); + } + + /// Find song by ID in current results + Song? _findSongById(String songId) { + // Search in search results first + for (Song song in _searchResults) { + if (song.id == songId) { + return song; + } + } + + // Search in top songs + for (Song song in _topSongs) { + if (song.id == songId) { + return song; + } + } + + return null; + } + + /// Get filtered search results + List getFilteredResults({String? artistFilter, String? albumFilter}) { + List results = _searchResults; + + if (artistFilter != null && artistFilter.isNotEmpty) { + results = results + .where((song) => + song.artist.toLowerCase().contains(artistFilter.toLowerCase())) + .toList(); + } + + if (albumFilter != null && albumFilter.isNotEmpty) { + results = results + .where((song) => + song.album.toLowerCase().contains(albumFilter.toLowerCase())) + .toList(); + } + + return results; + } + + /// Get search suggestions (based on current results) + List getSearchSuggestions() { + Set suggestions = {}; + + // Add artist names + for (Song song in _searchResults) { + suggestions.add(song.artist); + } + + // Add album names + for (Song song in _searchResults) { + if (song.album.isNotEmpty && song.album != 'Unknown Album') { + suggestions.add(song.album); + } + } + + return suggestions.take(10).toList(); + } + + /// Clear search error + void _clearSearchError() { + if (_searchError != null) { + _searchError = null; + notifyListeners(); + } + } + + /// Set search error + void _setSearchError(AppError error) { + _searchError = error; + notifyListeners(); + } + + /// Clear top songs error + void _clearTopSongsError() { + if (_topSongsError != null) { + _topSongsError = null; + notifyListeners(); + } + } + + /// Set top songs error + void _setTopSongsError(AppError error) { + _topSongsError = error; + notifyListeners(); + } + + /// Retry failed operations + Future retrySearch() async { + if (_searchQuery.isNotEmpty) { + await _performSearch(_searchQuery); + } + } + + Future retryTopSongs() async { + await _loadTopSongs(); + } + + /// Update search query without triggering search (for UI) + void updateSearchQuery(String query) { + _searchQuery = query; + notifyListeners(); + } + + /// Get popular artists from top songs + List getPopularArtists() { + Set artists = {}; + for (Song song in _topSongs) { + if (song.artist.isNotEmpty && song.artist != 'Unknown Artist') { + artists.add(song.artist); + } + } + return artists.take(10).toList(); + } + + /// Cleanup resources + @override + void dispose() { + debugPrint('🧹 Disposing SearchProvider...'); + _searchDebounceTimer?.cancel(); + super.dispose(); + } + + /// Debug information + Map getDebugInfo() { + return { + 'searchQuery': _searchQuery, + 'searchResultsCount': _searchResults.length, + 'topSongsCount': _topSongs.length, + 'searchLoadingState': _searchLoadingState.toString(), + 'topSongsLoadingState': _topSongsLoadingState.toString(), + 'hasSearchError': _searchError != null, + 'hasTopSongsError': _topSongsError != null, + 'searchError': _searchError?.toString(), + 'topSongsError': _topSongsError?.toString(), + }; + } +} diff --git a/lib/services/audio_player_service.dart b/lib/services/audio_player_service.dart new file mode 100644 index 0000000..3f24c33 --- /dev/null +++ b/lib/services/audio_player_service.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'package:just_audio/just_audio.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:Musify/main.dart' show audioHandler; +import 'package:Musify/services/background_audio_handler.dart' + show createMediaItem; + +/// Singleton AudioPlayer service using audio_service for background playback +/// Provides gapless playback, better buffering, enhanced control, and background audio +class AudioPlayerService { + // Singleton pattern implementation + static final AudioPlayerService _instance = AudioPlayerService._internal(); + factory AudioPlayerService() => _instance; + AudioPlayerService._internal(); + + // Private fields + AudioPlayer? _audioPlayer; + StreamSubscription? _positionSubscription; + StreamSubscription? _durationSubscription; + StreamSubscription? _currentIndexSubscription; + + // State management + PlayerState _playerState = PlayerState(false, ProcessingState.idle); + Duration _duration = Duration.zero; + Duration _position = Duration.zero; + String _currentUrl = ""; + bool _isInitialized = false; + + // Stream controllers for reactive state management + final StreamController _stateController = + StreamController.broadcast(); + final StreamController _positionController = + StreamController.broadcast(); + final StreamController _durationController = + StreamController.broadcast(); + final StreamController _errorController = + StreamController.broadcast(); + + // Public getters + PlayerState get playerState => _playerState; + Duration get duration => _duration; + Duration get position => _position; + String get currentUrl => _currentUrl; + bool get isInitialized => _isInitialized; + bool get isPlaying => _playerState.playing; + bool get isPaused => + !_playerState.playing && + _playerState.processingState != ProcessingState.idle; + bool get isStopped => _playerState.processingState == ProcessingState.idle; + + // Public streams for UI updates + Stream get stateStream => _stateController.stream; + Stream get positionStream => _positionController.stream; + Stream get durationStream => _durationController.stream; + Stream get errorStream => _errorController.stream; + + /// Initialize the audio player service + /// Should be called once during app startup + Future initialize() async { + try { + if (_isInitialized) { + debugPrint('🎵 AudioPlayerService already initialized'); + return; + } + + // Set up stream listeners from audio handler + _setupStreamListenersFromHandler(); + + _isInitialized = true; + debugPrint('✅ AudioPlayerService initialized successfully'); + } catch (e) { + debugPrint('❌ AudioPlayerService initialization failed: $e'); + _handleError('Failed to initialize audio player: $e'); + } + } + + /// Set up stream listeners from audio handler + void _setupStreamListenersFromHandler() { + try { + // Listen to playback state from audio handler + audioHandler.playbackState.listen( + (PlaybackState state) { + // Map audio_service AudioProcessingState to just_audio ProcessingState + ProcessingState processingState; + switch (state.processingState) { + case AudioProcessingState.idle: + processingState = ProcessingState.idle; + break; + case AudioProcessingState.loading: + processingState = ProcessingState.loading; + break; + case AudioProcessingState.buffering: + processingState = ProcessingState.buffering; + break; + case AudioProcessingState.ready: + processingState = ProcessingState.ready; + break; + case AudioProcessingState.completed: + processingState = ProcessingState.completed; + break; + case AudioProcessingState.error: + processingState = ProcessingState.idle; + if (state.errorMessage != null) { + _handleError('Playback error: ${state.errorMessage}'); + } + break; + default: + processingState = ProcessingState.idle; + } + + // Only broadcast if state actually changed + final newState = PlayerState(state.playing, processingState); + if (newState.playing != _playerState.playing || + newState.processingState != _playerState.processingState) { + _playerState = newState; + _stateController.add(_playerState); + debugPrint( + '🎵 State changed: playing=${state.playing}, processingState=$processingState'); + } + }, + onError: (error) { + debugPrint('❌ Playback state stream error: $error'); + _handleError('Player state error: $error'); + }, + ); + + // Get position updates from audio handler's player + _positionSubscription = audioHandler.audioPlayer.positionStream.listen( + (Duration position) { + _position = position; + _positionController.add(position); + }, + onError: (error) { + debugPrint('❌ Position stream error: $error'); + }, + ); + + // Get duration updates from audio handler's player + _durationSubscription = audioHandler.audioPlayer.durationStream.listen( + (Duration? duration) { + if (duration != null) { + _duration = duration; + _durationController.add(duration); + } + }, + onError: (error) { + debugPrint('❌ Duration stream error: $error'); + }, + ); + + debugPrint('✅ Stream subscriptions set up successfully'); + } catch (e) { + debugPrint('❌ Failed to set up stream subscriptions: $e'); + _handleError('Stream setup failed: $e'); + } + } + + /// Play audio from URL with proper error handling and retry logic + Future play( + String url, { + String? title, + String? artist, + String? album, + String? artworkUrl, + String? songId, + }) async { + try { + if (!_isInitialized) { + await initialize(); + } + + // Validate URL + if (url.isEmpty || Uri.tryParse(url) == null) { + throw Exception('Invalid URL provided: $url'); + } + + debugPrint('🎵 Playing: $url'); + + // Update current URL + _currentUrl = url; + + // Create media item for notification + final mediaItem = createMediaItem( + id: songId ?? url, + title: title ?? 'Unknown Title', + artist: artist ?? 'Unknown Artist', + album: album ?? '', + artUri: artworkUrl, + ); + + // Play through audio handler for background support + await audioHandler.playFromUrl(url, mediaItem); + + debugPrint('✅ Playback started with background support'); + return true; + } catch (e) { + debugPrint('❌ Play failed: $e'); + _handleError('Playback failed: $e'); + return false; + } + } + + /// Pause playback + Future pause() async { + try { + await audioHandler.pause(); + debugPrint('⏸️ Playback paused'); + return true; + } catch (e) { + debugPrint('❌ Pause failed: $e'); + _handleError('Pause failed: $e'); + return false; + } + } + + /// Resume playback + Future resume() async { + try { + await audioHandler.play(); + debugPrint('▶️ Playback resumed'); + return true; + } catch (e) { + debugPrint('❌ Resume failed: $e'); + _handleError('Resume failed: $e'); + return false; + } + } + + /// Stop playback and reset position + Future stop() async { + try { + await audioHandler.stop(); + _position = Duration.zero; + _positionController.add(_position); + debugPrint('⏹️ Playback stopped'); + return true; + } catch (e) { + debugPrint('❌ Stop failed: $e'); + _handleError('Stop failed: $e'); + return false; + } + } + + /// Seek to specific position + Future seek(Duration position) async { + try { + // Validate position + if (position.isNegative || position > _duration) { + debugPrint('⚠️ Invalid seek position: $position'); + return false; + } + + await audioHandler.seek(position); + debugPrint('🎯 Seeked to: $position'); + return true; + } catch (e) { + debugPrint('❌ Seek failed: $e'); + _handleError('Seek failed: $e'); + return false; + } + } + + /// Set volume (0.0 to 1.0) + Future setVolume(double volume) async { + try { + // Clamp volume to valid range + volume = volume.clamp(0.0, 1.0); + + await audioHandler.setVolume(volume); + debugPrint('🔊 Volume set to: $volume'); + return true; + } catch (e) { + debugPrint('❌ Set volume failed: $e'); + _handleError('Volume adjustment failed: $e'); + return false; + } + } + + /// Set callback for skip to next action from notification + void setOnSkipToNext(VoidCallback callback) { + audioHandler.onSkipToNext = callback; + debugPrint('✅ Skip to next callback registered'); + } + + /// Set callback for skip to previous action from notification + void setOnSkipToPrevious(VoidCallback callback) { + audioHandler.onSkipToPrevious = callback; + debugPrint('✅ Skip to previous callback registered'); + } + + /// Handle errors and emit them to UI + void _handleError(String error) { + debugPrint('🚨 AudioPlayerService error: $error'); + _errorController.add(error); + } + + /// Dispose current player and subscriptions + Future _disposeCurrentPlayer() async { + try { + // Cancel all subscriptions + await _positionSubscription?.cancel(); + await _durationSubscription?.cancel(); + await _currentIndexSubscription?.cancel(); + + // Clear subscriptions + _positionSubscription = null; + _durationSubscription = null; + _currentIndexSubscription = null; + + // Dispose audio player + if (_audioPlayer != null) { + await _audioPlayer!.dispose(); + _audioPlayer = null; + } + + debugPrint('🧹 AudioPlayer and subscriptions disposed'); + } catch (e) { + debugPrint('⚠️ Error during disposal: $e'); + // Continue with disposal even if there are errors + _audioPlayer = null; + } + } + + /// Complete cleanup - call this when app is terminating + Future dispose() async { + try { + debugPrint('🧹 Disposing AudioPlayerService...'); + + // Stop playback first + await stop(); + + // Dispose player and subscriptions + await _disposeCurrentPlayer(); + + // Close stream controllers + await _stateController.close(); + await _positionController.close(); + await _durationController.close(); + await _errorController.close(); + + // Reset state + _playerState = PlayerState(false, ProcessingState.idle); + _duration = Duration.zero; + _position = Duration.zero; + _currentUrl = ""; + _isInitialized = false; + + debugPrint('✅ AudioPlayerService disposed completely'); + } catch (e) { + debugPrint('❌ Error during AudioPlayerService disposal: $e'); + } + } + + /// Get current player instance (for advanced use cases) + /// Use with caution - prefer using service methods + AudioPlayer? get audioPlayer => _audioPlayer; +} diff --git a/lib/services/background_audio_handler.dart b/lib/services/background_audio_handler.dart new file mode 100644 index 0000000..4d64a0f --- /dev/null +++ b/lib/services/background_audio_handler.dart @@ -0,0 +1,353 @@ +import 'dart:async'; +import 'package:audio_service/audio_service.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:flutter/foundation.dart'; + +/// Background Audio Handler using audio_service +/// Enables background playback and lock screen media controls +/// Integrates with just_audio for audio playback +class MusifyAudioHandler extends BaseAudioHandler with SeekHandler { + // Audio player instance + final AudioPlayer _audioPlayer = AudioPlayer(); + + // State management + MediaItem? _currentMediaItem; + bool _isInitialized = false; + + // Stream subscriptions for cleanup + StreamSubscription? _playerStateSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _durationSubscription; + + // Callbacks for next/previous actions (to be set by MusicPlayerProvider) + VoidCallback? onSkipToNext; + VoidCallback? onSkipToPrevious; + + /// Constructor + MusifyAudioHandler() { + _init(); + } + + /// Initialize the audio handler + Future _init() async { + try { + debugPrint('🎵 Initializing MusifyAudioHandler...'); + + // Set up stream listeners + _setupAudioPlayerListeners(); + + _isInitialized = true; + debugPrint('✅ MusifyAudioHandler initialized successfully'); + } catch (e) { + debugPrint('❌ Failed to initialize MusifyAudioHandler: $e'); + } + } + + /// Set up audio player stream listeners + void _setupAudioPlayerListeners() { + // Listen to player state changes + _playerStateSubscription = _audioPlayer.playerStateStream.listen( + (playerState) { + _updatePlaybackState(playerState); + }, + onError: (error) { + debugPrint('❌ Player state stream error: $error'); + _broadcastError(error.toString()); + }, + ); + + // Listen to position changes + _positionSubscription = _audioPlayer.positionStream.listen( + (position) { + // Don't update position if song is completed + if (_audioPlayer.processingState != ProcessingState.completed) { + playbackState.add(playbackState.value.copyWith( + updatePosition: position, + )); + } + }, + onError: (error) { + debugPrint('❌ Position stream error: $error'); + }, + ); + + // Listen to duration changes + _durationSubscription = _audioPlayer.durationStream.listen( + (duration) { + if (duration != null && _currentMediaItem != null) { + // Update media item with actual duration + mediaItem.add(_currentMediaItem!.copyWith(duration: duration)); + } + }, + onError: (error) { + debugPrint('❌ Duration stream error: $error'); + }, + ); + } + + /// Update playback state based on player state + void _updatePlaybackState(PlayerState playerState) { + final processingState = _mapProcessingState(playerState.processingState); + final playing = playerState.playing; + + // When song completes, set position to duration to stop slider movement + final position = playerState.processingState == ProcessingState.completed + ? (_audioPlayer.duration ?? _audioPlayer.position) + : _audioPlayer.position; + + playbackState.add( + PlaybackState( + controls: _getControls(playing, playerState.processingState), + androidCompactActionIndices: const [0, 1, 2], + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + }, + processingState: processingState, + playing: playing, + updatePosition: position, + bufferedPosition: _audioPlayer.bufferedPosition, + speed: _audioPlayer.speed, + ), + ); + } + + /// Map just_audio ProcessingState to audio_service AudioProcessingState + AudioProcessingState _mapProcessingState(ProcessingState state) { + switch (state) { + case ProcessingState.idle: + return AudioProcessingState.idle; + case ProcessingState.loading: + return AudioProcessingState.loading; + case ProcessingState.buffering: + return AudioProcessingState.buffering; + case ProcessingState.ready: + return AudioProcessingState.ready; + case ProcessingState.completed: + return AudioProcessingState.completed; + default: + return AudioProcessingState.idle; + } + } + + /// Get media controls based on current state + List _getControls( + bool playing, ProcessingState processingState) { + // When song completes, show play button (not pause) + final isCompleted = processingState == ProcessingState.completed; + + return [ + const MediaControl( + androidIcon: 'drawable/ic_action_skip_previous', + label: 'Previous', + action: MediaAction.skipToPrevious, + ), + if (playing && !isCompleted) + const MediaControl( + androidIcon: 'drawable/ic_action_pause', + label: 'Pause', + action: MediaAction.pause, + ) + else + const MediaControl( + androidIcon: 'drawable/ic_action_play_arrow', + label: 'Play', + action: MediaAction.play, + ), + const MediaControl( + androidIcon: 'drawable/ic_action_skip_next', + label: 'Next', + action: MediaAction.skipToNext, + ), + const MediaControl( + androidIcon: 'drawable/ic_action_stop', + label: 'Stop', + action: MediaAction.stop, + ), + ]; + } + + /// Broadcast error to UI + void _broadcastError(String error) { + playbackState.add( + playbackState.value.copyWith( + processingState: AudioProcessingState.error, + errorMessage: error, + ), + ); + } + + // ========== Audio Service API Implementation ========== + + @override + Future play() async { + try { + debugPrint('▶️ Play command received'); + await _audioPlayer.play(); + } catch (e) { + debugPrint('❌ Play failed: $e'); + _broadcastError('Failed to play: $e'); + } + } + + @override + Future pause() async { + try { + debugPrint('⏸️ Pause command received'); + await _audioPlayer.pause(); + } catch (e) { + debugPrint('❌ Pause failed: $e'); + _broadcastError('Failed to pause: $e'); + } + } + + @override + Future stop() async { + try { + debugPrint('⏹️ Stop command received'); + await _audioPlayer.stop(); + await _audioPlayer.seek(Duration.zero); + + // Update playback state to stopped + playbackState.add( + PlaybackState( + controls: _getControls(false, ProcessingState.idle), + processingState: AudioProcessingState.idle, + playing: false, + ), + ); + } catch (e) { + debugPrint('❌ Stop failed: $e'); + _broadcastError('Failed to stop: $e'); + } + } + + @override + Future seek(Duration position) async { + try { + debugPrint('🎯 Seek to: $position'); + await _audioPlayer.seek(position); + } catch (e) { + debugPrint('❌ Seek failed: $e'); + _broadcastError('Failed to seek: $e'); + } + } + + @override + Future skipToNext() async { + debugPrint('⏭️ Skip to next from notification'); + if (onSkipToNext != null) { + onSkipToNext!(); + } else { + debugPrint('⚠️ onSkipToNext callback not set'); + } + } + + @override + Future skipToPrevious() async { + debugPrint('⏮️ Skip to previous from notification'); + if (onSkipToPrevious != null) { + onSkipToPrevious!(); + } else { + debugPrint('⚠️ onSkipToPrevious callback not set'); + } + } + + @override + Future setSpeed(double speed) async { + try { + debugPrint('🏃 Set speed to: $speed'); + await _audioPlayer.setSpeed(speed); + } catch (e) { + debugPrint('❌ Set speed failed: $e'); + _broadcastError('Failed to set speed: $e'); + } + } + + /// Custom method: Play from URL with media item information + Future playFromUrl(String url, MediaItem item) async { + try { + debugPrint('🎵 Playing from URL: $url'); + debugPrint('📀 Media: ${item.title} by ${item.artist}'); + + // Update current media item + _currentMediaItem = item; + mediaItem.add(item); + + // Set audio source and play + await _audioPlayer.setUrl(url); + await _audioPlayer.play(); + + debugPrint('✅ Playback started successfully'); + } catch (e) { + debugPrint('❌ Failed to play from URL: $e'); + _broadcastError('Failed to play: $e'); + rethrow; + } + } + + /// Custom method: Set volume + Future setVolume(double volume) async { + try { + volume = volume.clamp(0.0, 1.0); + await _audioPlayer.setVolume(volume); + debugPrint('🔊 Volume set to: $volume'); + } catch (e) { + debugPrint('❌ Set volume failed: $e'); + } + } + + /// Get current position + Duration get position => _audioPlayer.position; + + /// Get current duration + Duration? get duration => _audioPlayer.duration; + + /// Get audio player instance (for advanced use cases) + AudioPlayer get audioPlayer => _audioPlayer; + + /// Check if handler is initialized + bool get isInitialized => _isInitialized; + + /// Cleanup resources + Future dispose() async { + try { + debugPrint('🧹 Disposing MusifyAudioHandler...'); + + // Cancel subscriptions + await _playerStateSubscription?.cancel(); + await _positionSubscription?.cancel(); + await _durationSubscription?.cancel(); + + // Stop and dispose audio player + await _audioPlayer.stop(); + await _audioPlayer.dispose(); + + _isInitialized = false; + debugPrint('✅ MusifyAudioHandler disposed'); + } catch (e) { + debugPrint('❌ Error disposing MusifyAudioHandler: $e'); + } + } +} + +/// Helper function to create MediaItem from Song +MediaItem createMediaItem({ + required String id, + required String title, + required String artist, + required String album, + String? artUri, + Duration? duration, +}) { + return MediaItem( + id: id, + title: title, + artist: artist, + album: album, + artUri: artUri != null && artUri.isNotEmpty ? Uri.parse(artUri) : null, + duration: duration, + playable: true, + ); +} diff --git a/lib/shared/shared.dart b/lib/shared/shared.dart new file mode 100644 index 0000000..5f5dcae --- /dev/null +++ b/lib/shared/shared.dart @@ -0,0 +1,2 @@ +// Shared exports - widgets and utilities +export 'widgets/app_widgets.dart'; diff --git a/lib/shared/widgets/app_widgets.dart b/lib/shared/widgets/app_widgets.dart new file mode 100644 index 0000000..04cdaa3 --- /dev/null +++ b/lib/shared/widgets/app_widgets.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../../core/constants/app_colors.dart'; +import '../../core/constants/app_constants.dart'; + +/// Reusable image widgets that eliminate code duplication +/// Provides consistent image loading patterns across the app +class AppImageWidgets { + // Private constructor to prevent instantiation + AppImageWidgets._(); + + /// Optimized album art widget for large images (music player) + static Widget albumArt({ + required String imageUrl, + double? width, + double? height, + BorderRadius? borderRadius, + Color? backgroundColor, + Color? accentColor, + }) { + return RepaintBoundary( + child: ClipRRect( + borderRadius: + borderRadius ?? BorderRadius.circular(AppConstants.borderRadius), + child: CachedNetworkImage( + imageUrl: imageUrl, + width: width ?? AppConstants.albumArtSize, + height: height ?? AppConstants.albumArtSize, + fit: BoxFit.cover, + memCacheWidth: AppConstants.imageCacheWidth, + memCacheHeight: AppConstants.imageCacheHeight, + maxWidthDiskCache: AppConstants.imageCacheWidth, + maxHeightDiskCache: AppConstants.imageCacheHeight, + filterQuality: FilterQuality + .medium, // Changed from high to medium for better performance + placeholder: (context, url) => Container( + width: width ?? AppConstants.albumArtSize, + height: height ?? AppConstants.albumArtSize, + color: Colors.black12, + ), + errorWidget: (context, url, error) => Container( + width: width ?? AppConstants.albumArtSize, + height: height ?? AppConstants.albumArtSize, + color: Colors.transparent, // Completely transparent + ), + ), + ), + ); + } + + /// Optimized thumbnail widget for small images (lists, mini-player) + static Widget thumbnail({ + required String imageUrl, + double? size, + BorderRadius? borderRadius, + Color? backgroundColor, + }) { + final imageSize = size ?? AppConstants.thumbnailSize; + + return RepaintBoundary( + child: ClipRRect( + borderRadius: + borderRadius ?? BorderRadius.circular(AppConstants.borderRadius), + child: CachedNetworkImage( + imageUrl: imageUrl, + width: imageSize, + height: imageSize, + fit: BoxFit.cover, + memCacheWidth: AppConstants.thumbnailCacheSize, + memCacheHeight: AppConstants.thumbnailCacheSize, + maxWidthDiskCache: AppConstants.thumbnailCacheSize, + maxHeightDiskCache: AppConstants.thumbnailCacheSize, + filterQuality: FilterQuality + .low, // Low quality for thumbnails improves performance + placeholder: (context, url) => Container( + width: imageSize, + height: imageSize, + color: Colors.grey[800], + ), + errorWidget: (context, url, error) => Container( + width: imageSize, + height: imageSize, + color: Colors.transparent, // Completely transparent + ), + ), + ), + ); + } +} + +/// Reusable container widgets that eliminate gradient duplication +class AppContainerWidgets { + // Private constructor to prevent instantiation + AppContainerWidgets._(); + + /// Primary gradient background container + static Widget gradientBackground({ + required Widget child, + Gradient? gradient, + }) { + return Container( + decoration: BoxDecoration( + gradient: gradient ?? AppColors.primaryGradient, + ), + child: child, + ); + } + + /// Gradient button container + static Widget gradientButton({ + required Widget child, + required VoidCallback onPressed, + Gradient? gradient, + BorderRadius? borderRadius, + EdgeInsets? padding, + }) { + return Container( + decoration: BoxDecoration( + gradient: gradient ?? AppColors.buttonGradient, + borderRadius: borderRadius ?? + BorderRadius.circular(AppConstants.buttonBorderRadius), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: borderRadius ?? + BorderRadius.circular(AppConstants.buttonBorderRadius), + child: Padding( + padding: + padding ?? const EdgeInsets.all(AppConstants.defaultPadding), + child: child, + ), + ), + ), + ); + } + + /// Card container with consistent styling + static Widget appCard({ + required Widget child, + EdgeInsets? margin, + EdgeInsets? padding, + Color? color, + BorderRadius? borderRadius, + }) { + return Container( + margin: margin ?? + const EdgeInsets.symmetric( + vertical: AppConstants.smallPadding / 2, + ), + child: Card( + color: color ?? AppColors.cardBackground, + shape: RoundedRectangleBorder( + borderRadius: borderRadius ?? + BorderRadius.circular(AppConstants.cardBorderRadius), + ), + child: Padding( + padding: padding ?? const EdgeInsets.all(AppConstants.defaultPadding), + child: child, + ), + ), + ); + } +} diff --git a/lib/shared/widgets/skeleton_loader.dart b/lib/shared/widgets/skeleton_loader.dart new file mode 100644 index 0000000..00ae686 --- /dev/null +++ b/lib/shared/widgets/skeleton_loader.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; + +/// A shimmer effect widget that doesn't require any third-party dependencies +/// Based on Flutter's official documentation and examples +class ShimmerWidget extends StatefulWidget { + final Widget child; + final Duration period; + final Color baseColor; + final Color highlightColor; + + const ShimmerWidget({ + super.key, + required this.child, + this.period = const Duration(milliseconds: 1500), + this.baseColor = const Color(0xFF323238), + this.highlightColor = const Color(0xFF525257), + }); + + @override + State createState() => _ShimmerWidgetState(); +} + +class _ShimmerWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.period, + vsync: this, + ); + _animation = Tween( + begin: -1.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + )); + _controller.repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + widget.baseColor, + widget.highlightColor, + widget.baseColor, + ], + stops: const [0.0, 0.5, 1.0], + transform: GradientRotation(_animation.value * 0.5), + ).createShader(bounds); + }, + child: widget.child, + ); + }, + ); + } +} + +/// Skeleton loading widget for song cards +class SongCardSkeleton extends StatelessWidget { + const SongCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + elevation: 2, + child: ShimmerWidget( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Album Art Skeleton + Expanded( + flex: 3, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12.0), + topRight: Radius.circular(12.0), + ), + ), + ), + ), + // Song Info Skeleton + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title skeleton + Container( + width: double.infinity, + height: 14, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + SizedBox(height: 4), + Container( + width: 120, + height: 14, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + SizedBox(height: 8), + // Artist skeleton + Container( + width: 80, + height: 12, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + Spacer(), + // Download button skeleton + Align( + alignment: Alignment.centerRight, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Skeleton loading widget for search result items +class SearchResultSkeleton extends StatelessWidget { + const SearchResultSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 5, bottom: 5), + child: Card( + color: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + elevation: 0, + child: ShimmerWidget( + child: ListTile( + leading: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + ), + title: Container( + width: double.infinity, + height: 16, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Container( + width: 120, + height: 14, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + ), + trailing: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ), + ); + } +} + +/// Search results list skeleton +class SearchResultsListSkeleton extends StatelessWidget { + final int itemCount; + + const SearchResultsListSkeleton({ + super.key, + this.itemCount = 5, + }); + + @override + Widget build(BuildContext context) { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: itemCount, + itemBuilder: (context, index) => const SearchResultSkeleton(), + ); + } +} + +/// Grid skeleton for top songs loading state +class TopSongsGridSkeleton extends StatelessWidget { + final int itemCount; + + const TopSongsGridSkeleton({ + super.key, + this.itemCount = 6, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header skeleton + Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + bottom: 16.0, + top: 8.0, + ), + child: ShimmerWidget( + child: Container( + width: 200, + height: 24, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + // Grid skeleton + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 12.0), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12.0, + mainAxisSpacing: 12.0, + childAspectRatio: 0.8, + ), + itemCount: itemCount, + itemBuilder: (context, index) => const SongCardSkeleton(), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart new file mode 100644 index 0000000..4ba92c8 --- /dev/null +++ b/lib/shared/widgets/widgets.dart @@ -0,0 +1,2 @@ +// Shared widgets barrel file +export 'skeleton_loader.dart'; diff --git a/lib/style/appColors.dart b/lib/style/appColors.dart deleted file mode 100644 index a543ac8..0000000 --- a/lib/style/appColors.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:flutter/material.dart'; - -Color accent = Color(0xff61e88a); -Color accentLight = Colors.green[50]; diff --git a/lib/ui/aboutPage.dart b/lib/ui/aboutPage.dart index aa1e81f..d0ce759 100644 --- a/lib/ui/aboutPage.dart +++ b/lib/ui/aboutPage.dart @@ -1,38 +1,27 @@ import 'package:flutter/material.dart'; -import 'package:gradient_widgets/gradient_widgets.dart'; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; -import 'package:Musify/helper/utils.dart'; -import 'package:Musify/style/appColors.dart'; +import 'package:gradient_widgets_plus/gradient_widgets_plus.dart'; +import 'package:Musify/helper/contact_widget.dart'; +import 'package:Musify/core/constants/app_colors.dart'; class AboutPage extends StatelessWidget { + const AboutPage({super.key}); + @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xff384850), - Color(0xff263238), - Color(0xff263238), - ], - ), + gradient: AppColors.primaryGradient, ), child: Scaffold( backgroundColor: Colors.transparent, appBar: AppBar( - brightness: Brightness.dark, centerTitle: true, title: GradientText( "About", shaderRect: Rect.fromLTWH(13.0, 0.0, 100.0, 50.0), - gradient: LinearGradient(colors: [ - Color(0xff4db6ac), - Color(0xff61e88a), - ]), + gradient: AppColors.buttonGradient, style: TextStyle( - color: accent, + color: AppColors.accent, fontSize: 25, fontWeight: FontWeight.w700, ), @@ -40,20 +29,43 @@ class AboutPage extends StatelessWidget { leading: IconButton( icon: Icon( Icons.arrow_back, - color: accent, + color: AppColors.accent, ), onPressed: () => Navigator.pop(context, false), ), backgroundColor: Colors.transparent, elevation: 0, ), - body: SingleChildScrollView(child: AboutCards()), + // appBar: AppBar( + // systemOverlayStyle: SystemUiOverlayStyle.light, + // centerTitle: true, + // title: Text( + // "About", + // style: TextStyle( + // color: AppColors.accent, + // fontSize: 25, + // fontWeight: FontWeight.w700, + // ), + // ), + // leading: IconButton( + // icon: Icon( + // Icons.arrow_back, + // color: AppColors.accent, + // ), + // onPressed: () => Navigator.pop(context, false), + // ), + // backgroundColor: Colors.transparent, + // elevation: 0, + // ), + body: SingleChildScrollView(child: const AboutCards()), ), ); } } class AboutCards extends StatelessWidget { + const AboutCards({super.key}); + @override Widget build(BuildContext context) { return Material( @@ -65,17 +77,21 @@ class AboutCards extends StatelessWidget { child: Column( children: [ ListTile( - title: Image.network( - "https://telegra.ph/file/4798f3a9303b8300e4b5b.png", + title: Image.asset( + "assets/image.png", height: 120, ), + // title: Image.network( + // "https://telegra.ph/file/4798f3a9303b8300e4b5b.png", + // height: 120, + // ), subtitle: Padding( padding: const EdgeInsets.all(13.0), child: Center( child: Text( "Musify | 2.1.0", style: TextStyle( - color: accentLight, + color: AppColors.textSecondary, fontSize: 24, fontWeight: FontWeight.w600), ), @@ -85,238 +101,45 @@ class AboutCards extends StatelessWidget { ], ), ), - Padding( - padding: const EdgeInsets.only(bottom: 8.0, left: 10, right: 10), - child: Divider( - color: Colors.white24, - thickness: 0.8, - ), + ContactCard( + name: 'Harsh V23', + subtitle: 'App Developer', + imageUrl: 'https://telegram.im/img/harshv23', + telegramUrl: 'https://telegram.dog/harshv23', + xUrl: 'https://x.com/harshv23', + textColor: AppColors.textSecondary, ), - Padding( - padding: - const EdgeInsets.only(top: 8, left: 8, right: 8, bottom: 6), - child: Card( - color: Color(0xff263238), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0)), - elevation: 2.3, - child: ListTile( - leading: Container( - height: 50, - width: 50, - decoration: BoxDecoration( - shape: BoxShape.circle, - image: DecorationImage( - fit: BoxFit.fill, - image: NetworkImage("https://telegram.im/img/harshv23"), - ), - ), - ), - title: Text( - 'Harsh V23', - style: TextStyle(color: accentLight), - ), - subtitle: Text( - 'App Developer', - style: TextStyle(color: accentLight), - ), - trailing: Wrap( - children: [ - IconButton( - icon: Icon( - MdiIcons.telegram, - color: accentLight, - ), - tooltip: 'Contact on Telegram', - onPressed: () { - launchURL("https://telegram.dog/harshv23"); - }, - ), - IconButton( - icon: Icon( - MdiIcons.twitter, - color: accentLight, - ), - tooltip: 'Contact on Twitter', - onPressed: () { - launchURL("https://twitter.com/harshv23"); - }, - ), - ], - ), - ), - ), + ContactCard( + name: 'Sumanjay', + subtitle: 'App Developer', + imageUrl: 'https://telegra.ph/file/a64152b2fae1bf6e7d98e.jpg', + telegramUrl: 'https://telegram.dog/cyberboysumanjay', + xUrl: 'https://x.com/cyberboysj', + textColor: AppColors.textSecondary, ), - Padding( - padding: - const EdgeInsets.only(top: 8, left: 8, right: 8, bottom: 6), - child: Card( - color: Color(0xff263238), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - elevation: 2.3, - child: ListTile( - leading: Container( - width: 50.0, - height: 50, - decoration: BoxDecoration( - shape: BoxShape.circle, - image: DecorationImage( - fit: BoxFit.fill, - image: NetworkImage( - "https://telegra.ph/file/a64152b2fae1bf6e7d98e.jpg"), - ), - ), - ), - title: Text( - 'Sumanjay', - style: TextStyle(color: accentLight), - ), - subtitle: Text( - 'App Developer', - style: TextStyle(color: accentLight), - ), - trailing: Wrap( - children: [ - IconButton( - icon: Icon( - MdiIcons.telegram, - color: accentLight, - ), - tooltip: 'Contact on Telegram', - onPressed: () { - launchURL("https://telegram.dog/cyberboysumanjay"); - }, - ), - IconButton( - icon: Icon( - MdiIcons.twitter, - color: accentLight, - ), - tooltip: 'Contact on Twitter', - onPressed: () { - launchURL("https://twitter.com/cyberboysj"); - }, - ), - ], - ), - ), - ), + ContactCard( + name: 'Dhruvan Bhalara', + subtitle: 'Contributor', + imageUrl: 'https://avatars1.githubusercontent.com/u/53393418?v=4', + telegramUrl: 'https://t.me/dhruvanbhalara', + xUrl: 'https://x.com/dhruvanbhalara', + textColor: AppColors.textSecondary, ), - Padding( - padding: - const EdgeInsets.only(top: 8, left: 8, right: 8, bottom: 6), - child: Card( - color: Color(0xff263238), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - elevation: 2.3, - child: ListTile( - leading: Container( - width: 50.0, - height: 50, - decoration: BoxDecoration( - shape: BoxShape.circle, - image: DecorationImage( - fit: BoxFit.fill, - image: NetworkImage( - "https://avatars1.githubusercontent.com/u/53393418?v=4"), - ), - ), - ), - title: Text( - 'Dhruvan Bhalara', - style: TextStyle(color: accentLight), - ), - subtitle: Text( - 'Contributor', - style: TextStyle(color: accentLight), - ), - trailing: Wrap( - children: [ - IconButton( - icon: Icon( - MdiIcons.telegram, - color: accentLight, - ), - tooltip: 'Contact on Telegram', - onPressed: () { - launchURL("https://t.me/dhruvanbhalara"); - }, - ), - IconButton( - icon: Icon( - MdiIcons.twitter, - color: accentLight, - ), - tooltip: 'Contact on Twitter', - onPressed: () { - launchURL("https://twitter.com/dhruvanbhalara"); - }, - ), - ], - ), - ), - ), + ContactCard( + name: 'Kapil Jhajhria', + subtitle: 'Contributor', + imageUrl: 'https://avatars3.githubusercontent.com/u/6892756?v=4', + telegramUrl: 'https://telegram.dog/kapiljhajhria', + xUrl: 'https://x.com/kapiljhajhria', + textColor: AppColors.textSecondary, ), - Padding( - padding: - const EdgeInsets.only(top: 8, left: 8, right: 8, bottom: 6), - child: Card( - color: Color(0xff263238), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - elevation: 2.3, - child: ListTile( - leading: Container( - width: 50.0, - height: 50, - decoration: BoxDecoration( - shape: BoxShape.circle, - image: DecorationImage( - fit: BoxFit.fill, - image: NetworkImage( - "https://avatars3.githubusercontent.com/u/6892756?v=4"), - ), - ), - ), - title: Text( - 'Kapil Jhajhria', - style: TextStyle(color: accentLight), - ), - subtitle: Text( - 'Contributor', - style: TextStyle(color: accentLight), - ), - trailing: Wrap( - children: [ - IconButton( - icon: Icon( - MdiIcons.telegram, - color: accentLight, - ), - tooltip: 'Contact on Telegram', - onPressed: () { - launchURL("https://telegram.dog/kapiljhajhria"); - }, - ), - IconButton( - icon: Icon( - MdiIcons.twitter, - color: accentLight, - ), - tooltip: 'Contact on Twitter', - onPressed: () { - launchURL("https://twitter.com/kapiljhajhria"); - }, - ), - ], - ), - ), - ), + ContactCard( + name: 'Kunal Kashyap', + subtitle: 'App Developer', + imageUrl: 'https://avatars.githubusercontent.com/u/118793083?v=4', + telegramUrl: 'https://telegram.dog/NinjaApache', + xUrl: 'https://x.com/KashyapK257', + textColor: AppColors.textSecondary, ), ], ), diff --git a/lib/ui/homePage.dart b/lib/ui/homePage.dart index 255156e..8e20ab7 100644 --- a/lib/ui/homePage.dart +++ b/lib/ui/homePage.dart @@ -1,25 +1,25 @@ -import 'dart:io'; -import 'dart:ui'; - -import 'package:audiotagger/audiotagger.dart'; -import 'package:audiotagger/models/tag.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:ext_storage/ext_storage.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:gradient_widgets/gradient_widgets.dart'; -import 'package:http/http.dart' as http; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; -import 'package:Musify/API/saavn.dart'; -import 'package:Musify/music.dart'; -import 'package:Musify/style/appColors.dart'; -import 'package:Musify/ui/aboutPage.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:progress_dialog/progress_dialog.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:provider/provider.dart'; + +import 'package:Musify/music.dart' as music; +import 'package:Musify/providers/music_player_provider.dart'; +import 'package:Musify/providers/search_provider.dart'; +import 'package:Musify/models/app_models.dart'; +import 'package:Musify/core/constants/app_colors.dart'; +import 'package:Musify/shared/widgets/widgets.dart'; + +// Modular imports +import 'package:Musify/features/home/home.dart'; +import 'package:Musify/features/player/player.dart'; +import 'package:Musify/features/download/download.dart'; +import 'package:Musify/features/search/widgets/search_results_list.dart' + as custom_search; class Musify extends StatefulWidget { + const Musify({super.key}); + @override State createState() { return AppState(); @@ -28,576 +28,224 @@ class Musify extends StatefulWidget { class AppState extends State { TextEditingController searchBar = TextEditingController(); - bool fetchingSongs = false; + @override void initState() { super.initState(); SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( - systemNavigationBarColor: Color(0xff1c252a), + systemNavigationBarColor: AppColors.backgroundSecondary, statusBarColor: Colors.transparent, )); } - search() async { + @override + void dispose() { + searchBar.dispose(); + super.dispose(); + } + + // Search functionality + Future search() async { String searchQuery = searchBar.text; if (searchQuery.isEmpty) return; - fetchingSongs = true; - setState(() {}); - await fetchSongsList(searchQuery); - fetchingSongs = false; - setState(() {}); - } - getSongDetails(String id, var context) async { - try { - await fetchSongDetails(id); - print(kUrl); - } catch (e) { - artist = "Unknown"; - print(e); - } - setState(() { - checker = "Haa"; - }); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AudioApp(), - ), - ); + final searchProvider = Provider.of(context, listen: false); + await searchProvider.searchSongs(searchQuery); } - downloadSong(id) async { - String filepath; - String filepath2; - var status = await Permission.storage.status; - if (status.isUndetermined || status.isDenied) { - // code of read or write file in external storage (SD card) - // You can request multiple permissions at once. - Map statuses = await [ - Permission.storage, - ].request(); - debugPrint(statuses[Permission.storage].toString()); - } - status = await Permission.storage.status; - await fetchSongDetails(id); - if (status.isGranted) { - ProgressDialog pr = ProgressDialog(context); - pr = ProgressDialog( - context, - type: ProgressDialogType.Normal, - isDismissible: false, - showLogs: false, - ); + // Get song details and play + Future getSongDetails(String id, var context) async { + try { + debugPrint('🎵 getSongDetails called with ID: $id'); - pr.style( - backgroundColor: Color(0xff263238), - elevation: 4, - textAlign: TextAlign.left, - progressTextStyle: TextStyle(color: Colors.white), - message: "Downloading " + title, - messageTextStyle: TextStyle(color: accent), - progressWidget: Padding( - padding: const EdgeInsets.all(20.0), - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(accent), - ), - ), - ); - await pr.show(); + final searchProvider = + Provider.of(context, listen: false); + final musicPlayer = + Provider.of(context, listen: false); - final filename = title + ".m4a"; - final artname = title + "_artwork.jpg"; - //Directory appDocDir = await getExternalStorageDirectory(); - String dlPath = await ExtStorage.getExternalStoragePublicDirectory( - ExtStorage.DIRECTORY_MUSIC); - await File(dlPath + "/" + filename) - .create(recursive: true) - .then((value) => filepath = value.path); - await File(dlPath + "/" + artname) - .create(recursive: true) - .then((value) => filepath2 = value.path); - debugPrint('Audio path $filepath'); - debugPrint('Image path $filepath2'); - if (has_320 == "true") { - kUrl = rawkUrl.replaceAll("_96.mp4", "_320.mp4"); - final client = http.Client(); - final request = http.Request('HEAD', Uri.parse(kUrl)) - ..followRedirects = false; - final response = await client.send(request); - debugPrint(response.statusCode.toString()); - kUrl = (response.headers['location']); - debugPrint(rawkUrl); - debugPrint(kUrl); - final request2 = http.Request('HEAD', Uri.parse(kUrl)) - ..followRedirects = false; - final response2 = await client.send(request2); - if (response2.statusCode != 200) { - kUrl = kUrl.replaceAll(".mp4", ".mp3"); - } - } - var request = await HttpClient().getUrl(Uri.parse(kUrl)); - var response = await request.close(); - var bytes = await consolidateHttpClientResponseBytes(response); - File file = File(filepath); + // Show loading indicator while fetching song details + EasyLoading.show(status: 'Loading song...'); - var request2 = await HttpClient().getUrl(Uri.parse(image)); - var response2 = await request2.close(); - var bytes2 = await consolidateHttpClientResponseBytes(response2); - File file2 = File(filepath2); + // Get song details with audio URL + Song? song = await searchProvider.searchAndPrepareSong(id); - await file.writeAsBytes(bytes); - await file2.writeAsBytes(bytes2); - debugPrint("Started tag editing"); + if (song == null) { + throw Exception('Song not found or unable to get audio URL'); + } - final tag = Tag( - title: title, - artist: artist, - artwork: filepath2, - album: album, - lyrics: lyrics, - genre: null, + debugPrint('✅ Song details fetched successfully'); + + // Dismiss loading before starting playback + // The bottom player will show loading state during playback initialization + EasyLoading.dismiss(); + debugPrint('✅ EasyLoading dismissed'); + + // Set current song and start playing + // DON'T await - let playback happen in background + // The playSong method sets loading state immediately for UI feedback + musicPlayer.playSong(song); + + debugPrint('🎵 Song playback initiated, navigating to player...'); + + // Small delay to ensure EasyLoading is fully dismissed and playSong sets state + await Future.delayed(const Duration(milliseconds: 100)); + + // Navigate to music player immediately + // Don't wait for playback to start - the player screen will show loading state + if (context.mounted) { + debugPrint('🎵 Context mounted, pushing music player route...'); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + debugPrint('🎵 Music player builder called'); + return const music.AudioApp(); + }, + ), + ).then((value) { + debugPrint('✅ Music player route completed/popped'); + }); + debugPrint('✅ Navigation push called'); + } else { + debugPrint('⚠️ Context not mounted, skipping navigation'); + } + } catch (e) { + EasyLoading.dismiss(); + debugPrint('Error getting song details: $e'); + + // Show error message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to load song: $e'), + backgroundColor: Colors.red, + ), ); + } + } - debugPrint("Setting up Tags"); - final tagger = Audiotagger(); - await tagger.writeTags( - path: filepath, - tag: tag, - ); - await Future.delayed(const Duration(seconds: 1), () {}); - await pr.hide(); + // Download functionality using the modular service + Future downloadSong(String id) async { + await DownloadService.downloadSong(id); + } - if (await file2.exists()) { - await file2.delete(); - } - debugPrint("Done"); - Fluttertoast.showToast( - msg: "Download Complete!", - toastLength: Toast.LENGTH_SHORT, - gravity: ToastGravity.BOTTOM, - timeInSecForIosWeb: 1, - backgroundColor: Colors.black, - textColor: Color(0xff61e88a), - fontSize: 14.0); - } else if (status.isDenied || status.isPermanentlyDenied) { - Fluttertoast.showToast( - msg: "Storage Permission Denied!\nCan't Download Songs", - toastLength: Toast.LENGTH_SHORT, - gravity: ToastGravity.BOTTOM, - timeInSecForIosWeb: 1, - backgroundColor: Colors.black, - textColor: Color(0xff61e88a), - fontSize: 14.0); - } else { - Fluttertoast.showToast( - msg: "Permission Error!", - toastLength: Toast.LENGTH_SHORT, - gravity: ToastGravity.values[50], - timeInSecForIosWeb: 1, - backgroundColor: Colors.black, - textColor: Color(0xff61e88a), - fontSize: 14.0); - } + // Load top songs (called automatically by SearchProvider constructor) + Future topSongs() async { + // Top songs are loaded automatically when SearchProvider is initialized + // This method is kept for compatibility but doesn't need to do anything + } + + // Clear search + void clearSearch() { + final searchProvider = Provider.of(context, listen: false); + searchProvider.clearSearch(); + searchBar.clear(); } @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xff384850), - Color(0xff263238), - Color(0xff263238), - ], - ), + gradient: AppColors.primaryGradient, ), - child: Scaffold( - resizeToAvoidBottomPadding: false, - backgroundColor: Colors.transparent, - //backgroundColor: Color(0xff384850), - bottomNavigationBar: kUrl != "" - ? Container( - height: 75, - //color: Color(0xff1c252a), - decoration: BoxDecoration( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(18), - topRight: Radius.circular(18)), - color: Color(0xff1c252a)), - child: Padding( - padding: const EdgeInsets.only(top: 5.0, bottom: 2), - child: GestureDetector( - onTap: () { - checker = "Nahi"; - if (kUrl != "") { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => AudioApp()), - ); - } - }, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only( - top: 8.0, - ), - child: IconButton( - icon: Icon( - MdiIcons.appleKeyboardControl, - size: 22, - ), - onPressed: null, - disabledColor: accent, - ), - ), - Padding( - padding: const EdgeInsets.only( - left: 0.0, top: 7, bottom: 7, right: 15), - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: CachedNetworkImage( - imageUrl: image, - fit: BoxFit.fill, - ), - ), + child: Consumer( + builder: (context, searchProvider, child) { + return Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: Colors.transparent, + bottomNavigationBar: const BottomPlayer(), + body: Column( + children: [ + // Fixed header and search bar + RepaintBoundary( + child: Padding( + padding: EdgeInsets.all(12.0), + child: Column( + children: [ + // Home header with logo and settings + HomeHeader( + searchController: searchBar, + onClearSearch: clearSearch, ), - Padding( - padding: const EdgeInsets.only(top: 0.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - title, - style: TextStyle( - color: accent, - fontSize: 17, - fontWeight: FontWeight.w600), - ), - Text( - artist, - style: - TextStyle(color: accentLight, fontSize: 15), - ) - ], - ), + + // Search bar + SearchBarWidget( + controller: searchBar, + onSearch: search, ), - Spacer(), - IconButton( - icon: playerState == PlayerState.playing - ? Icon(MdiIcons.pause) - : Icon(MdiIcons.playOutline), - color: accent, - splashColor: Colors.transparent, - onPressed: () { - setState(() { - if (playerState == PlayerState.playing) { - audioPlayer.pause(); - playerState = PlayerState.paused; - } else if (playerState == PlayerState.paused) { - audioPlayer.play(kUrl); - playerState = PlayerState.playing; - } - }); - }, - iconSize: 45, - ) ], ), ), ), - ) - : SizedBox.shrink(), - body: SingleChildScrollView( - padding: EdgeInsets.all(12.0), - child: Column( - children: [ - Padding(padding: EdgeInsets.only(top: 30, bottom: 20.0)), - Center( - child: Row(children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 42.0), - child: Center( - child: GradientText( - "Musify.", - shaderRect: Rect.fromLTWH(13.0, 0.0, 100.0, 50.0), - gradient: LinearGradient(colors: [ - Color(0xff4db6ac), - Color(0xff61e88a), - ]), - style: TextStyle( - fontSize: 35, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - ), - Container( - child: IconButton( - iconSize: 26, - alignment: Alignment.center, - icon: Icon(MdiIcons.dotsVertical), - color: accent, - onPressed: () => { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AboutPage(), - ), - ), - }, - ), - ) - ]), - ), - Padding(padding: EdgeInsets.only(top: 20)), - TextField( - onSubmitted: (String value) { - search(); - }, - controller: searchBar, - style: TextStyle( - fontSize: 16, - color: accent, - ), - cursorColor: Colors.green[50], - decoration: InputDecoration( - fillColor: Color(0xff263238), - filled: true, - enabledBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(100), - ), - borderSide: BorderSide( - color: Color(0xff263238), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(100), - ), - borderSide: BorderSide(color: accent), - ), - suffixIcon: IconButton( - icon: fetchingSongs - ? SizedBox( - height: 18, - width: 18, - child: Center( - child: CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(accent), - ), - ), - ) - : Icon( - Icons.search, - color: accent, - ), - color: accent, - onPressed: () { - search(); - }, - ), - border: InputBorder.none, - hintText: "Search...", - hintStyle: TextStyle( - color: accent, - ), - contentPadding: const EdgeInsets.only( - left: 18, - right: 20, - top: 14, - bottom: 14, + + // Scrollable content area + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: _buildContent(searchProvider), ), ), - ), - searchedList.isNotEmpty - ? ListView.builder( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemCount: searchedList.length, - itemBuilder: (BuildContext ctxt, int index) { - return Padding( - padding: const EdgeInsets.only(top: 5, bottom: 5), - child: Card( - color: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - elevation: 0, - child: InkWell( - borderRadius: BorderRadius.circular(10.0), - onTap: () { - getSongDetails( - searchedList[index]["id"], context); - }, - onLongPress: () { - topSongs(); - }, - splashColor: accent, - hoverColor: accent, - focusColor: accent, - highlightColor: accent, - child: Column( - children: [ - ListTile( - leading: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - MdiIcons.musicNoteOutline, - size: 30, - color: accent, - ), - ), - title: Text( - (searchedList[index]['title']) - .toString() - .split("(")[0] - .replaceAll(""", "\"") - .replaceAll("&", "&"), - style: TextStyle(color: Colors.white), - ), - subtitle: Text( - searchedList[index]['more_info'] - ["singers"], - style: TextStyle(color: Colors.white), - ), - trailing: IconButton( - color: accent, - icon: Icon(MdiIcons.downloadOutline), - onPressed: () => downloadSong( - searchedList[index]["id"]), - ), - ), - ], - ), - ), - ), - ); - }, - ) - : FutureBuilder( - future: topSongs(), - builder: (context, data) { - if (data.hasData) - return Container( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only( - top: 30.0, bottom: 10, left: 8), - child: Text( - "Top 15 Songs", - textAlign: TextAlign.left, - style: TextStyle( - fontSize: 22, - color: accent, - fontWeight: FontWeight.w600, - ), - ), - ), - Container( - //padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0), - height: - MediaQuery.of(context).size.height * 0.22, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: 15, - itemBuilder: (context, index) { - return getTopSong( - data.data[index]["image"], - data.data[index]["title"], - data.data[index]["more_info"] - ["artistMap"] - ["primary_artists"][0]["name"], - data.data[index]["id"]); - }, - ), - ), - ], - ), - ); - return Center( - child: Padding( - padding: const EdgeInsets.all(35.0), - child: CircularProgressIndicator( - valueColor: - new AlwaysStoppedAnimation(accent), - ), - )); - }, - ), - ], - ), - ), + ], + ), + ); + }, ), ); } - Widget getTopSong(String image, String title, String subtitle, String id) { - return InkWell( - onTap: () { - getSongDetails(id, context); - }, - child: Column( - children: [ - Container( - height: MediaQuery.of(context).size.height * 0.17, - width: MediaQuery.of(context).size.width * 0.4, - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), + // Separate method to build content based on search state + Widget _buildContent(SearchProvider searchProvider) { + // Show search results if there's a search query and results + if (searchProvider.showSearchResults) { + return custom_search.SearchResultsList( + onSongTap: getSongDetails, + onDownload: downloadSong, + onLongPress: topSongs, + ); + } + // Show top songs if no search query + else if (searchProvider.showTopSongs) { + return TopSongsGrid( + onSongTap: getSongDetails, + onDownload: downloadSong, + ); + } + // Show skeleton loaders when loading (deferred to avoid initial frame skip) + else if (searchProvider.isSearching || searchProvider.isLoadingTopSongs) { + // Show skeleton grid for top songs loading + if (searchProvider.isLoadingTopSongs) { + return TopSongsGridSkeleton(itemCount: 6); + } + // Show search results skeleton for search + else { + return SearchResultsListSkeleton(itemCount: 5); + } + } + // Default empty state + else { + return Container( + height: 300, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.music_note, + size: 64, + color: AppColors.textSecondary, ), - color: Colors.transparent, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10.0), - image: DecorationImage( - fit: BoxFit.fill, - image: CachedNetworkImageProvider(image), - ), + SizedBox(height: 16), + Text( + 'Search for songs or browse top tracks', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 16, ), ), - ), - ), - SizedBox( - height: 2, - ), - Text( - title - .split("(")[0] - .replaceAll("&", "&") - .replaceAll("'", "'") - .replaceAll(""", "\""), - style: TextStyle( - color: Colors.white, - fontSize: 14.0, - fontWeight: FontWeight.bold, - ), - ), - SizedBox( - height: 2, - ), - Text( - subtitle, - style: TextStyle( - color: Colors.white38, - fontSize: 12.0, - fontWeight: FontWeight.bold, - ), + ], ), - ], - ), - ); + ), + ); + } } } diff --git a/pubspec.lock b/pubspec.lock index 94aab47..a888dc9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,125 +1,182 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" source: hosted - version: "2.5.0-nullsafety.1" - audioplayer: + version: "2.13.0" + audio_service: dependency: "direct main" description: - name: audioplayer - url: "https://pub.dartlang.org" + name: audio_service + sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044 + url: "https://pub.dev" + source: hosted + version: "0.18.18" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" source: hosted - version: "0.8.1" - audioplayer_web: + version: "0.1.4" + audio_session: dependency: "direct main" description: - name: audioplayer_web - url: "https://pub.dartlang.org" + name: audio_session + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" + url: "https://pub.dev" source: hosted - version: "0.7.1" - audiotagger: + version: "0.1.25" + audiotags: dependency: "direct main" description: - name: audiotagger - url: "https://pub.dartlang.org" + name: audiotags + sha256: b09ab98c9b7d516470763d33fe974cab710bea1de24b5811339ac1b9e68268de + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.4.5" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.2" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" cached_network_image: dependency: "direct main" description: name: cached_network_image - url: "https://pub.dartlang.org" + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" source: hosted - version: "2.2.0+1" - characters: + version: "3.4.1" + cached_network_image_platform_interface: dependency: transitive description: - name: characters - url: "https://pub.dartlang.org" + 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.1.0-nullsafety.3" - charcode: + version: "1.3.1" + characters: dependency: transitive description: - name: charcode - url: "https://pub.dartlang.org" + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.4.0" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" source: hosted - version: "1.15.0-nullsafety.3" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" + version: "1.19.1" crypto: dependency: transitive description: name: crypto - url: "https://pub.dartlang.org" + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "3.0.6" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - url: "https://pub.dartlang.org" + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" source: hosted - version: "0.1.3" - des_plugin: + version: "1.0.8" + dart_des: dependency: "direct main" description: - name: des_plugin - url: "https://pub.dartlang.org" + name: dart_des + sha256: "0a66afb8883368c824497fd2a1fd67bdb1a785965a3956728382c03d40747c33" + url: "https://pub.dev" source: hosted - version: "0.0.3" - ext_storage: - dependency: "direct main" - description: - name: ext_storage - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" + version: "1.0.2" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" file: dependency: transitive description: name: file - url: "https://pub.dartlang.org" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" source: hosted - version: "5.2.1" + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -129,9 +186,34 @@ packages: dependency: transitive description: name: flutter_cache_manager - url: "https://pub.dartlang.org" + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "3.4.1" + flutter_easyloading: + dependency: "direct main" + description: + name: flutter_easyloading + sha256: ba21a3c883544e582f9cc455a4a0907556714e1e9cf0eababfcb600da191d17c + url: "https://pub.dev" + source: hosted + version: "3.0.5" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: "35c257fc7f98e34c1314d6c145e5ed54e7c94e8a9f469947e31c9298177d546f" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + flutter_spinkit: + dependency: transitive + description: + name: flutter_spinkit + sha256: "77850df57c00dc218bfe96071d576a8babec24cf58b2ed121c83cca4a2fdce7f" + url: "https://pub.dev" + source: hosted + version: "5.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -146,301 +228,519 @@ packages: dependency: "direct main" description: name: fluttertoast - url: "https://pub.dartlang.org" + sha256: "90778fe0497fe3a09166e8cf2e0867310ff434b794526589e77ec03cf08ba8e8" + url: "https://pub.dev" source: hosted - version: "7.0.2" - gradient_widgets: + version: "8.2.14" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + gradient_widgets_plus: dependency: "direct main" description: - name: gradient_widgets - url: "https://pub.dartlang.org" + name: gradient_widgets_plus + sha256: "3de25d43c5b1de0cbb09bc293c47e5611750a7f02ece4ab0c8f2a3c5fd2773c6" + url: "https://pub.dev" source: hosted - version: "0.5.2" + version: "1.0.0" http: dependency: "direct main" description: name: http - url: "https://pub.dartlang.org" + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" source: hosted - version: "0.12.2" + version: "1.5.0" http_parser: dependency: transitive description: name: http_parser - url: "https://pub.dartlang.org" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" source: hosted - version: "3.1.4" - intl: + version: "4.1.2" + js: dependency: transitive description: - name: intl - url: "https://pub.dartlang.org" + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" source: hosted - version: "0.16.1" - js: + version: "0.7.2" + json_annotation: dependency: transitive description: - name: js - url: "https://pub.dartlang.org" + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" source: hosted - version: "0.6.3-nullsafety.1" + version: "4.9.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e + url: "https://pub.dev" + source: hosted + version: "0.9.46" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" + 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" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" source: hosted - version: "0.12.10-nullsafety.1" + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" material_design_icons_flutter: dependency: "direct main" description: name: material_design_icons_flutter - url: "https://pub.dartlang.org" + sha256: "6f986b7a51f3ad4c00e33c5c84e8de1bdd140489bbcdc8b66fc1283dad4dea5a" + url: "https://pub.dev" source: hosted - version: "4.0.5345" + version: "7.0.7296" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" source: hosted - version: "1.3.0-nullsafety.4" + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" source: hosted - version: "1.8.0-nullsafety.1" + version: "1.9.1" path_provider: dependency: "direct main" description: name: path_provider - url: "https://pub.dartlang.org" + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" source: hosted - version: "1.6.11" - path_provider_linux: + version: "2.1.5" + path_provider_android: dependency: transitive description: - name: path_provider_linux - url: "https://pub.dartlang.org" + name: path_provider_android + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + url: "https://pub.dev" source: hosted - version: "0.0.1+2" - path_provider_macos: + version: "2.2.18" + path_provider_foundation: dependency: transitive description: - name: path_provider_macos - url: "https://pub.dartlang.org" + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" source: hosted - version: "0.0.4+3" + version: "2.4.2" + 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 - url: "https://pub.dartlang.org" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" source: hosted - version: "1.0.2" - pedantic: + version: "2.1.2" + path_provider_windows: dependency: transitive description: - name: pedantic - url: "https://pub.dartlang.org" + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" source: hosted - version: "1.8.0+1" + version: "2.3.0" permission_handler: dependency: "direct main" description: name: permission_handler - url: "https://pub.dartlang.org" + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" source: hosted - version: "5.0.1+1" - permission_handler_platform_interface: + version: "11.4.0" + permission_handler_android: dependency: transitive description: - name: permission_handler_platform_interface - url: "https://pub.dartlang.org" + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" source: hosted - version: "2.0.1" - platform: + version: "12.1.0" + permission_handler_apple: dependency: transitive description: - name: platform - url: "https://pub.dartlang.org" + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" source: hosted - version: "2.2.1" - platform_detect: + version: "9.4.7" + permission_handler_html: dependency: transitive description: - name: platform_detect - url: "https://pub.dartlang.org" + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" source: hosted - version: "1.4.0" - plugin_platform_interface: + version: "0.1.3+5" + permission_handler_platform_interface: dependency: transitive description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" source: hosted - version: "1.0.2" - process: + version: "4.3.0" + permission_handler_windows: dependency: transitive description: - name: process - url: "https://pub.dartlang.org" + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" source: hosted - version: "3.0.13" - progress_dialog: - dependency: "direct main" + version: "0.2.1" + platform: + dependency: transitive description: - name: progress_dialog - url: "https://pub.dartlang.org" + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" source: hosted - version: "1.2.4" - pub_semver: + version: "3.1.6" + plugin_platform_interface: dependency: transitive description: - name: pub_semver - url: "https://pub.dartlang.org" + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" source: hosted - version: "1.4.4" + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" rxdart: dependency: transitive description: name: rxdart - url: "https://pub.dartlang.org" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" source: hosted - version: "0.24.1" + version: "0.28.0" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" source: hosted - version: "1.8.0-nullsafety.2" + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: transitive description: name: sqflite - url: "https://pub.dartlang.org" + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" source: hosted - version: "1.3.1" + 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 - url: "https://pub.dartlang.org" + 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: "1.0.2+1" + 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 - url: "https://pub.dartlang.org" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" source: hosted - version: "1.10.0-nullsafety.2" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.4.1" synchronized: dependency: transitive description: name: synchronized - url: "https://pub.dartlang.org" + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.4.0" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" source: hosted - version: "0.2.19-nullsafety.2" + version: "0.7.6" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.dartlang.org" + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" source: hosted - version: "1.3.0-nullsafety.3" + version: "1.4.0" url_launcher: dependency: "direct main" description: name: url_launcher - url: "https://pub.dartlang.org" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "199bc33e746088546a39cc5f36bac5a278c5e53b40cb3196f99e7345fdcfae6b" + url: "https://pub.dev" source: hosted - version: "5.5.0" + version: "6.3.22" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - url: "https://pub.dartlang.org" + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" source: hosted - version: "0.0.1+1" + version: "3.2.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - url: "https://pub.dartlang.org" + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" source: hosted - version: "0.0.1+7" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - url: "https://pub.dartlang.org" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - url: "https://pub.dartlang.org" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" uuid: dependency: transitive description: name: uuid - url: "https://pub.dartlang.org" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "4.5.1" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + 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: "2.1.0-nullsafety.3" + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: "direct overridden" + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" xdg_directories: dependency: transitive description: name: xdg_directories - url: "https://pub.dartlang.org" + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "1.1.0" sdks: - dart: ">=2.10.0-110 <=2.11.0-213.1.beta" - flutter: ">=1.12.13+hotfix.5 <2.0.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7438f5f..a41ef63 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,35 +18,44 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 2.1.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=2.17.0 <4.0.0" dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^0.1.3 - http: ^0.12.2 - audioplayer: 0.8.1 - audioplayer_web: 0.7.1 - des_plugin: ^0.0.3 - ext_storage: ^1.0.3 - audiotagger: ^1.1.0 - path_provider: ^1.6.11 - progress_dialog: ^1.2.4 - gradient_widgets: ^0.5.2 - material_design_icons_flutter: 4.0.5345 - url_launcher: ^5.5.0 - permission_handler: ^5.0.1+1 - fluttertoast: ^7.0.2 - cached_network_image: ^2.2.0+1 + cupertino_icons: ^1.0.6 + http: ^1.1.0 + # Migrated from audioplayers to just_audio for better features and performance + just_audio: ^0.9.36 + audio_session: ^0.1.18 + audio_service: ^0.18.12 # Background audio playback with full control + dart_des: ^1.0.1 # Pure Dart DES implementation (port of pyDES) + # ext_storage: ^1.0.3 + path_provider: ^2.1.1 # Replaced ext_storage with path_provider + # audiotagger: ^2.2.1 # Removed due to Android Gradle Plugin compatibility issues + audiotags: ^1.4.5 + # progress_dialog: ^1.2.4 + flutter_easyloading: ^3.0.5 # Replaced progress_dialog with flutter_easyloading + # gradient_widgets: ^0.6.0 # Temporarily disabled due to TextTheme.button compatibility issue + gradient_widgets_plus: ^1.0.0 + material_design_icons_flutter: ^7.0.7296 + url_launcher: ^6.2.1 + permission_handler: ^11.0.1 + fluttertoast: ^8.2.5 + cached_network_image: ^3.3.0 + provider: ^6.1.1 # State management solution following industry standards + dev_dependencies: flutter_test: sdk: flutter +dependency_overrides: + win32: ^5.14.0 # Override to fix UnmodifiableUint8ListView compatibility issue + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -66,8 +75,8 @@ flutter: - asset: fonts/DMSans-Medium.ttf # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg + assets: + - assets/image.png # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see