diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..29a3a50
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,43 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..e720147
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "b0850beeb25f6d5b10426284f506557f66181b36"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: b0850beeb25f6d5b10426284f506557f66181b36
+ base_revision: b0850beeb25f6d5b10426284f506557f66181b36
+ - platform: android
+ create_revision: b0850beeb25f6d5b10426284f506557f66181b36
+ base_revision: b0850beeb25f6d5b10426284f506557f66181b36
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..1edebac
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,13 @@
+{
+ "configurations": [
+ {
+ "name": "Flutter",
+ "type": "dart",
+ "request": "launch",
+ "program": "lib/main.dart",
+ "args": [
+ "--dart-define=cronetHttpNoPlay=true"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..153deba
--- /dev/null
+++ b/README.md
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+# Railgun-
+
+✨ Use Bilibili As Your Audio Player ✨
+
+
+
+Railgun- is a mobile application designed to provide playback functionality for Bilibili audio content. This application is built using the Flutter framework, ensuring efficient audio playback and a user-friendly interface.
+
+## Features
+
+- Browse and search Bilibili audio content
+- Play and pause audio
+- Create and manage playlists
+- Background playback support
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Compatibility
+
+| Android | iOS |
+| :-----: | :-: |
+| ✔️ | - |
+
+## Quick Start
+
+1. Install the Flutter SDK.
+2. Clone this repository.
+3. Run `flutter pub get` in the project's root directory to install dependencies.
+4. Connect an Android device or start an emulator.
+5. Run `flutter run` to launch the application.
+
+## Disclaimer
+
+This project is for learning purposes only. Please do not use it for other purposes.
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..6f56801
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,13 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle b/android/app/build.gradle
new file mode 100644
index 0000000..5e1cd02
--- /dev/null
+++ b/android/app/build.gradle
@@ -0,0 +1,58 @@
+plugins {
+ id "com.android.application"
+ id "kotlin-android"
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id "dev.flutter.flutter-gradle-plugin"
+}
+
+def localProperties = new Properties()
+def localPropertiesFile = rootProject.file("local.properties")
+if (localPropertiesFile.exists()) {
+ localPropertiesFile.withReader("UTF-8") { reader ->
+ localProperties.load(reader)
+ }
+}
+
+def flutterVersionCode = localProperties.getProperty("flutter.versionCode")
+if (flutterVersionCode == null) {
+ flutterVersionCode = "1"
+}
+
+def flutterVersionName = localProperties.getProperty("flutter.versionName")
+if (flutterVersionName == null) {
+ flutterVersionName = "1.0"
+}
+
+android {
+ namespace = "com.ezer.railgun_minus"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.ezer.railgun_minus"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutterVersionCode.toInteger()
+ versionName = flutterVersionName
+ }
+
+ 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
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..3b7547d
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..99249b6
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/example/railgun_minus/MainActivity.kt b/android/app/src/main/kotlin/com/example/railgun_minus/MainActivity.kt
new file mode 100644
index 0000000..d172c28
--- /dev/null
+++ b/android/app/src/main/kotlin/com/example/railgun_minus/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.ezer.railgun_minus
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity: FlutterActivity()
diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png
new file mode 100644
index 0000000..0f73141
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png
new file mode 100644
index 0000000..0f73141
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png
new file mode 100644
index 0000000..1745a95
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png
new file mode 100644
index 0000000..1745a95
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png
new file mode 100644
index 0000000..51a458f
Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-hdpi/splash.png b/android/app/src/main/res/drawable-night-hdpi/splash.png
new file mode 100644
index 0000000..51a458f
Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png
new file mode 100644
index 0000000..8a02ecb
Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-mdpi/splash.png b/android/app/src/main/res/drawable-night-mdpi/splash.png
new file mode 100644
index 0000000..8a02ecb
Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png
new file mode 100644
index 0000000..71e9c81
Binary files /dev/null and b/android/app/src/main/res/drawable-night-v21/background.png differ
diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png
new file mode 100644
index 0000000..b681273
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xhdpi/splash.png b/android/app/src/main/res/drawable-night-xhdpi/splash.png
new file mode 100644
index 0000000..b681273
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png
new file mode 100644
index 0000000..9316ed5
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxhdpi/splash.png
new file mode 100644
index 0000000..9316ed5
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png
new file mode 100644
index 0000000..07c7e44
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png
new file mode 100644
index 0000000..07c7e44
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png
new file mode 100644
index 0000000..71e9c81
Binary files /dev/null and b/android/app/src/main/res/drawable-night/background.png differ
diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/android/app/src/main/res/drawable-night/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable-night/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png
new file mode 100644
index 0000000..3107d37
Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png
new file mode 100644
index 0000000..07efdcc
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png
new file mode 100644
index 0000000..07efdcc
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png
new file mode 100644
index 0000000..6e6d4cb
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png
new file mode 100644
index 0000000..6e6d4cb
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png
new file mode 100644
index 0000000..d34cd71
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png
new file mode 100644
index 0000000..d34cd71
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png
new file mode 100644
index 0000000..3107d37
Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..f4ca03c
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..e3d3c2f
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..6e82ab7
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..f8d98b4
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..55076c3
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml
new file mode 100644
index 0000000..2ddcec1
--- /dev/null
+++ b/android/app/src/main/res/values-night-v31/styles.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..dbc9ea9
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml
new file mode 100644
index 0000000..e437c38
--- /dev/null
+++ b/android/app/src/main/res/values-v31/styles.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..0d1fa8f
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..eb5f25b
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,6 @@
+
+
+
+ 127.0.0.1
+
+
\ No newline at end of file
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..9a612f8
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..d2ffbff
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,18 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.buildDir = "../build"
+subprojects {
+ project.buildDir = "${rootProject.buildDir}/${project.name}"
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean", Delete) {
+ delete rootProject.buildDir
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..b4dddc5
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+android.enableJetifier=true
\ No newline at end of file
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e1ca574
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 0000000..536165d
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1,25 @@
+pluginManagement {
+ def flutterSdkPath = {
+ def properties = new Properties()
+ file("local.properties").withInputStream { properties.load(it) }
+ def flutterSdkPath = properties.getProperty("flutter.sdk")
+ assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
+ return flutterSdkPath
+ }()
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id "dev.flutter.flutter-plugin-loader" version "1.0.0"
+ id "com.android.application" version "7.3.0" apply false
+ id "org.jetbrains.kotlin.android" version "1.7.10" apply false
+}
+
+include ":app"
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..8ef79b3
Binary files /dev/null and b/assets/icon.png differ
diff --git a/assets/icon_dark.png b/assets/icon_dark.png
new file mode 100644
index 0000000..0daf127
Binary files /dev/null and b/assets/icon_dark.png differ
diff --git a/assets/screenshot_1.jpg b/assets/screenshot_1.jpg
new file mode 100644
index 0000000..1640056
Binary files /dev/null and b/assets/screenshot_1.jpg differ
diff --git a/assets/screenshot_2.jpg b/assets/screenshot_2.jpg
new file mode 100644
index 0000000..8799772
Binary files /dev/null and b/assets/screenshot_2.jpg differ
diff --git a/assets/screenshot_3.jpg b/assets/screenshot_3.jpg
new file mode 100644
index 0000000..f0a7b65
Binary files /dev/null and b/assets/screenshot_3.jpg differ
diff --git a/assets/screenshot_4.jpg b/assets/screenshot_4.jpg
new file mode 100644
index 0000000..526842a
Binary files /dev/null and b/assets/screenshot_4.jpg differ
diff --git a/assets/screenshot_5.jpg b/assets/screenshot_5.jpg
new file mode 100644
index 0000000..a2d8578
Binary files /dev/null and b/assets/screenshot_5.jpg differ
diff --git a/assets/screenshot_6.jpg b/assets/screenshot_6.jpg
new file mode 100644
index 0000000..7d334f8
Binary files /dev/null and b/assets/screenshot_6.jpg differ
diff --git a/ios/.gitignore b/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..7c56964
--- /dev/null
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+ MinimumOSVersion
+ 12.0
+
+
diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..b99329c
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,616 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.railgunMinus;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.railgunMinus.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.railgunMinus.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.railgunMinus.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.railgunMinus;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.railgunMinus;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..8e3ca5d
--- /dev/null
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..1d526a1
--- /dev/null
+++ b/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..9074fee
--- /dev/null
+++ b/ios/Runner/AppDelegate.swift
@@ -0,0 +1,13 @@
+import Flutter
+import UIKit
+
+@UIApplicationMain
+@objc class AppDelegate: FlutterAppDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ GeneratedPluginRegistrant.register(with: self)
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d0d98aa
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1 @@
+{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
\ No newline at end of file
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..ae21edb
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..a1ef078
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..e21839e
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..ec9a2e3
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..b60120f
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..44d9a51
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..cfdeabf
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..e21839e
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..27b79d9
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..8a913c4
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
new file mode 100644
index 0000000..6ed931b
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
new file mode 100644
index 0000000..7bf4c13
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
new file mode 100644
index 0000000..476ebb3
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
new file mode 100644
index 0000000..68ba773
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..8a913c4
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..86a4fe1
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
new file mode 100644
index 0000000..ddf6d2b
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
new file mode 100644
index 0000000..50fc12e
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..63c5244
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..c5ebb11
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000..a99169d
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
new file mode 100644
index 0000000..8bb185b
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
@@ -0,0 +1,22 @@
+{
+ "images" : [
+ {
+ "filename" : "background.png",
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "darkbackground.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png
new file mode 100644
index 0000000..3107d37
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png
new file mode 100644
index 0000000..71e9c81
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..f3387d4
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,56 @@
+{
+ "images" : [
+ {
+ "filename" : "LaunchImage.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "LaunchImageDark.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "LaunchImage@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "LaunchImageDark@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "LaunchImage@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "LaunchImageDark@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000..1745a95
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000..07efdcc
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000..6e6d4cb
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png
new file mode 100644
index 0000000..8a02ecb
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png
new file mode 100644
index 0000000..b681273
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png
new file mode 100644
index 0000000..9316ed5
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..2fd157a
--- /dev/null
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
new file mode 100644
index 0000000..6914be4
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,53 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Railgun Minus
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ railgun_minus
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UIStatusBarHidden
+
+ UIViewControllerBasedStatusBarAppearance
+
+
+
diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 0000000..1abcea6
--- /dev/null
+++ b/lib/main.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+// import 'package:just_audio_background/just_audio_background.dart';
+
+import 'src/router.dart';
+import 'src/services.dart';
+
+Future main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await SystemChrome.setPreferredOrientations(
+ [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
+ await SeamlessJustAudioBackground.init(
+ androidNotificationChannelId: 'com.ezer.railgun.channel.audio',
+ androidNotificationChannelName: 'Railgun-',
+ androidNotificationOngoing: true,
+ );
+ await AudioPlayApi.init();
+ await SettingApi.init();
+ await RemoteApi.init();
+ runApp(const App());
+}
+
+class App extends StatelessWidget {
+ const App({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp.router(
+ routerConfig: appRouter,
+ theme: ThemeData.from(
+ colorScheme: const ColorScheme.light(
+ primary: Colors.indigoAccent,
+ secondary: Colors.cyanAccent,
+ ),
+ ),
+ darkTheme: ThemeData.from(
+ colorScheme: const ColorScheme.dark(
+ primary: Colors.indigoAccent,
+ secondary: Colors.cyanAccent,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/component/app_search_bar.dart b/lib/src/component/app_search_bar.dart
new file mode 100644
index 0000000..d3ba03c
--- /dev/null
+++ b/lib/src/component/app_search_bar.dart
@@ -0,0 +1,124 @@
+import 'package:flutter/material.dart';
+
+import '../services.dart';
+
+class AppSearchBar extends StatefulWidget implements PreferredSizeWidget {
+ const AppSearchBar({
+ super.key,
+ this.height = 60,
+ this.padding = const EdgeInsets.fromLTRB(16, 12, 16, 4),
+ this.searchText = '',
+ required this.controller,
+ required this.onSubmitted,
+ });
+
+ final double height;
+ final EdgeInsets padding;
+ final String searchText;
+ final TextEditingController controller;
+ final void Function(String) onSubmitted;
+
+ @override
+ State createState() => _AppSearchBarState();
+
+ @override
+ Size get preferredSize => Size.fromHeight(height);
+}
+
+class _AppSearchBarState extends State {
+ late final _controller = widget.controller;
+ final _focusNode = FocusNode();
+
+ @override
+ void initState() {
+ super.initState();
+ _controller.text = widget.searchText;
+ }
+
+ @override
+ void dispose() {
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: widget.padding,
+ child: SearchBar(
+ controller: _controller,
+ focusNode: _focusNode,
+ leading: const Opacity(opacity: .6, child: Icon(Icons.search)),
+ shadowColor: const WidgetStatePropertyAll(Colors.transparent),
+ shape: WidgetStatePropertyAll(
+ RoundedRectangleBorder(
+ side: BorderSide(
+ color: (Theme.of(context).brightness == Brightness.light
+ ? Theme.of(context).primaryColor
+ : Theme.of(context).primaryColorLight)
+ .withOpacity(.85),
+ width: 1.6),
+ borderRadius: const BorderRadius.all(Radius.circular(8)),
+ ),
+ ),
+ padding: const WidgetStatePropertyAll(
+ EdgeInsets.symmetric(horizontal: 16)),
+ onTapOutside: (event) => _focusNode.unfocus(),
+ onSubmitted: (value) {
+ if (value == '') {
+ return;
+ }
+ if (value.startsWith('>')) {
+ var commands = value.split(RegExp(r'\s+'));
+ if (commands[0] == '>' && commands.length > 1) {
+ switch (commands[1]) {
+ case 'up':
+ if (commands.length > 2) {
+ switch (commands[2]) {
+ case 'add':
+ switch (commands.length) {
+ case 4:
+ case 5:
+ case 6:
+ var uid = int.tryParse(commands[3]);
+ var tag =
+ commands.length > 4 ? commands[4] : null;
+ var pattern =
+ commands.length > 5 ? commands[5] : null;
+ if (uid != null) {
+ SettingApi.addUp(
+ uid: uid,
+ tag: tag,
+ pattern: pattern,
+ );
+ _controller.clear();
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Up $uid added')),
+ );
+ return;
+ }
+ }
+ case 'remove':
+ if (commands.length == 4) {
+ var uid = int.tryParse(commands[3]);
+ if (uid != null) {
+ SettingApi.removeUp(uid);
+ _controller.clear();
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Up $uid removed')),
+ );
+ return;
+ }
+ }
+ default:
+ }
+ }
+ default:
+ }
+ }
+ }
+ widget.onSubmitted(value);
+ }),
+ );
+ }
+}
diff --git a/lib/src/component/audio_view.dart b/lib/src/component/audio_view.dart
new file mode 100644
index 0000000..eccc8c3
--- /dev/null
+++ b/lib/src/component/audio_view.dart
@@ -0,0 +1,544 @@
+import 'package:flutter/material.dart';
+
+import '../models.dart';
+import '../services.dart';
+import '../utils.dart';
+
+class VerticalOverlayShape extends SliderComponentShape {
+ const VerticalOverlayShape({required this.height});
+
+ final double height;
+
+ @override
+ Size getPreferredSize(bool isEnabled, bool isDiscrete) {
+ return Size(0, height);
+ }
+
+ @override
+ void paint(
+ PaintingContext context,
+ Offset center, {
+ required Animation activationAnimation,
+ required Animation enableAnimation,
+ required bool isDiscrete,
+ required TextPainter labelPainter,
+ required RenderBox parentBox,
+ required SliderThemeData sliderTheme,
+ required TextDirection textDirection,
+ required double value,
+ required double textScaleFactor,
+ required Size sizeWithOverflow,
+ }) {
+ final Paint paint = Paint()
+ ..color = sliderTheme.overlayColor!
+ ..style = PaintingStyle.fill;
+
+ final Rect rect = Rect.fromCenter(center: center, width: 0, height: height);
+ context.canvas.drawRect(rect, paint);
+ }
+}
+
+class AudioView extends StatefulWidget {
+ const AudioView({super.key});
+
+ @override
+ _AudioViewState createState() => _AudioViewState();
+}
+
+class _AudioViewState extends State with TickerProviderStateMixin {
+ late TabController _tabController;
+
+ @override
+ void initState() {
+ super.initState();
+
+ final audioSummariesLength = AudioPlayApi.audioSummaries?.length ?? 0;
+ var currIndex =
+ AudioPlayApi.currentIndex < 0 ? 0 : AudioPlayApi.currentIndex;
+ if (audioSummariesLength > 1) {
+ currIndex++;
+ }
+
+ _tabController = TabController(
+ length: audioSummariesLength > 1
+ ? audioSummariesLength + 2
+ : audioSummariesLength,
+ initialIndex: currIndex,
+ vsync: this,
+ );
+ _tabController.addListener(_syncAudioState);
+
+ AudioSequenceNotifier.registerListener(_updateAudioSequence);
+ AudioPlayingNotifier.registerListener(_updateState);
+ }
+
+ @override
+ void dispose() {
+ AudioSequenceNotifier.unregisterListener(_updateAudioSequence);
+ AudioPlayingNotifier.unregisterListener(_updateState);
+ _tabController.removeListener(_syncAudioState);
+ _tabController.dispose();
+ super.dispose();
+ }
+
+ void _updateAudioSequence() {
+ final audioSummariesLength = AudioPlayApi.audioSummaries?.length ?? 0;
+ var currIndex =
+ AudioPlayApi.currentIndex < 0 ? 0 : AudioPlayApi.currentIndex;
+ // TODO: Unknowing currIndex update not in time
+ if (currIndex > audioSummariesLength ||
+ (currIndex == audioSummariesLength && currIndex > 0)) {
+ return;
+ }
+
+ if (audioSummariesLength > 1) {
+ currIndex++;
+ }
+ setState(() {
+ _tabController.dispose();
+ _tabController = TabController(
+ length: audioSummariesLength > 1
+ ? audioSummariesLength + 2
+ : audioSummariesLength,
+ initialIndex: currIndex,
+ vsync: this,
+ );
+ _tabController.addListener(_syncAudioState);
+ });
+ }
+
+ void _updateState() => setState(() {});
+
+ Future _syncAudioState() async {
+ var currIndex = _tabController.index;
+ var prevIndex = AudioPlayApi.currentIndex;
+ final audioSummariesLength = AudioPlayApi.audioSummaries?.length ?? 0;
+
+ if (audioSummariesLength > 1) {
+ prevIndex++;
+ if (currIndex == 0) {
+ currIndex = audioSummariesLength;
+ await AudioPlayApi.pause();
+ await AudioPlayApi.seekToPrevious();
+ AudioPlayApi.play();
+ return _tabController.animateTo(currIndex);
+ } else if (currIndex == audioSummariesLength + 1) {
+ currIndex = 1;
+ await AudioPlayApi.pause();
+ await AudioPlayApi.seekToNext();
+ AudioPlayApi.play();
+ return _tabController.animateTo(currIndex);
+ }
+ }
+
+ if (currIndex == prevIndex) {
+ return;
+ }
+
+ await AudioPlayApi.pause();
+ if (prevIndex == 0) {
+ return;
+ }
+
+ currIndex < prevIndex
+ ? await AudioPlayApi.seekToPrevious()
+ : await AudioPlayApi.seekToNext();
+
+ return AudioPlayApi.play();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final currAudioSummary = AudioPlayApi.currentAudioSummary;
+ final audioSummaries = AudioPlayApi.audioSummaries ?? [];
+
+ final previewImage = Stack(
+ alignment: Alignment.center,
+ children: [
+ (currAudioSummary?.isValid ?? false)
+ ? ClipOval(
+ child: AspectRatio(
+ aspectRatio: 1,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ Image.memory(
+ currAudioSummary!.pic,
+ fit: BoxFit.cover,
+ ),
+ Container(color: Colors.black26),
+ ],
+ ),
+ ),
+ )
+ : AspectRatio(
+ aspectRatio: 1,
+ child: Container(
+ decoration: BoxDecoration(
+ color: Colors.grey.withOpacity(.75),
+ shape: BoxShape.circle,
+ ),
+ ),
+ ),
+ Icon(
+ AudioPlayApi.isPlaying ? Icons.pause : Icons.play_arrow,
+ color: Colors.white,
+ size: 20,
+ ),
+ ],
+ );
+
+ final audioHandler = Flexible(
+ child: TabBarView(
+ controller: _tabController,
+ children: [
+ if (audioSummaries.length > 1) ...[
+ AudioTabView(audioSummary: audioSummaries.last),
+ for (var audioSummary in audioSummaries)
+ AudioTabView(audioSummary: audioSummary),
+ AudioTabView(audioSummary: audioSummaries.first),
+ ] else
+ for (var audioSummary in audioSummaries)
+ AudioTabView(audioSummary: audioSummary),
+ ],
+ ),
+ );
+
+ return currAudioSummary?.isValid ?? false
+ ? Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const TrackProgress(),
+ SizedBox(
+ height: 42,
+ child: Row(
+ children: [
+ Flexible(
+ child: InkWell(
+ onTap: () {
+ if (currAudioSummary?.isValid ?? false) {
+ setState(() {
+ AudioPlayApi.isPlaying
+ ? AudioPlayApi.pause()
+ : AudioPlayApi.play();
+ });
+ }
+ },
+ onLongPress: () => showModalBottomSheet(
+ context: context,
+ builder: (context) => const AudioPlaylistView(),
+ ),
+ child: Row(
+ children: [
+ const SizedBox(width: 10),
+ previewImage,
+ const SizedBox(width: 8),
+ audioHandler,
+ const PlayModeButton(),
+ const SizedBox(width: 4),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 10),
+ ],
+ )
+ : const SizedBox.shrink();
+ }
+}
+
+class AudioTabView extends StatelessWidget {
+ const AudioTabView({
+ super.key,
+ required this.audioSummary,
+ });
+
+ final AudioSummary audioSummary;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ Display.formatTitle(audioSummary.title, simplify: true),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 2.5),
+ const Opacity(
+ opacity: .4,
+ child: TrackTime(),
+ ),
+ ],
+ );
+ }
+}
+
+class AudioPlaylistView extends StatefulWidget {
+ const AudioPlaylistView({
+ super.key,
+ });
+
+ @override
+ State createState() => _AudioPlaylistViewState();
+}
+
+class _AudioPlaylistViewState extends State {
+ @override
+ void initState() {
+ super.initState();
+ AudioSequenceNotifier.registerListener(_updateState);
+ }
+
+ @override
+ void dispose() {
+ AudioSequenceNotifier.unregisterListener(_updateState);
+ super.dispose();
+ }
+
+ void _updateState() => setState(() {});
+
+ @override
+ Widget build(BuildContext context) {
+ final currAudioSummaries = AudioPlayApi.audioSummaries ?? [];
+ final currIndex = AudioPlayApi.currentIndex;
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ height: 4,
+ width: 36,
+ decoration: BoxDecoration(
+ color: Colors.grey.withOpacity(.25),
+ borderRadius: BorderRadius.circular(99),
+ ),
+ ),
+ const SizedBox(height: 16),
+ for (var entry in currAudioSummaries.asMap().entries)
+ Container(
+ color: entry.key == currIndex
+ ? Theme.of(context).colorScheme.primary.withOpacity(.1)
+ : null,
+ child: InkWell(
+ onTap: () async {
+ await AudioPlayApi.pause();
+ await AudioPlayApi.seekTo(entry.value);
+ AudioPlayApi.play();
+ // setState(() {});
+ },
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Flexible(
+ child: Padding(
+ padding: const EdgeInsets.only(left: 24),
+ child: Row(
+ children: [
+ Flexible(
+ child: Text(
+ Display.formatTitle(
+ entry.value.title,
+ simplify: true,
+ ),
+ style: TextStyle(
+ color: entry.key == currIndex
+ ? Theme.of(context).colorScheme.primary
+ : null,
+ fontSize: 16,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ Opacity(
+ opacity: .4,
+ child: Text(
+ ' · ${Display.formatTitle(
+ entry.value.author,
+ simplify: true,
+ )}',
+ style: TextStyle(
+ color: entry.key == currIndex
+ ? Theme.of(context).colorScheme.primary
+ : null,
+ fontSize: 12,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: Opacity(
+ opacity: .4,
+ child: IconButton(
+ icon: const Icon(Icons.close),
+ iconSize: 20,
+ highlightColor: Colors.transparent,
+ onPressed: () async {
+ await AudioPlayApi.removeAt(entry.key);
+ if (AudioPlayApi.audioSummaries?.isEmpty ??
+ true) {
+ setState(() {
+ Navigator.pop(context);
+ });
+ }
+ }),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class TrackTime extends StatefulWidget {
+ const TrackTime({
+ super.key,
+ });
+
+ @override
+ State createState() => _TrackTimeState();
+}
+
+class _TrackTimeState extends State {
+ @override
+ void initState() {
+ super.initState();
+ AudioPositionNotifier.registerListener(_updateState);
+ }
+
+ @override
+ void dispose() {
+ AudioPositionNotifier.unregisterListener(_updateState);
+ super.dispose();
+ }
+
+ void _updateState() => setState(() {});
+
+ @override
+ Widget build(BuildContext context) {
+ final position = AudioPlayApi.position;
+ final positionMinutes = position.inMinutes;
+ final positionSeconds = position.inSeconds.remainder(60);
+ final duration = AudioPlayApi.duration;
+ final durationMinutes = duration?.inMinutes ?? 0;
+ final durationSeconds = duration?.inSeconds.remainder(60) ?? 0;
+
+ return Text(
+ '${Display.formatDuration('$positionMinutes:$positionSeconds')} / ${Display.formatDuration('$durationMinutes:$durationSeconds')}',
+ style: const TextStyle(fontSize: 10),
+ );
+ }
+}
+
+class PlayModeButton extends StatefulWidget {
+ const PlayModeButton({
+ super.key,
+ });
+
+ @override
+ State createState() => _PlayModeButtonState();
+}
+
+class _PlayModeButtonState extends State {
+ @override
+ void initState() {
+ super.initState();
+ AudioPlayModeNotifier.registerListener(_updateState);
+ }
+
+ @override
+ void dispose() {
+ AudioPlayModeNotifier.unregisterListener(_updateState);
+ super.dispose();
+ }
+
+ void _updateState() => setState(() {});
+
+ @override
+ Widget build(BuildContext context) {
+ return IconButton(
+ icon: Icon(
+ switch (AudioPlayApi.currentPlayMode) {
+ PlayMode.all => Icons.repeat,
+ PlayMode.one => Icons.repeat_one,
+ PlayMode.shuffle => Icons.shuffle,
+ },
+ ),
+ onPressed: () => setState(() {
+ switch (AudioPlayApi.currentPlayMode) {
+ case PlayMode.all:
+ AudioPlayApi.setPlayMode(PlayMode.one);
+ case PlayMode.one:
+ AudioPlayApi.setPlayMode(PlayMode.shuffle);
+ case PlayMode.shuffle:
+ AudioPlayApi.setPlayMode(PlayMode.all);
+ }
+ }),
+ );
+ }
+}
+
+class TrackProgress extends StatefulWidget {
+ const TrackProgress({
+ super.key,
+ });
+
+ @override
+ State createState() => _TrackProgressState();
+}
+
+class _TrackProgressState extends State {
+ @override
+ void initState() {
+ super.initState();
+ AudioPositionNotifier.registerListener(_updateState);
+ }
+
+ @override
+ void dispose() {
+ AudioPositionNotifier.unregisterListener(_updateState);
+ super.dispose();
+ }
+
+ void _updateState() => setState(() {});
+
+ @override
+ Widget build(BuildContext context) {
+ return SliderTheme(
+ data: const SliderThemeData(
+ trackHeight: 4,
+ trackShape: RectangularSliderTrackShape(),
+ thumbShape: RoundSliderThumbShape(enabledThumbRadius: 0),
+ overlayShape: VerticalOverlayShape(height: 21),
+ // overlayShape: SliderComponentShape.noOverlay,
+ overlayColor: Colors.transparent,
+ ),
+ child: Slider(
+ value: AudioPlayApi.position.inMilliseconds.toDouble(),
+ max: AudioPlayApi.duration?.inMilliseconds.toDouble() ??
+ AudioPlayApi.position.inMilliseconds.toDouble(),
+ onChanged: (value) => setState(() {
+ AudioPlayApi.seek(Duration(milliseconds: value.toInt()));
+ }),
+ ),
+ );
+ }
+}
diff --git a/lib/src/component/filtered_tab_bar.dart b/lib/src/component/filtered_tab_bar.dart
new file mode 100644
index 0000000..58972cf
--- /dev/null
+++ b/lib/src/component/filtered_tab_bar.dart
@@ -0,0 +1,102 @@
+import 'package:flutter/material.dart';
+
+class FilteredTabBar extends StatefulWidget {
+ const FilteredTabBar({
+ super.key,
+ required this.tabTexts,
+ required this.filters,
+ required this.controller,
+ }) : assert(tabTexts.length > 0),
+ assert(filters.length > 0);
+
+ final List tabTexts;
+ final List<
+ ({
+ String text,
+ IconData leadingIcon,
+ void Function()? onPressed,
+ })> filters;
+ final TabController controller;
+
+ @override
+ State createState() => _FilteredTabBarState();
+}
+
+class _FilteredTabBarState extends State
+ with SingleTickerProviderStateMixin {
+ static const _tabHeight = 40.0;
+
+ late final _controller = widget.controller;
+ int _selectedIndex = 0;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ children: [
+ SizedBox(
+ height: _tabHeight,
+ child: Row(
+ children: [
+ Flexible(
+ child: TabBar(
+ controller: _controller,
+ tabs: [
+ for (var tabText in widget.tabTexts)
+ Tab(text: tabText, height: _tabHeight),
+ ],
+ overlayColor: WidgetStateProperty.all(Colors.transparent),
+ dividerColor: Colors.transparent,
+ ),
+ ),
+ MenuAnchor(
+ style: MenuStyle(
+ shadowColor: WidgetStateProperty.all(Colors.transparent),
+ padding: WidgetStateProperty.all(EdgeInsets.zero),
+ shape: WidgetStateProperty.all(
+ RoundedRectangleBorder(
+ side: BorderSide(
+ color: Theme.of(context).highlightColor,
+ width: 2,
+ ),
+ borderRadius: BorderRadius.circular(8)),
+ ),
+ ),
+ alignmentOffset: const Offset(-51, 6),
+ menuChildren: [
+ for (var entry in widget.filters.asMap().entries)
+ Opacity(
+ opacity: entry.key == _selectedIndex ? 1 : .5,
+ child: MenuItemButton(
+ onPressed: () {
+ setState(() => _selectedIndex = entry.key);
+ entry.value.onPressed?.call();
+ },
+ leadingIcon: Icon(entry.value.leadingIcon),
+ child: Text(entry.value.text),
+ ),
+ ),
+ ],
+ builder: (_, menuController, __) => IconButton(
+ visualDensity: VisualDensity.comfortable,
+ highlightColor: Colors.transparent,
+ onPressed: () {
+ if (menuController.isOpen) {
+ menuController.close();
+ } else {
+ menuController.open();
+ }
+ },
+ icon: const Opacity(
+ opacity: .5,
+ child: Icon(Icons.filter_alt_outlined),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ Divider(height: 0, color: Theme.of(context).dividerColor),
+ ],
+ );
+ }
+}
diff --git a/lib/src/component/search_view.dart b/lib/src/component/search_view.dart
new file mode 100644
index 0000000..590f1aa
--- /dev/null
+++ b/lib/src/component/search_view.dart
@@ -0,0 +1,233 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
+
+import '../models.dart';
+import '../services.dart';
+import '../utils.dart';
+
+class SearchView extends StatefulWidget {
+ const SearchView({
+ super.key,
+ required this.searchApi,
+ });
+
+ final SearchApi searchApi;
+
+ @override
+ _SearchViewState createState() => _SearchViewState();
+}
+
+class _SearchViewState extends State {
+ static const _pageSize = 10;
+ static const _padding = 12.0;
+
+ final PagingController _pagingController =
+ PagingController(firstPageKey: 1);
+ bool _isDisposed = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _pagingController.addPageRequestListener((pageKey) => _fetchPage(pageKey));
+ }
+
+ @override
+ void dispose() {
+ _isDisposed = true;
+ _pagingController.dispose();
+ super.dispose();
+ }
+
+ Future _fetchPage(int pageKey) async {
+ try {
+ final newItems =
+ await widget.searchApi.getPage(pageKey: pageKey, pageSize: _pageSize);
+ final isLastPage = newItems.length < _pageSize;
+ if (_isDisposed) {
+ return;
+ }
+ if (isLastPage) {
+ _pagingController.appendLastPage(newItems);
+ } else {
+ _pagingController.appendPage(newItems, ++pageKey);
+ }
+ } catch (error) {
+ if (_isDisposed) {
+ return;
+ }
+ _pagingController.error = error;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) => PagedListView(
+ pagingController: _pagingController,
+ builderDelegate: PagedChildBuilderDelegate(
+ itemBuilder: (context, item, index) {
+ if (!item.isValid) {
+ return const SizedBox.shrink();
+ }
+
+ return Column(
+ children: [
+ InkWell(
+ onTap: () async {
+ if (item.isValid) {
+ await AudioPlayApi.pause();
+ await AudioPlayApi.add(item);
+ await AudioPlayApi.seekTo(item);
+ AudioPlayApi.play();
+ }
+ },
+ onLongPress: () {
+ if (item.isValid) {
+ Clipboard.setData(
+ ClipboardData(text: item.bvid!),
+ );
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Copied to clipboard'),
+ ),
+ );
+ }
+ },
+ child: Padding(
+ padding: const EdgeInsets.all(_padding),
+ child: SearchItem(item: item),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: _padding),
+ child: Divider(
+ height: 0,
+ color: Theme.of(context).dividerColor,
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+}
+
+class SearchItem extends StatelessWidget {
+ const SearchItem({
+ super.key,
+ required this.item,
+ });
+
+ final AudioSummary item;
+
+ @override
+ Widget build(BuildContext context) {
+ final previewImage = ClipRRect(
+ borderRadius: BorderRadius.circular(4),
+ child: AspectRatio(
+ aspectRatio: 16 / 9,
+ child: Image.memory(
+ item.pic,
+ fit: BoxFit.cover,
+ ),
+ ),
+ );
+
+ final durationDisplay = Align(
+ alignment: Alignment.bottomRight,
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Colors.black.withOpacity(.6),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ child: Text(
+ ' ${Display.formatDuration(item.duration)} ',
+ style: const TextStyle(color: Colors.white, fontSize: 10),
+ ),
+ ),
+ ),
+ );
+
+ final titleDisplay = Align(
+ alignment: Alignment.topLeft,
+ child: Text(
+ Display.formatTitle(item.title),
+ // style: const TextStyle(fontWeight: FontWeight.bold),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ );
+
+ final authorDisplay = Row(
+ children: [
+ const Icon(
+ Icons.person,
+ size: 18,
+ color: Colors.grey,
+ ),
+ const SizedBox(width: 3),
+ Text(
+ item.author,
+ style: const TextStyle(
+ color: Colors.grey,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ );
+
+ final playAndPubDate = Row(
+ children: [
+ const Icon(
+ Icons.play_circle_outline,
+ size: 18,
+ color: Colors.grey,
+ ),
+ const SizedBox(width: 3),
+ Text(
+ '${Display.formatPlay(item.play)} · ${Display.formatPubDate(item.pubDate)}',
+ style: const TextStyle(color: Colors.grey, fontSize: 12),
+ ),
+ ],
+ );
+
+ return IntrinsicHeight(
+ child: Row(
+ children: [
+ Flexible(
+ flex: 14,
+ child: Stack(
+ children: [
+ previewImage,
+ durationDisplay,
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ Flexible(
+ flex: 17,
+ child: Stack(
+ children: [
+ titleDisplay,
+ Align(
+ alignment: Alignment.bottomLeft,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.end,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ authorDisplay,
+ const SizedBox(height: 2),
+ playAndPubDate,
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/src/component/user_info.dart b/lib/src/component/user_info.dart
new file mode 100644
index 0000000..baa883b
--- /dev/null
+++ b/lib/src/component/user_info.dart
@@ -0,0 +1,69 @@
+import 'package:flutter/material.dart';
+
+import '../models.dart';
+
+class UserInfo extends StatelessWidget {
+ const UserInfo({
+ super.key,
+ required this.userSummary,
+ });
+
+ final UserSummary userSummary;
+
+ @override
+ Widget build(BuildContext context) {
+ return IntrinsicHeight(
+ child: Row(
+ children: [
+ CircleAvatar(
+ backgroundImage: MemoryImage(userSummary.face),
+ radius: 22.5,
+ ),
+ const SizedBox(width: 10),
+ Stack(
+ children: [
+ Align(
+ alignment: Alignment.topLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: Text(
+ userSummary.name,
+ style: TextStyle(
+ fontSize: 14,
+ // fontWeight: FontWeight.bold,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ ),
+ ),
+ ),
+ if (userSummary.tag != null)
+ Align(
+ alignment: Alignment.bottomLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 2),
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 4, vertical: 1),
+ decoration: BoxDecoration(
+ color: Theme.of(context).brightness == Brightness.light
+ ? Colors.grey.shade400
+ : Colors.grey.shade600,
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Text(
+ userSummary.tag!,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 11,
+ ),
+ ),
+ ),
+ ),
+ )
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/src/component/user_view.dart b/lib/src/component/user_view.dart
new file mode 100644
index 0000000..385615a
--- /dev/null
+++ b/lib/src/component/user_view.dart
@@ -0,0 +1,271 @@
+import 'package:flutter/material.dart';
+
+import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
+
+import '../models.dart';
+import '../services.dart';
+import '../utils.dart';
+
+class UserView extends StatefulWidget {
+ const UserView({
+ super.key,
+ required this.userApi,
+ this.height = 112.5,
+ });
+
+ final UserApi userApi;
+ final double height;
+
+ @override
+ _UserViewState createState() => _UserViewState();
+}
+
+class _UserViewState extends State {
+ static const _pageSize = 5;
+ static const _padding = 4.0;
+
+ final PagingController _pagingController =
+ PagingController(firstPageKey: 1);
+ bool _isDisposed = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _pagingController.addPageRequestListener((pageKey) => _fetchPage(pageKey));
+ }
+
+ @override
+ void dispose() {
+ _isDisposed = true;
+ _pagingController.dispose();
+ super.dispose();
+ }
+
+ Future _fetchPage(int pageKey) async {
+ try {
+ final (audioSummaries: newItems, isLastPage: isLastPage) =
+ await widget.userApi.getPage(pageKey: pageKey, pageSize: _pageSize);
+ if (_isDisposed) {
+ return;
+ }
+ if (isLastPage) {
+ _pagingController.appendLastPage(newItems);
+ } else {
+ _pagingController.appendPage(newItems, ++pageKey);
+ }
+ } catch (error) {
+ if (_isDisposed) {
+ return;
+ }
+ _pagingController.error = error;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) => Flexible(
+ child: Container(
+ constraints: BoxConstraints(
+ maxHeight: widget.height + _padding * 2,
+ ),
+ child: PagedListView(
+ scrollDirection: Axis.horizontal,
+ pagingController: _pagingController,
+ builderDelegate: PagedChildBuilderDelegate(
+ itemBuilder: (context, item, index) {
+ if (!item.isValid) {
+ return const SizedBox.shrink();
+ }
+
+ return InkWell(
+ onTap: () async {
+ if (item.isValid) {
+ await AudioPlayApi.pause();
+ await AudioPlayApi.add(item);
+ await AudioPlayApi.seekTo(item);
+ AudioPlayApi.play();
+ }
+ },
+ child: Padding(
+ padding: const EdgeInsets.all(_padding),
+ child: UserItem(
+ item: item,
+ height: widget.height,
+ ),
+ ),
+ );
+ },
+ newPageProgressIndicatorBuilder: (context) =>
+ const SizedBox.shrink(),
+ firstPageProgressIndicatorBuilder: (context) =>
+ const SizedBox.shrink(),
+ ),
+ ),
+ ),
+ );
+}
+
+class UserItem extends StatefulWidget {
+ const UserItem({
+ super.key,
+ required this.item,
+ required this.height,
+ });
+
+ static const _circular = 4.0;
+ static const _textPadding = 6.0;
+ static const _ratio = 4 / 3;
+ static const _expandedRatio = 21 / 9;
+
+ final double height;
+ final AudioSummary item;
+
+ @override
+ _UserItemState createState() => _UserItemState();
+}
+
+class _UserItemState extends State {
+ final _scrollController = ScrollController();
+ bool _isLongPressed = false;
+
+ @override
+ void dispose() {
+ _scrollController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final previewImage = ClipRRect(
+ borderRadius: const BorderRadius.only(
+ topLeft: Radius.circular(UserItem._circular),
+ topRight: Radius.circular(UserItem._circular),
+ ),
+ child: AspectRatio(
+ aspectRatio: _isLongPressed ? UserItem._expandedRatio : UserItem._ratio,
+ child: Image.memory(
+ widget.item.pic,
+ fit: BoxFit.cover,
+ ),
+ ),
+ );
+
+ final durationDisplay = Align(
+ alignment: Alignment.bottomRight,
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Colors.black.withOpacity(.6),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ child: Text(
+ ' ${Display.formatDuration(widget.item.duration)} ',
+ style: const TextStyle(color: Colors.white, fontSize: 8.5),
+ ),
+ ),
+ ),
+ );
+
+ final playDisplay = Align(
+ alignment: Alignment.topLeft,
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Colors.black.withOpacity(.5),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const SizedBox(width: 1),
+ const Icon(
+ Icons.play_arrow_rounded,
+ size: 12,
+ color: Colors.white,
+ ),
+ Text(
+ ' ${Display.formatPlay(widget.item.play)} ',
+ style: const TextStyle(color: Colors.white, fontSize: 8.5),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ return GestureDetector(
+ onLongPress: () async {
+ if (!_isLongPressed) {
+ setState(() => _isLongPressed = true);
+ if (_scrollController.position.maxScrollExtent >
+ _scrollController.position.viewportDimension -
+ UserItem._textPadding * 2) {
+ await _scrollController.animateTo(
+ _scrollController.position.maxScrollExtent,
+ duration: const Duration(milliseconds: 2400),
+ curve: Curves.easeIn,
+ );
+ }
+ }
+ },
+ onLongPressUp: () async {
+ if (_isLongPressed) {
+ setState(() => _isLongPressed = false);
+ await _scrollController.animateTo(
+ _scrollController.position.minScrollExtent,
+ duration: const Duration(milliseconds: 120),
+ curve: Curves.easeOut,
+ );
+ }
+ },
+ child: SizedBox(
+ height: widget.height,
+ width: _isLongPressed
+ ? widget.height * (UserItem._expandedRatio / UserItem._ratio)
+ : widget.height,
+ child: Stack(
+ children: [
+ SizedBox(
+ height: widget.height / UserItem._ratio,
+ child: Stack(
+ children: [
+ previewImage,
+ durationDisplay,
+ playDisplay,
+ ],
+ ),
+ ),
+ Align(
+ alignment: Alignment.bottomCenter,
+ child: Container(
+ height: widget.height * (1 - 1 / UserItem._ratio),
+ width: _isLongPressed
+ ? widget.height *
+ (UserItem._expandedRatio / UserItem._ratio)
+ : widget.height,
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primary.withOpacity(.9),
+ borderRadius: const BorderRadius.only(
+ bottomLeft: Radius.circular(UserItem._circular),
+ bottomRight: Radius.circular(UserItem._circular),
+ ),
+ ),
+ padding: const EdgeInsets.all(UserItem._textPadding),
+ child: SingleChildScrollView(
+ controller: _scrollController,
+ scrollDirection: Axis.horizontal,
+ physics: const NeverScrollableScrollPhysics(),
+ child: Text(
+ Display.formatTitle(widget.item.title, simplify: true),
+ style: const TextStyle(fontSize: 10, color: Colors.white),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/components.dart b/lib/src/components.dart
new file mode 100644
index 0000000..0927add
--- /dev/null
+++ b/lib/src/components.dart
@@ -0,0 +1,6 @@
+export 'component/app_search_bar.dart';
+export 'component/filtered_tab_bar.dart';
+export 'component/search_view.dart';
+export 'component/user_view.dart';
+export 'component/user_info.dart';
+export 'component/audio_view.dart';
diff --git a/lib/src/model/audio_summary.dart b/lib/src/model/audio_summary.dart
new file mode 100644
index 0000000..9ce0bbd
--- /dev/null
+++ b/lib/src/model/audio_summary.dart
@@ -0,0 +1,103 @@
+import 'package:flutter/foundation.dart';
+import 'package:railgun_minus/src/utils.dart';
+
+@immutable
+class AudioSummary {
+ const AudioSummary({
+ required this.bvid,
+ required this.title,
+ required this.author,
+ required this.mid,
+ required this.play,
+ required this.pubDate,
+ required this.duration,
+ required this.pic,
+ required this.description,
+ // required this.tags,
+ required this.like,
+ required this.favorites,
+ });
+
+ AudioSummary.invalid()
+ : bvid = null,
+ title = '',
+ author = '',
+ mid = 0,
+ play = 0,
+ pubDate = DateTime(0),
+ duration = '',
+ pic = Uint8List(0),
+ description = '',
+ // tags = const [],
+ like = 0,
+ favorites = 0;
+
+ factory AudioSummary.fromJson(Map json) => AudioSummary(
+ bvid: json['bvid'] as String,
+ title: Format.validTitle(json['title'] as String),
+ author: json['author'] as String,
+ mid: json['mid'] as int,
+ play: json['play'] as int,
+ pubDate:
+ DateTime.fromMillisecondsSinceEpoch((json['pubdate'] as int) * 1000)
+ .toLocal(),
+ duration: json['duration'] as String,
+ pic: json['pic'] as Uint8List,
+ description: json['description'] as String,
+ // tags: (json['tag'] as String).split(','),
+ like: json['like'] as int,
+ favorites: json['favorites'] as int,
+ );
+
+ final String? bvid;
+ final String title;
+ final String author;
+ final int mid;
+ final int play;
+ final DateTime pubDate;
+ final String duration;
+ final Uint8List pic;
+ final String description;
+ // final List tags;
+ final int like;
+ final int favorites;
+
+ @override
+ bool operator ==(Object other) {
+ return other is AudioSummary &&
+ runtimeType == other.runtimeType &&
+ bvid == other.bvid &&
+ title == other.title &&
+ author == other.author &&
+ mid == other.mid &&
+ play == other.play &&
+ pubDate == other.pubDate &&
+ duration == other.duration &&
+ pic == other.pic &&
+ description == other.description &&
+ // tags == other.tags &&
+ like == other.like &&
+ favorites == other.favorites;
+ }
+
+ @override
+ int get hashCode {
+ return Object.hash(
+ runtimeType,
+ bvid,
+ title,
+ author,
+ mid,
+ play,
+ pubDate,
+ duration,
+ pic,
+ description,
+ // tags,
+ like,
+ favorites,
+ );
+ }
+
+ bool get isValid => bvid != null;
+}
diff --git a/lib/src/model/user_summary.dart b/lib/src/model/user_summary.dart
new file mode 100644
index 0000000..a5c8f82
--- /dev/null
+++ b/lib/src/model/user_summary.dart
@@ -0,0 +1,49 @@
+import 'package:flutter/foundation.dart';
+
+@immutable
+class UserSummary {
+ const UserSummary({
+ required this.mid,
+ required this.name,
+ required this.face,
+ required this.sign,
+ this.tag,
+ });
+
+ factory UserSummary.fromJson(Map json) => UserSummary(
+ mid: json['mid'] as int,
+ name: json['name'] as String,
+ face: json['face'] as Uint8List,
+ sign: json['sign'] as String,
+ tag: json['tag'] as String?,
+ );
+
+ final int mid;
+ final String name;
+ final Uint8List face;
+ final String sign;
+ final String? tag;
+
+ @override
+ bool operator ==(Object other) {
+ return other is UserSummary &&
+ runtimeType == other.runtimeType &&
+ mid == other.mid &&
+ name == other.name &&
+ face == other.face &&
+ sign == other.sign &&
+ tag == other.tag;
+ }
+
+ @override
+ int get hashCode {
+ return Object.hash(
+ runtimeType,
+ mid,
+ name,
+ face,
+ sign,
+ tag,
+ );
+ }
+}
diff --git a/lib/src/models.dart b/lib/src/models.dart
new file mode 100644
index 0000000..f04681f
--- /dev/null
+++ b/lib/src/models.dart
@@ -0,0 +1,2 @@
+export 'model/audio_summary.dart';
+export 'model/user_summary.dart';
\ No newline at end of file
diff --git a/lib/src/page/home_page.dart b/lib/src/page/home_page.dart
new file mode 100644
index 0000000..051cbbd
--- /dev/null
+++ b/lib/src/page/home_page.dart
@@ -0,0 +1,84 @@
+import 'package:flutter/material.dart';
+
+import 'package:go_router/go_router.dart';
+import 'package:railgun_minus/src/model/user_summary.dart';
+
+import '../components.dart';
+import '../services.dart';
+
+class HomePage extends StatefulWidget {
+ const HomePage({super.key});
+
+ @override
+ State createState() => _HomePageState();
+}
+
+class _HomePageState extends State {
+ final _controller = TextEditingController();
+ final _userApis = [
+ for (var up in SettingApi.Ups)
+ if (up.pattern != null)
+ UserApi(
+ uid: up.uid,
+ tag: up.tag,
+ )..addVerifier(
+ (jsonData) => RegExp(up.pattern!).hasMatch(jsonData['title']),
+ )
+ else
+ UserApi(
+ uid: up.uid,
+ tag: up.tag,
+ )
+ ];
+ final Map _userSummaries = {};
+
+ @override
+ void initState() {
+ super.initState();
+ for (var userApi in _userApis) {
+ userApi.userSummary.then((value) {
+ setState(() {
+ _userSummaries[userApi] = value;
+ });
+ });
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppSearchBar(
+ controller: _controller,
+ onSubmitted: (value) {
+ context.go('/search/$value');
+ _controller.clear();
+ }),
+ body: Padding(
+ padding: const EdgeInsets.only(left: 16),
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ for (var userApi in _userApis) ...[
+ const SizedBox(height: 16),
+ if (_userSummaries[userApi] != null) ...[
+ UserInfo(userSummary: _userSummaries[userApi]!),
+ const SizedBox(height: 4),
+ ],
+ UserView(userApi: userApi),
+ ],
+ ],
+ ),
+ ),
+ ),
+ bottomNavigationBar: const AudioView(),
+ );
+ }
+}
diff --git a/lib/src/page/search_page.dart b/lib/src/page/search_page.dart
new file mode 100644
index 0000000..4b4837e
--- /dev/null
+++ b/lib/src/page/search_page.dart
@@ -0,0 +1,113 @@
+import 'package:flutter/material.dart';
+import 'package:railgun_minus/src/services.dart';
+
+import '../components.dart';
+
+class SearchPage extends StatefulWidget {
+ const SearchPage({
+ super.key,
+ required this.keyword,
+ }) : assert(keyword != '');
+
+ final String keyword;
+
+ @override
+ State createState() => _SearchPageState();
+}
+
+class _SearchPageState extends State
+ with SingleTickerProviderStateMixin {
+ static const _tabConfigs = [
+ (
+ text: '综合',
+ value: 0,
+ ),
+ (
+ text: '音乐',
+ value: 3,
+ ),
+ (
+ text: '虚拟',
+ value: 30,
+ ),
+ ];
+ static const _filterConfigs = [
+ (
+ text: '默认',
+ value: 'totalrank',
+ leadingIcon: Icons.menu,
+ ),
+ (
+ text: '播放',
+ value: 'click',
+ leadingIcon: Icons.play_circle_outline,
+ ),
+ (
+ text: '时间',
+ value: 'pubdate',
+ leadingIcon: Icons.access_time,
+ ),
+ ];
+
+ final _searchController = TextEditingController();
+ late final _tabController =
+ TabController(length: _tabConfigs.length, vsync: this);
+
+ late String _keyword = widget.keyword;
+ String _order = _filterConfigs[0].value;
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ _tabController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppSearchBar(
+ searchText: widget.keyword,
+ controller: _searchController,
+ onSubmitted: (value) => setState(() {
+ _keyword = value;
+ _order = _filterConfigs[0].value;
+ }),
+ ),
+ body: Column(
+ children: [
+ FilteredTabBar(
+ key: ValueKey(_keyword),
+ tabTexts: [for (var tabConfig in _tabConfigs) tabConfig.text],
+ filters: [
+ for (var filterConfig in _filterConfigs)
+ (
+ text: filterConfig.text,
+ leadingIcon: filterConfig.leadingIcon,
+ onPressed: () => setState(() => _order = filterConfig.value),
+ ),
+ ],
+ controller: _tabController,
+ ),
+ Flexible(
+ child: TabBarView(
+ controller: _tabController,
+ children: [
+ for (var tabConfig in _tabConfigs)
+ SearchView(
+ key: ValueKey([_keyword, _order]),
+ searchApi: SearchApi(
+ keyword: _keyword,
+ order: _order,
+ typeId: tabConfig.value,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ bottomNavigationBar: const AudioView(),
+ );
+ }
+}
diff --git a/lib/src/pages.dart b/lib/src/pages.dart
new file mode 100644
index 0000000..e8e4864
--- /dev/null
+++ b/lib/src/pages.dart
@@ -0,0 +1,2 @@
+export 'page/home_page.dart';
+export 'page/search_page.dart';
\ No newline at end of file
diff --git a/lib/src/router.dart b/lib/src/router.dart
new file mode 100644
index 0000000..b37c7c0
--- /dev/null
+++ b/lib/src/router.dart
@@ -0,0 +1,24 @@
+import 'package:flutter/material.dart';
+
+import 'package:go_router/go_router.dart';
+
+import 'pages.dart';
+
+final GoRouter appRouter = GoRouter(
+ routes: [
+ GoRoute(
+ path: '/',
+ builder: (_, __) => const SafeArea(
+ child: HomePage(),
+ ),
+ routes: [
+ GoRoute(
+ path: 'search/:keyword',
+ builder: (_, state) => SafeArea(
+ child: SearchPage(keyword: state.pathParameters['keyword']!),
+ ),
+ ),
+ ],
+ ),
+ ],
+);
diff --git a/lib/src/service/audio_play_api.dart b/lib/src/service/audio_play_api.dart
new file mode 100644
index 0000000..d161f96
--- /dev/null
+++ b/lib/src/service/audio_play_api.dart
@@ -0,0 +1,322 @@
+import 'dart:convert';
+import 'package:flutter/material.dart';
+
+import 'package:just_audio/just_audio.dart';
+// import 'package:just_audio_background/just_audio_background.dart';
+
+import 'seamless_audio_handler.dart';
+import 'local_lock_caching_audio_source.dart';
+import '../models.dart';
+import 'remote_api.dart';
+
+enum PlayMode {
+ all,
+ one,
+ shuffle,
+}
+
+class SummaryLockCachingAudioSource extends LocalLockCachingAudioSource {
+ final AudioSummary audioSummary;
+
+ SummaryLockCachingAudioSource(
+ super.uri, {
+ super.headers,
+ super.cacheFile,
+ super.tag,
+ required this.audioSummary,
+ });
+}
+
+class SeamlessAudioPlayer extends AudioPlayer {
+ @override
+ Future seekToNext() async {
+ if (loopMode == LoopMode.one) {
+ await setLoopMode(LoopMode.all);
+ await super.seekToNext();
+ return await setLoopMode(LoopMode.one);
+ }
+ return super.seekToNext();
+ }
+
+ @override
+ Future seekToPrevious() async {
+ if (loopMode == LoopMode.one) {
+ await setLoopMode(LoopMode.all);
+ await super.seekToPrevious();
+ return await setLoopMode(LoopMode.one);
+ }
+ return super.seekToPrevious();
+ }
+}
+
+class AudioPlayApi {
+ static final AudioPlayApi _instance = AudioPlayApi._();
+
+ factory AudioPlayApi() => _instance;
+
+ AudioPlayApi._() : _player = SeamlessAudioPlayer();
+
+ static Future init() async {
+ await _instance._player.setAudioSource(_instance._audios);
+ await _instance._player.setShuffleModeEnabled(false);
+ return await _instance._player.setLoopMode(LoopMode.all);
+ }
+
+ final AudioPlayer _player;
+ final ConcatenatingAudioSource _audios =
+ ConcatenatingAudioSource(children: []);
+ PlayMode _currentPlayMode = PlayMode.all;
+
+ static AudioPlayer get player => _instance._player;
+
+ static bool get isPlaying => _instance._isPlaying;
+ bool get _isPlaying => _player.playing;
+
+ static PlayMode get currentPlayMode => _instance._currentPlayMode;
+
+ static List? get audioSummaries => _instance._audioSummaries;
+ List? get _audioSummaries {
+ final seq = _player.sequence;
+ if (seq == null) {
+ return null;
+ }
+
+ if (_player.shuffleModeEnabled) {
+ final indices = _player.shuffleIndices;
+ if (indices == null) {
+ return null;
+ }
+
+ return [
+ for (var index in indices)
+ (seq[index] as SummaryLockCachingAudioSource).audioSummary
+ ];
+ }
+
+ return [
+ for (var audio in seq)
+ (audio as SummaryLockCachingAudioSource).audioSummary
+ ];
+ }
+
+ static int get currentIndex => _instance._currentIndex;
+ int get _currentIndex {
+ final realCurrentIndex = _player.currentIndex;
+ if (realCurrentIndex == null) {
+ return -1;
+ }
+
+ if (_player.shuffleModeEnabled) {
+ final indices = _player.shuffleIndices;
+ if (indices == null) {
+ return -1;
+ }
+
+ return indices.indexOf(realCurrentIndex);
+ }
+
+ return realCurrentIndex;
+ }
+
+ static AudioSummary? get currentAudioSummary =>
+ _instance._currentAudioSummary;
+ AudioSummary? get _currentAudioSummary {
+ if (currentIndex == -1) {
+ return null;
+ }
+
+ final seq = audioSummaries;
+ if (seq == null || seq.isEmpty) {
+ return null;
+ }
+
+ return seq[currentIndex];
+ }
+
+ static Duration get position => _instance._position;
+ Duration get _position => _player.position;
+
+ static Duration? get duration => _instance._duration;
+ Duration? get _duration => _player.duration;
+
+ static Future add(AudioSummary audioSummary) async =>
+ await _instance._add(audioSummary);
+ Future _add(AudioSummary audioSummary) async {
+ if (!audioSummary.isValid) {
+ return;
+ }
+
+ if (_audios.children.any((audio) =>
+ (audio as SummaryLockCachingAudioSource).audioSummary.bvid ==
+ audioSummary.bvid)) {
+ return;
+ }
+
+ final viewResponse = await RemoteApi.getBaseSrc(
+ RemoteApiPath.audioInfo,
+ query: {'bvid': audioSummary.bvid as String},
+ );
+ final cid = json.decode(viewResponse.body)['data']['cid'];
+
+ final playUrlResponse = await RemoteApi.getBaseSrc(
+ RemoteApiPath.audioStream,
+ query: {
+ 'bvid': audioSummary.bvid as String,
+ 'cid': cid.toString(),
+ 'fnval': 16.toString(),
+ },
+ );
+ final playUrl = Uri.parse(
+ (json.decode(playUrlResponse.body)['data']['dash']['audio'] as List)
+ .last['baseUrl']);
+
+ return await _audios.add(
+ SummaryLockCachingAudioSource(
+ playUrl,
+ headers: RemoteApi.headers,
+ tag: MediaItem(
+ id: audioSummary.bvid!,
+ title: audioSummary.title,
+ artist: audioSummary.author,
+ ),
+ audioSummary: audioSummary,
+ ),
+ );
+ }
+
+ static Future removeAt(int index) async => _instance._removeAt(index);
+ Future _removeAt(int index) async {
+ if (index < 0 || index >= _audios.children.length) {
+ return;
+ }
+
+ if (_player.shuffleModeEnabled) {
+ final indices = _player.shuffleIndices;
+ if (indices == null) {
+ return;
+ }
+
+ final realIndex = indices[index];
+ if (realIndex == -1) {
+ return;
+ }
+
+ return await _audios.removeAt(realIndex);
+ }
+
+ return await _audios.removeAt(index);
+ }
+
+ static Future seek(Duration? position, {int? index}) async =>
+ _instance._seek(position, index: index);
+ Future _seek(Duration? position, {int? index}) async =>
+ _player.seek(position, index: index);
+
+ static Future seekTo(AudioSummary audioSummary) async =>
+ _instance._seekTo(audioSummary);
+ Future _seekTo(AudioSummary audioSummary) async {
+ int index = _player.sequence?.indexWhere((audio) =>
+ (audio as SummaryLockCachingAudioSource).audioSummary.bvid ==
+ audioSummary.bvid) ??
+ -1;
+
+ if (index != -1) {
+ return await _seek(null, index: index);
+ }
+ }
+
+ static Future seekToPrevious() async => _instance._seekToPrevious();
+ Future _seekToPrevious() async => await _player.seekToPrevious();
+
+ static Future seekToNext() async => _instance._seekToNext();
+ Future _seekToNext() async => await _player.seekToNext();
+
+ static Future play() async => _instance._play();
+ Future _play() async => await _player.play();
+
+ static Future pause() async => _instance._pause();
+ Future _pause() async => await _player.pause();
+
+ static Future setPlayMode(PlayMode playMode) async =>
+ _instance._setPlayMode(playMode);
+ Future _setPlayMode(PlayMode playMode) async {
+ _currentPlayMode = playMode;
+ switch (playMode) {
+ case PlayMode.all:
+ await _player.setLoopMode(LoopMode.all);
+ await _player.setShuffleModeEnabled(false);
+ case PlayMode.one:
+ await _player.setLoopMode(LoopMode.one);
+ await _player.setShuffleModeEnabled(false);
+ case PlayMode.shuffle:
+ await _player.setLoopMode(LoopMode.all);
+ await _player.setShuffleModeEnabled(true);
+ await _player.shuffle();
+ }
+ }
+}
+
+class AudioPlayModeNotifier extends ChangeNotifier {
+ static final AudioPlayModeNotifier _instance = AudioPlayModeNotifier._();
+
+ factory AudioPlayModeNotifier() => _instance;
+
+ AudioPlayModeNotifier._() {
+ AudioPlayApi.player.loopModeStream.listen((loopMode) => notifyListeners());
+ AudioPlayApi.player.shuffleModeEnabledStream
+ .listen((shuffleMode) => notifyListeners());
+ }
+
+ static void registerListener(VoidCallback listener) =>
+ _instance.addListener(listener);
+ static void unregisterListener(VoidCallback listener) =>
+ _instance.removeListener(listener);
+}
+
+class AudioPositionNotifier extends ChangeNotifier {
+ static final AudioPositionNotifier _instance = AudioPositionNotifier._();
+
+ factory AudioPositionNotifier() => _instance;
+
+ AudioPositionNotifier._() {
+ AudioPlayApi.player.positionStream.listen((position) => notifyListeners());
+ }
+
+ static void registerListener(VoidCallback listener) =>
+ _instance.addListener(listener);
+ static void unregisterListener(VoidCallback listener) =>
+ _instance.removeListener(listener);
+}
+
+class AudioSequenceNotifier extends ChangeNotifier {
+ static final AudioSequenceNotifier _instance = AudioSequenceNotifier._();
+
+ factory AudioSequenceNotifier() => _instance;
+
+ AudioSequenceNotifier._() {
+ AudioPlayApi.player.sequenceStream.listen((sequence) => notifyListeners());
+ AudioPlayApi.player.shuffleIndicesStream
+ .listen((indices) => notifyListeners());
+ AudioPlayApi.player.currentIndexStream.listen((index) => notifyListeners());
+ }
+
+ static void registerListener(VoidCallback listener) =>
+ _instance.addListener(listener);
+ static void unregisterListener(VoidCallback listener) =>
+ _instance.removeListener(listener);
+}
+
+class AudioPlayingNotifier extends ChangeNotifier {
+ static final AudioPlayingNotifier _instance = AudioPlayingNotifier._();
+
+ factory AudioPlayingNotifier() => _instance;
+
+ AudioPlayingNotifier._() {
+ AudioPlayApi.player.playingStream.listen((playing) => notifyListeners());
+ }
+
+ static void registerListener(VoidCallback listener) =>
+ _instance.addListener(listener);
+ static void unregisterListener(VoidCallback listener) =>
+ _instance.removeListener(listener);
+}
diff --git a/lib/src/service/local_lock_caching_audio_source.dart b/lib/src/service/local_lock_caching_audio_source.dart
new file mode 100644
index 0000000..a385a2b
--- /dev/null
+++ b/lib/src/service/local_lock_caching_audio_source.dart
@@ -0,0 +1,447 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:crypto/crypto.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+import 'package:rxdart/rxdart.dart';
+import 'package:just_audio/just_audio.dart';
+
+Future _getCacheDir() async => Directory(
+ p.join((await getApplicationCacheDirectory()).path, 'just_audio_cache'));
+
+HttpClient _createHttpClient({String? userAgent}) {
+ final client = HttpClient();
+ if (userAgent != null) {
+ client.userAgent = userAgent;
+ }
+ return client;
+}
+
+Future _getUrl(HttpClient client, Uri uri,
+ {Map? headers}) async {
+ final request = await client.getUrl(uri);
+ if (headers != null) {
+ final host = request.headers.value(HttpHeaders.hostHeader);
+ request.headers.clear();
+ request.headers.set(HttpHeaders.contentLengthHeader, '0');
+ headers.forEach((name, value) => request.headers.set(name, value));
+ if (host != null) {
+ request.headers.set(HttpHeaders.hostHeader, host);
+ }
+ if (client.userAgent != null) {
+ request.headers.set(HttpHeaders.userAgentHeader, client.userAgent!);
+ }
+ }
+ // Match ExoPlayer's native behavior
+ request.maxRedirects = 20;
+ return request;
+}
+
+/// Encapsulates the start and end of an HTTP range request.
+class _HttpRangeRequest {
+ /// The starting byte position of the range request.
+ final int start;
+
+ /// The last byte position of the range request, or `null` if requesting
+ /// until the end of the media.
+ final int? end;
+
+ /// The end byte position (exclusive), defaulting to `null`.
+ int? get endEx => end == null ? null : end! + 1;
+
+ _HttpRangeRequest(this.start, this.end);
+
+ /// Format a range header for this request.
+ String get header =>
+ 'bytes=$start-${end != null ? (end! - 1).toString() : ""}';
+
+ /// Creates an [_HttpRangeRequest] from [header].
+ static _HttpRangeRequest? parse(List? header) {
+ if (header == null || header.isEmpty) return null;
+ final match = RegExp(r'^bytes=(\d+)(-(\d+)?)?').firstMatch(header.first);
+ if (match == null) return null;
+ int? intGroup(int i) => match[i] != null ? int.parse(match[i]!) : null;
+ return _HttpRangeRequest(intGroup(1)!, intGroup(3));
+ }
+}
+
+/// Request parameters for a [StreamAudioSource].
+class _StreamingByteRangeRequest {
+ /// The start of the range request.
+ final int? start;
+
+ /// The end of the range request.
+ final int? end;
+
+ /// Completes when the response is available.
+ final _completer = Completer();
+
+ _StreamingByteRangeRequest(this.start, this.end);
+
+ /// The response for this request.
+ Future get future => _completer.future;
+
+ /// Completes this request with the given [response].
+ void complete(StreamAudioResponse response) {
+ if (_completer.isCompleted) {
+ return;
+ }
+ _completer.complete(response);
+ }
+
+ /// Fails this request with the given [error] and [stackTrace].
+ void fail(dynamic error, [StackTrace? stackTrace]) {
+ if (_completer.isCompleted) {
+ return;
+ }
+ _completer.completeError(error as Object, stackTrace);
+ }
+}
+
+/// When a byte range request on a [LockCachingAudioSource] overlaps partially
+/// with the cache file and partially with the live HTTP stream, the consumer
+/// needs to first consume the cached part before the live part. This class
+/// provides a place to buffer the live part until the consumer reaches it, and
+/// also keeps track of the [end] of the byte range so that the producer knows
+/// when to stop adding data.
+class _InProgressCacheResponse {
+ // NOTE: This isn't necessarily memory efficient. Since the entire audio file
+ // will likely be downloaded at a faster rate than the rate at which the
+ // player is consuming audio data, it is also likely that this buffered data
+ // will never be used.
+ // TODO: Improve this code.
+ // ignore: close_sinks
+ final controller = ReplaySubject>();
+ final int? end;
+ _InProgressCacheResponse({
+ required this.end,
+ });
+}
+
+/// This is an experimental audio source that caches the audio while it is being
+/// downloaded and played. It is not supported on platforms that do not provide
+/// access to the file system (e.g. web).
+class LocalLockCachingAudioSource extends StreamAudioSource {
+ Future? _response;
+ final Uri uri;
+ final Map? headers;
+ final Future cacheFile;
+ int _progress = 0;
+ final _requests = <_StreamingByteRangeRequest>[];
+ final _downloadProgressSubject = BehaviorSubject();
+ bool _downloading = false;
+
+ /// Creates a [LocalLockCachingAudioSource] to that provides [uri] to the player
+ /// while simultaneously caching it to [cacheFile]. If no cache file is
+ /// supplied, just_audio will allocate a cache file internally.
+ ///
+ /// If headers are set, just_audio will create a cleartext local HTTP proxy on
+ /// your device to forward HTTP requests with headers included.
+ LocalLockCachingAudioSource(
+ this.uri, {
+ this.headers,
+ File? cacheFile,
+ super.tag,
+ }) : cacheFile =
+ cacheFile != null ? Future.value(cacheFile) : _getCacheFile(uri) {
+ _init();
+ }
+
+ Future _init() async {
+ final cacheFile = await this.cacheFile;
+ _downloadProgressSubject.add((await cacheFile.exists()) ? 1.0 : 0.0);
+ }
+
+ /// Returns a [UriAudioSource] resolving directly to the cache file if it
+ /// exists, otherwise returns `this`. This can be
+ Future resolve() async {
+ final file = await cacheFile;
+ return await file.exists() ? AudioSource.uri(Uri.file(file.path)) : this;
+ }
+
+ /// Emits the current download progress as a double value from 0.0 (nothing
+ /// downloaded) to 1.0 (download complete).
+ Stream get downloadProgressStream => _downloadProgressSubject.stream;
+
+ /// Removes the underlying cache files. It is an error to clear the cache
+ /// while a download is in progress.
+ Future clearCache() async {
+ if (_downloading) {
+ throw Exception("Cannot clear cache while download is in progress");
+ }
+ _response = null;
+ final cacheFile = await this.cacheFile;
+ if (await cacheFile.exists()) {
+ await cacheFile.delete();
+ }
+ final mimeFile = await _mimeFile;
+ if (await mimeFile.exists()) {
+ await mimeFile.delete();
+ }
+ _progress = 0;
+ _downloadProgressSubject.add(0.0);
+ }
+
+ /// Get file for caching [uri] with proper extension
+ static Future _getCacheFile(final Uri uri) async => File(p.joinAll([
+ (await _getCacheDir()).path,
+ 'remote',
+ sha256
+ .convert(utf8.encode(uri.pathSegments.last.toString()))
+ .toString() +
+ p.extension(uri.path),
+ ]));
+
+ Future get _partialCacheFile async =>
+ File('${(await cacheFile).path}.part');
+
+ /// We use this to record the original content type of the downloaded audio.
+ /// NOTE: We could instead rely on the cache file extension, but the original
+ /// URL might not provide a correct extension. As a fallback, we could map the
+ /// MIME type to an extension but we will need a complete dictionary.
+ Future get _mimeFile async => File('${(await cacheFile).path}.mime');
+
+ Future _readCachedMimeType() async {
+ final file = await _mimeFile;
+ if (file.existsSync()) {
+ return (await _mimeFile).readAsString();
+ } else {
+ return 'audio/mpeg';
+ }
+ }
+
+ /// Start downloading the whole audio file to the cache and fulfill byte-range
+ /// requests during the download. There are 3 scenarios:
+ ///
+ /// 1. If the byte range request falls entirely within the cache region, it is
+ /// fulfilled from the cache.
+ /// 2. If the byte range request overlaps the cached region, the first part is
+ /// fulfilled from the cache, and the region beyond the cache is fulfilled
+ /// from a memory buffer of the downloaded data.
+ /// 3. If the byte range request is entirely outside the cached region, a
+ /// separate HTTP request is made to fulfill it while the download of the
+ /// entire file continues in parallel.
+ Future _fetch() async {
+ _downloading = true;
+ final cacheFile = await this.cacheFile;
+ final partialCacheFile = await _partialCacheFile;
+
+ File getEffectiveCacheFile() =>
+ partialCacheFile.existsSync() ? partialCacheFile : cacheFile;
+
+ final httpClient = _createHttpClient();
+ // final httpClient = _createHttpClient(userAgent: _player?._userAgent);
+ final httpRequest = await _getUrl(httpClient, uri, headers: headers);
+ final response = await httpRequest.close();
+ if (response.statusCode != 200) {
+ httpClient.close();
+ throw Exception('HTTP Status Error: ${response.statusCode}');
+ }
+ (await _partialCacheFile).createSync(recursive: true);
+ // TODO: Should close sink after done, but it throws an error.
+ // ignore: close_sinks
+ final sink = (await _partialCacheFile).openWrite();
+ final sourceLength =
+ response.contentLength == -1 ? null : response.contentLength;
+ final mimeType = response.headers.contentType.toString();
+ final acceptRanges = response.headers.value(HttpHeaders.acceptRangesHeader);
+ final originSupportsRangeRequests =
+ acceptRanges != null && acceptRanges != 'none';
+ final mimeFile = await _mimeFile;
+ await mimeFile.writeAsString(mimeType);
+ final inProgressResponses = <_InProgressCacheResponse>[];
+ late StreamSubscription> subscription;
+ var percentProgress = 0;
+ void updateProgress(int newPercentProgress) {
+ if (newPercentProgress != percentProgress) {
+ percentProgress = newPercentProgress;
+ _downloadProgressSubject.add(percentProgress / 100);
+ }
+ }
+
+ _progress = 0;
+ subscription = response.listen((data) async {
+ _progress += data.length;
+ final newPercentProgress = (sourceLength == null)
+ ? 0
+ : (sourceLength == 0)
+ ? 100
+ : (100 * _progress ~/ sourceLength);
+ updateProgress(newPercentProgress);
+ sink.add(data);
+ final readyRequests = _requests
+ .where((request) =>
+ !originSupportsRangeRequests ||
+ request.start == null ||
+ (request.start!) < _progress)
+ .toList();
+ final notReadyRequests = _requests
+ .where((request) =>
+ originSupportsRangeRequests &&
+ request.start != null &&
+ (request.start!) >= _progress)
+ .toList();
+ // Add this live data to any responses in progress.
+ for (var cacheResponse in inProgressResponses) {
+ final end = cacheResponse.end;
+ if (end != null && _progress >= end) {
+ // We've received enough data to fulfill the byte range request.
+ final subEnd =
+ min(data.length, max(0, data.length - (_progress - end)));
+ cacheResponse.controller.add(data.sublist(0, subEnd));
+ cacheResponse.controller.close();
+ } else {
+ cacheResponse.controller.add(data);
+ }
+ }
+ inProgressResponses.removeWhere((element) => element.controller.isClosed);
+ if (_requests.isEmpty) return;
+ // Prevent further data coming from the HTTP source until we have set up
+ // an entry in inProgressResponses to continue receiving live HTTP data.
+ subscription.pause();
+ await sink.flush();
+ // Process any requests that start within the cache.
+ for (var request in readyRequests) {
+ _requests.remove(request);
+ int? start, end;
+ if (originSupportsRangeRequests) {
+ start = request.start;
+ end = request.end;
+ } else {
+ // If the origin doesn't support range requests, the proxy should also
+ // ignore range requests and instead serve a complete 200 response
+ // which the client (AV or exo player) should know how to deal with.
+ }
+ final effectiveStart = start ?? 0;
+ final effectiveEnd = end ?? sourceLength;
+ Stream> responseStream;
+ if (effectiveEnd != null && effectiveEnd <= _progress) {
+ responseStream =
+ getEffectiveCacheFile().openRead(effectiveStart, effectiveEnd);
+ } else {
+ final cacheResponse = _InProgressCacheResponse(end: effectiveEnd);
+ inProgressResponses.add(cacheResponse);
+ responseStream = Rx.concatEager([
+ // NOTE: The cache file part of the stream must not overlap with
+ // the live part. "_progress" should
+ // to the cache file at the time
+ getEffectiveCacheFile().openRead(effectiveStart, _progress),
+ cacheResponse.controller.stream,
+ ]);
+ }
+ request.complete(StreamAudioResponse(
+ rangeRequestsSupported: originSupportsRangeRequests,
+ sourceLength: start != null ? sourceLength : null,
+ contentLength:
+ effectiveEnd != null ? effectiveEnd - effectiveStart : null,
+ offset: start,
+ contentType: mimeType,
+ stream: responseStream.asBroadcastStream(),
+ ));
+ }
+ subscription.resume();
+ // Process any requests that start beyond the cache.
+ for (var request in notReadyRequests) {
+ _requests.remove(request);
+ final start = request.start!;
+ final end = request.end ?? sourceLength;
+ final httpClient = _createHttpClient();
+ // final httpClient = _createHttpClient(userAgent: _player?._userAgent);
+
+ final rangeRequest = _HttpRangeRequest(start, end);
+ _getUrl(httpClient, uri, headers: {
+ if (headers != null) ...headers!,
+ HttpHeaders.rangeHeader: rangeRequest.header,
+ }).then((httpRequest) async {
+ final response = await httpRequest.close();
+ if (response.statusCode != 206) {
+ httpClient.close();
+ throw Exception('HTTP Status Error: ${response.statusCode}');
+ }
+ request.complete(StreamAudioResponse(
+ rangeRequestsSupported: originSupportsRangeRequests,
+ sourceLength: sourceLength,
+ contentLength: end != null ? end - start : null,
+ offset: start,
+ contentType: mimeType,
+ stream: response.asBroadcastStream(),
+ ));
+ }, onError: (dynamic e, StackTrace? stackTrace) {
+ request.fail(e, stackTrace);
+ }).onError((Object e, StackTrace st) {
+ request.fail(e, st);
+ });
+ }
+ }, onDone: () async {
+ if (sourceLength == null) {
+ updateProgress(100);
+ }
+ for (var cacheResponse in inProgressResponses) {
+ if (!cacheResponse.controller.isClosed) {
+ cacheResponse.controller.close();
+ }
+ }
+ (await _partialCacheFile).renameSync(cacheFile.path);
+ await subscription.cancel();
+ httpClient.close();
+ _downloading = false;
+ }, onError: (Object e, StackTrace stackTrace) async {
+ (await _partialCacheFile).deleteSync();
+ httpClient.close();
+ // Fail all pending requests
+ for (final req in _requests) {
+ req.fail(e, stackTrace);
+ }
+ _requests.clear();
+ // Close all in progress requests
+ for (final res in inProgressResponses) {
+ res.controller.addError(e, stackTrace);
+ res.controller.close();
+ }
+ _downloading = false;
+ }, cancelOnError: true);
+ return response;
+ }
+
+ @override
+ Future request([int? start, int? end]) async {
+ final cacheFile = await this.cacheFile;
+ if (cacheFile.existsSync()) {
+ final sourceLength = cacheFile.lengthSync();
+ return StreamAudioResponse(
+ rangeRequestsSupported: true,
+ sourceLength: start != null ? sourceLength : null,
+ contentLength: (end ?? sourceLength) - (start ?? 0),
+ offset: start,
+ contentType: await _readCachedMimeType(),
+ stream: cacheFile.openRead(start, end).asBroadcastStream(),
+ );
+ }
+ final byteRangeRequest = _StreamingByteRangeRequest(start, end);
+ _requests.add(byteRangeRequest);
+ _response ??=
+ _fetch().catchError((dynamic error, StackTrace? stackTrace) async {
+ // So that we can restart later
+ _response = null;
+ // Cancel any pending request
+ for (final req in _requests) {
+ req.fail(error, stackTrace);
+ }
+ return Future.error(error as Object, stackTrace);
+ });
+ return byteRangeRequest.future.then((response) {
+ response.stream.listen((event) {}, onError: (Object e, StackTrace st) {
+ // So that we can restart later
+ _response = null;
+ // Cancel any pending request
+ for (final req in _requests) {
+ req.fail(e, st);
+ }
+ });
+ return response;
+ });
+ }
+}
diff --git a/lib/src/service/remote_api.dart b/lib/src/service/remote_api.dart
new file mode 100644
index 0000000..9832b12
--- /dev/null
+++ b/lib/src/service/remote_api.dart
@@ -0,0 +1,197 @@
+// import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+import 'package:cronet_http/cronet_http.dart';
+// import 'package:crypto/crypto.dart';
+
+enum RemoteApiPath {
+ // wbi,
+ search,
+ audioInfo,
+ audioStream,
+ userInfo,
+ userSpace,
+}
+
+class RemoteApi {
+ static final RemoteApi _instance = RemoteApi._();
+
+ factory RemoteApi() => _instance;
+
+ RemoteApi._()
+ : _client = CronetClient.fromCronetEngine(
+ CronetEngine.build(
+ cacheMode: CacheMode.memory,
+ cacheMaxSize: 32 * 1024 * 1024,
+ ),
+ closeEngine: true,
+ );
+
+ static Future init() async {
+ final response =
+ await _instance._client.get(Uri.parse(_headers['Referer']!));
+ _headers['Cookie'] = response.headers['set-cookie']!;
+ // _instance._client.get(getBaseUri(RemoteApiPath.wbi)).then((response) {
+ // final responseJson = json.decode(response.body);
+
+ // final imgKey = (responseJson['data']['wbi_img']['img_url'] as String)
+ // .split('/')
+ // .last
+ // .split('.')
+ // .first;
+ // final subKey = (responseJson['data']['wbi_img']['sub_url'] as String)
+ // .split('/')
+ // .last
+ // .split('.')
+ // .first;
+ // final rawWbiKey = imgKey + subKey;
+
+ // _wbiKey = mixinKeyEncryptionTable
+ // .map((i) => rawWbiKey[i])
+ // .join()
+ // .substring(0, 32);
+ // });
+ }
+
+ static const base = 'api.bilibili.com';
+ static const accountBase = 'api.vc.bilibili.com';
+ static final Map _headers = {
+ 'User-Agent': 'Mozilla/5.0',
+ 'Referer': 'https://www.bilibili.com',
+ };
+ // static const mixinKeyEncryptionTable = [
+ // 46,
+ // 47,
+ // 18,
+ // 2,
+ // 53,
+ // 8,
+ // 23,
+ // 32,
+ // 15,
+ // 50,
+ // 10,
+ // 31,
+ // 58,
+ // 3,
+ // 45,
+ // 35,
+ // 27,
+ // 43,
+ // 5,
+ // 49,
+ // 33,
+ // 9,
+ // 42,
+ // 19,
+ // 29,
+ // 28,
+ // 14,
+ // 39,
+ // 12,
+ // 38,
+ // 41,
+ // 13,
+ // 37,
+ // 48,
+ // 7,
+ // 16,
+ // 24,
+ // 55,
+ // 40,
+ // 61,
+ // 26,
+ // 17,
+ // 0,
+ // 1,
+ // 60,
+ // 51,
+ // 30,
+ // 4,
+ // 22,
+ // 25,
+ // 54,
+ // 21,
+ // 56,
+ // 59,
+ // 6,
+ // 63,
+ // 57,
+ // 62,
+ // 11,
+ // 36,
+ // 20,
+ // 34,
+ // 44,
+ // 52
+ // ];
+ // static late final String _wbiKey;
+ final http.Client _client;
+
+ static Map get headers => _headers;
+ static String getPath(RemoteApiPath path) => switch (path) {
+ // RemoteApiPath.wbi => 'x/web-interface/nav',
+ RemoteApiPath.search => 'x/web-interface/search/type',
+ RemoteApiPath.audioInfo => 'x/web-interface/view',
+ RemoteApiPath.audioStream => 'x/player/playurl',
+ RemoteApiPath.userInfo => 'account/v1/user/cards',
+ RemoteApiPath.userSpace => 'x/series/recArchivesByKeywords',
+ };
+ static Uri getBaseUri(
+ RemoteApiPath path, {
+ Map? query,
+ }) {
+ final apiBase = path == RemoteApiPath.userInfo ? accountBase : base;
+ return Uri.https(apiBase, getPath(path), query);
+ }
+
+ static Future get(
+ Uri url, {
+ Map? headers,
+ }) async =>
+ _instance._get(url, headers: headers);
+
+ Future _get(
+ Uri url, {
+ Map? headers,
+ }) async =>
+ await _client.get(url, headers: {..._headers, ...?headers});
+
+ static Future getBaseSrc(
+ RemoteApiPath path, {
+ Map? query,
+ Map? headers,
+ }) async =>
+ await get(getBaseUri(path, query: query), headers: headers);
+
+ // static Map encryptWbi(Map? query) =>
+ // _instance._encryptWbi(query);
+ // Map _encryptWbi(Map? query) {
+ // query ??= {};
+
+ // query['wts'] =
+ // (DateTime.now().millisecondsSinceEpoch / 1000).round().toString();
+
+ // query = Map.fromEntries(
+ // query.entries.toList()
+ // ..sort(
+ // (a, b) => a.key.compareTo(b.key),
+ // ),
+ // ).map(
+ // (k, v) => MapEntry(
+ // k,
+ // v.replaceAll(RegExp(r"[!'()*]"), ''),
+ // ),
+ // );
+
+ // query['w_rid'] = md5
+ // .convert(
+ // utf8.encode(
+ // Uri(queryParameters: query).query + _wbiKey,
+ // ),
+ // )
+ // .toString();
+
+ // return query;
+ // }
+}
diff --git a/lib/src/service/seamless_audio_handler.dart b/lib/src/service/seamless_audio_handler.dart
new file mode 100644
index 0000000..8afa5cb
--- /dev/null
+++ b/lib/src/service/seamless_audio_handler.dart
@@ -0,0 +1,963 @@
+import 'dart:async';
+import 'dart:math';
+
+import 'package:audio_service/audio_service.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:just_audio_platform_interface/just_audio_platform_interface.dart';
+import 'package:rxdart/rxdart.dart';
+
+export 'package:audio_service/audio_service.dart' show MediaItem;
+
+late SwitchAudioHandler _audioHandler;
+late JustAudioPlatform _platform;
+
+/// Provides the [init] method to initialise just_audio for background playback.
+class SeamlessJustAudioBackground {
+ /// Initialise just_audio for background playback. This should be called from
+ /// your app's `main` method. e.g.:
+ ///
+ /// ```dart
+ /// Future main() async {
+ /// await JustAudioBackground.init(
+ /// androidNotificationChannelId: 'com.ryanheise.bg_demo.channel.audio',
+ /// androidNotificationChannelName: 'Audio playback',
+ /// androidNotificationOngoing: true,
+ /// );
+ /// runApp(MyApp());
+ /// }
+ /// ```
+ ///
+ /// Each parameter controls a behaviour in audio_service. Consult
+ /// audio_service's `AudioServiceConfig` API documentation for more
+ /// information.
+ static Future init({
+ bool androidResumeOnClick = true,
+ String? androidNotificationChannelId,
+ String androidNotificationChannelName = 'Notifications',
+ String? androidNotificationChannelDescription,
+ Color? notificationColor,
+ String androidNotificationIcon = 'mipmap/ic_launcher',
+ bool androidShowNotificationBadge = false,
+ bool androidNotificationClickStartsActivity = true,
+ bool androidNotificationOngoing = false,
+ bool androidStopForegroundOnPause = true,
+ int? artDownscaleWidth,
+ int? artDownscaleHeight,
+ Duration fastForwardInterval = const Duration(seconds: 10),
+ Duration rewindInterval = const Duration(seconds: 10),
+ bool preloadArtwork = false,
+ Map? androidBrowsableRootExtras,
+ }) async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await _SeamlessJustAudioBackgroundPlugin.setup(
+ androidResumeOnClick: androidResumeOnClick,
+ androidNotificationChannelId: androidNotificationChannelId,
+ androidNotificationChannelName: androidNotificationChannelName,
+ androidNotificationChannelDescription:
+ androidNotificationChannelDescription,
+ notificationColor: notificationColor,
+ androidNotificationIcon: androidNotificationIcon,
+ androidShowNotificationBadge: androidShowNotificationBadge,
+ androidNotificationClickStartsActivity:
+ androidNotificationClickStartsActivity,
+ androidNotificationOngoing: androidNotificationOngoing,
+ androidStopForegroundOnPause: androidStopForegroundOnPause,
+ artDownscaleWidth: artDownscaleWidth,
+ artDownscaleHeight: artDownscaleHeight,
+ fastForwardInterval: fastForwardInterval,
+ rewindInterval: rewindInterval,
+ preloadArtwork: preloadArtwork,
+ androidBrowsableRootExtras: androidBrowsableRootExtras,
+ );
+ }
+}
+
+class _SeamlessJustAudioBackgroundPlugin extends JustAudioPlatform {
+ static Future setup({
+ bool androidResumeOnClick = true,
+ String? androidNotificationChannelId,
+ String androidNotificationChannelName = 'Notifications',
+ String? androidNotificationChannelDescription,
+ Color? notificationColor,
+ String androidNotificationIcon = 'mipmap/ic_launcher',
+ bool androidShowNotificationBadge = false,
+ bool androidNotificationClickStartsActivity = true,
+ bool androidNotificationOngoing = false,
+ bool androidStopForegroundOnPause = true,
+ int? artDownscaleWidth,
+ int? artDownscaleHeight,
+ Duration fastForwardInterval = const Duration(seconds: 10),
+ Duration rewindInterval = const Duration(seconds: 10),
+ bool preloadArtwork = false,
+ Map? androidBrowsableRootExtras,
+ }) async {
+ _platform = JustAudioPlatform.instance;
+ JustAudioPlatform.instance = _SeamlessJustAudioBackgroundPlugin();
+ _audioHandler = await AudioService.init(
+ builder: () => SwitchAudioHandler(BaseAudioHandler()),
+ config: AudioServiceConfig(
+ androidResumeOnClick: androidResumeOnClick,
+ androidNotificationChannelId: androidNotificationChannelId,
+ androidNotificationChannelName: androidNotificationChannelName,
+ androidNotificationChannelDescription:
+ androidNotificationChannelDescription,
+ notificationColor: notificationColor,
+ androidNotificationIcon: androidNotificationIcon,
+ androidShowNotificationBadge: androidShowNotificationBadge,
+ androidNotificationClickStartsActivity:
+ androidNotificationClickStartsActivity,
+ androidNotificationOngoing: androidNotificationOngoing,
+ androidStopForegroundOnPause: androidStopForegroundOnPause,
+ artDownscaleWidth: artDownscaleWidth,
+ artDownscaleHeight: artDownscaleHeight,
+ fastForwardInterval: fastForwardInterval,
+ rewindInterval: rewindInterval,
+ preloadArtwork: preloadArtwork,
+ androidBrowsableRootExtras: androidBrowsableRootExtras,
+ ),
+ );
+ }
+
+ _SeamlessJustAudioPlayer? _player;
+
+ _SeamlessJustAudioBackgroundPlugin();
+
+ @override
+ Future init(InitRequest request) async {
+ if (_player != null) {
+ throw PlatformException(
+ code: "error",
+ message:
+ "just_audio_background supports only a single player instance");
+ }
+ _player = _SeamlessJustAudioPlayer(
+ initRequest: request,
+ );
+ return _player!;
+ }
+
+ @override
+ Future disposePlayer(
+ DisposePlayerRequest request) async {
+ final player = _player;
+ _player = null;
+ await player?.release();
+ return DisposePlayerResponse();
+ }
+
+ @override
+ Future disposeAllPlayers(
+ DisposeAllPlayersRequest request) async {
+ final player = _player;
+ _player = null;
+ await player?.release();
+ return DisposeAllPlayersResponse();
+ }
+}
+
+class _SeamlessJustAudioPlayer extends AudioPlayerPlatform {
+ final InitRequest initRequest;
+ final eventController = StreamController.broadcast();
+ final playerDataController = StreamController.broadcast();
+ bool? _playing;
+ IcyMetadataMessage? _icyMetadata;
+ int? _androidAudioSessionId;
+ late final _SeamlessPlayerAudioHandler _playerAudioHandler;
+
+ _SeamlessJustAudioPlayer({required this.initRequest})
+ : super(initRequest.id) {
+ _playerAudioHandler = _SeamlessPlayerAudioHandler(initRequest);
+ _audioHandler.inner = _playerAudioHandler;
+ _audioHandler.playbackState.listen((playbackState) {
+ broadcastPlaybackEvent();
+ });
+ _audioHandler.customEvent.listen((dynamic event) {
+ switch (event['type']) {
+ case 'icyMetadata':
+ _icyMetadata = event['value'] as IcyMetadataMessage?;
+ broadcastPlaybackEvent();
+ break;
+ case 'androidAudioSessionId':
+ _androidAudioSessionId = event['value'] as int?;
+ broadcastPlaybackEvent();
+ break;
+ }
+ });
+ _audioHandler.mediaItem.listen((mediaItem) {
+ if (mediaItem == null) return;
+ broadcastPlaybackEvent();
+ });
+ }
+
+ PlaybackState get playbackState => _audioHandler.playbackState.nvalue!;
+
+ Future release() async {
+ eventController.close();
+ await _audioHandler.stop();
+ }
+
+ void broadcastPlaybackEvent() {
+ if (eventController.isClosed) return;
+ eventController.add(PlaybackEventMessage(
+ //processingState: playbackState.processingState,
+ processingState: {
+ AudioProcessingState.idle: ProcessingStateMessage.idle,
+ AudioProcessingState.loading: ProcessingStateMessage.loading,
+ AudioProcessingState.ready: ProcessingStateMessage.ready,
+ AudioProcessingState.buffering: ProcessingStateMessage.buffering,
+ AudioProcessingState.completed: ProcessingStateMessage.completed,
+ AudioProcessingState.error: ProcessingStateMessage.idle,
+ }[playbackState.processingState]!,
+ updatePosition: playbackState.position,
+ updateTime: playbackState.updateTime,
+ bufferedPosition: playbackState.bufferedPosition,
+ icyMetadata: _icyMetadata,
+ duration: _playerAudioHandler.currentMediaItem?.duration,
+ currentIndex: playbackState.queueIndex,
+ androidAudioSessionId: _androidAudioSessionId,
+ ));
+ if (playbackState.playing != _playing) {
+ _playing = playbackState.playing;
+ playerDataController.add(PlayerDataMessage(
+ playing: playbackState.playing,
+ ));
+ }
+ }
+
+ @override
+ Stream get playbackEventMessageStream =>
+ eventController.stream;
+
+ @override
+ Stream get playerDataMessageStream =>
+ playerDataController.stream;
+
+ @override
+ Future load(LoadRequest request) =>
+ _playerAudioHandler.customLoad(request);
+
+ @override
+ Future play(PlayRequest request) async {
+ await _audioHandler.play();
+ return PlayResponse();
+ }
+
+ @override
+ Future pause(PauseRequest request) async {
+ await _audioHandler.pause();
+ return PauseResponse();
+ }
+
+ @override
+ Future setVolume(SetVolumeRequest request) =>
+ _playerAudioHandler.customSetVolume(request);
+
+ @override
+ Future setSpeed(SetSpeedRequest request) async {
+ await _playerAudioHandler.setSpeed(request.speed);
+ return SetSpeedResponse();
+ }
+
+ @override
+ Future setPitch(SetPitchRequest request) async {
+ await _playerAudioHandler.customSetPitch(request);
+ return SetPitchResponse();
+ }
+
+ @override
+ Future setSkipSilence(
+ SetSkipSilenceRequest request) async {
+ await _playerAudioHandler.customSetSkipSilence(request);
+ return SetSkipSilenceResponse();
+ }
+
+ @override
+ Future setLoopMode(SetLoopModeRequest request) async {
+ await _audioHandler
+ .setRepeatMode(AudioServiceRepeatMode.values[request.loopMode.index]);
+ return SetLoopModeResponse();
+ }
+
+ @override
+ Future setShuffleMode(
+ SetShuffleModeRequest request) async {
+ await _audioHandler.setShuffleMode(
+ AudioServiceShuffleMode.values[request.shuffleMode.index]);
+ return SetShuffleModeResponse();
+ }
+
+ @override
+ Future setShuffleOrder(
+ SetShuffleOrderRequest request) =>
+ _playerAudioHandler.customSetShuffleOrder(request);
+
+ @override
+ Future setWebCrossOrigin(
+ SetWebCrossOriginRequest request) async {
+ _playerAudioHandler.customSetWebCrossOrigin(request);
+ return SetWebCrossOriginResponse();
+ }
+
+ @override
+ Future seek(SeekRequest request) =>
+ _playerAudioHandler.customPlayerSeek(request);
+
+ @override
+ Future concatenatingInsertAll(
+ ConcatenatingInsertAllRequest request) =>
+ _playerAudioHandler.customConcatenatingInsertAll(request);
+
+ @override
+ Future concatenatingRemoveRange(
+ ConcatenatingRemoveRangeRequest request) =>
+ _playerAudioHandler.customConcatenatingRemoveRange(request);
+
+ @override
+ Future concatenatingMove(
+ ConcatenatingMoveRequest request) =>
+ _playerAudioHandler.customConcatenatingMove(request);
+
+ @override
+ Future setAndroidAudioAttributes(
+ SetAndroidAudioAttributesRequest request) =>
+ _playerAudioHandler.customSetAndroidAudioAttributes(request);
+
+ @override
+ Future
+ setAutomaticallyWaitsToMinimizeStalling(
+ SetAutomaticallyWaitsToMinimizeStallingRequest request) =>
+ _playerAudioHandler
+ .customSetAutomaticallyWaitsToMinimizeStalling(request);
+
+ @override
+ Future androidEqualizerBandSetGain(
+ AndroidEqualizerBandSetGainRequest request) =>
+ _playerAudioHandler.customAndroidEqualizerBandSetGain(request);
+
+ @override
+ Future androidEqualizerGetParameters(
+ AndroidEqualizerGetParametersRequest request) =>
+ _playerAudioHandler.customAndroidEqualizerGetParameters(request);
+
+ @override
+ Future
+ androidLoudnessEnhancerSetTargetGain(
+ AndroidLoudnessEnhancerSetTargetGainRequest request) =>
+ _playerAudioHandler
+ .customAndroidLoudnessEnhancerSetTargetGain(request);
+
+ @override
+ Future audioEffectSetEnabled(
+ AudioEffectSetEnabledRequest request) =>
+ _playerAudioHandler.customAudioEffectSetEnabled(request);
+
+ @override
+ Future setAllowsExternalPlayback(
+ SetAllowsExternalPlaybackRequest request) =>
+ _playerAudioHandler.customSetAllowsExternalPlayback(request);
+
+ @override
+ Future
+ setCanUseNetworkResourcesForLiveStreamingWhilePaused(
+ SetCanUseNetworkResourcesForLiveStreamingWhilePausedRequest
+ request) =>
+ _playerAudioHandler
+ .customSetCanUseNetworkResourcesForLiveStreamingWhilePaused(
+ request);
+
+ @override
+ Future setPreferredPeakBitRate(
+ SetPreferredPeakBitRateRequest request) =>
+ _playerAudioHandler.customSetPreferredPeakBitRate(request);
+}
+
+class _SeamlessPlayerAudioHandler extends BaseAudioHandler
+ with QueueHandler, SeekHandler {
+ final _playerCompleter = Completer();
+ PlaybackEventMessage _justAudioEvent = PlaybackEventMessage(
+ processingState: ProcessingStateMessage.idle,
+ updateTime: DateTime.now(),
+ updatePosition: Duration.zero,
+ bufferedPosition: Duration.zero,
+ duration: null,
+ icyMetadata: null,
+ currentIndex: null,
+ androidAudioSessionId: null,
+ );
+ AudioSourceMessage? _source;
+ bool _playing = false;
+ double _speed = 1.0;
+ _SeamlessSeeker? _seeker;
+ AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none;
+ AudioServiceShuffleMode _shuffleMode = AudioServiceShuffleMode.none;
+ List _shuffleIndices = [];
+ List _shuffleIndicesInv = [];
+ List _effectiveIndices = [];
+ List _effectiveIndicesInv = [];
+
+ Future get _player => _playerCompleter.future;
+ int? get index => _justAudioEvent.currentIndex;
+ MediaItem? get currentMediaItem => index != null &&
+ currentQueue != null &&
+ index! >= 0 &&
+ index! < currentQueue!.length
+ ? currentQueue![index!]
+ : null;
+
+ List? get currentQueue => queue.nvalue;
+
+ _SeamlessPlayerAudioHandler(InitRequest initRequest) {
+ _init(initRequest);
+ }
+
+ Future _init(InitRequest initRequest) async {
+ final player = await _platform.init(initRequest);
+ _playerCompleter.complete(player);
+ final playbackEventMessageStream = player.playbackEventMessageStream;
+ playbackEventMessageStream.listen((event) {
+ _justAudioEvent = event;
+ _broadcastState();
+ });
+ playbackEventMessageStream
+ .map((event) => event.icyMetadata)
+ .distinct()
+ .listen((icyMetadata) {
+ customEvent.add({
+ 'type': 'icyMetadata',
+ 'value': icyMetadata,
+ });
+ });
+ playbackEventMessageStream
+ .map((event) => event.androidAudioSessionId)
+ .distinct()
+ .listen((audioSessionId) {
+ customEvent.add({
+ 'type': 'androidAudioSessionId',
+ 'value': audioSessionId,
+ });
+ });
+ playbackEventMessageStream
+ .map((event) => SeamlessTrackInfo(event.currentIndex, event.duration))
+ .distinct()
+ .debounceTime(const Duration(milliseconds: 100))
+ .map((track) {
+ // Platform may send us a null duration on dispose, which we should
+ // ignore.
+ final currentMediaItem = this.currentMediaItem;
+ if (currentMediaItem != null) {
+ if (track.duration == null && currentMediaItem.duration != null) {
+ return SeamlessTrackInfo(track.index, currentMediaItem.duration);
+ }
+ }
+ return track;
+ })
+ .distinct()
+ .listen((track) {
+ if (currentMediaItem != null) {
+ if (track.duration != currentMediaItem!.duration &&
+ (index! < queue.nvalue!.length && track.duration != null)) {
+ currentQueue![index!] =
+ currentQueue![index!].copyWith(duration: track.duration);
+ queue.add(currentQueue!);
+ }
+ mediaItem.add(currentMediaItem!);
+ }
+ });
+ }
+
+ @override
+ Future updateQueue(List queue) async {
+ this.queue.add(queue);
+ if (mediaItem.nvalue == null &&
+ index != null &&
+ index! >= 0 &&
+ index! < queue.length) {
+ mediaItem.add(queue[index!]);
+ }
+ }
+
+ Future customLoad(LoadRequest request) async {
+ _source = request.audioSourceMessage;
+ _updateShuffleIndices();
+ _updateQueue();
+ final response = await (await _player).load(LoadRequest(
+ audioSourceMessage: _source!,
+ initialPosition: request.initialPosition,
+ initialIndex: request.initialIndex,
+ ));
+ return LoadResponse(duration: response.duration);
+ }
+
+ Future customSetVolume(SetVolumeRequest request) async =>
+ await (await _player).setVolume(request);
+
+ Future customSetSpeed(SetSpeedRequest request) async =>
+ await (await _player).setSpeed(request);
+
+ Future customSetPitch(SetPitchRequest request) async =>
+ await (await _player).setPitch(request);
+
+ Future customSetSkipSilence(
+ SetSkipSilenceRequest request) async =>
+ await (await _player).setSkipSilence(request);
+
+ Future customPlayerSeek(SeekRequest request) async =>
+ await (await _player).seek(request);
+
+ Future customSetShuffleOrder(
+ SetShuffleOrderRequest request) async {
+ _source = request.audioSourceMessage;
+ _updateShuffleIndices();
+ _broadcastStateIfActive();
+ return await (await _player).setShuffleOrder(SetShuffleOrderRequest(
+ audioSourceMessage: _source!,
+ ));
+ }
+
+ Future customSetWebCrossOrigin(
+ SetWebCrossOriginRequest request) async {
+ return await (await _player).setWebCrossOrigin(request);
+ }
+
+ Future customConcatenatingInsertAll(
+ ConcatenatingInsertAllRequest request) async {
+ final cat = _source!.findCat(request.id)!;
+ cat.children.insertAll(request.index, request.children);
+ _updateShuffleIndices();
+ _broadcastStateIfActive();
+ _updateQueue();
+ return await (await _player).concatenatingInsertAll(request);
+ }
+
+ Future customConcatenatingRemoveRange(
+ ConcatenatingRemoveRangeRequest request) async {
+ final cat = _source!.findCat(request.id)!;
+ cat.children.removeRange(request.startIndex, request.endIndex);
+ _updateShuffleIndices();
+ _broadcastStateIfActive();
+ _updateQueue();
+ return await (await _player).concatenatingRemoveRange(request);
+ }
+
+ Future customConcatenatingMove(
+ ConcatenatingMoveRequest request) async {
+ final cat = _source!.findCat(request.id)!;
+ cat.children
+ .insert(request.newIndex, cat.children.removeAt(request.currentIndex));
+ _updateShuffleIndices();
+ _broadcastStateIfActive();
+ _updateQueue();
+ return await (await _player).concatenatingMove(request);
+ }
+
+ Future customSetAndroidAudioAttributes(
+ SetAndroidAudioAttributesRequest request) async =>
+ await (await _player).setAndroidAudioAttributes(request);
+
+ Future
+ customSetAutomaticallyWaitsToMinimizeStalling(
+ SetAutomaticallyWaitsToMinimizeStallingRequest request) async =>
+ await (await _player)
+ .setAutomaticallyWaitsToMinimizeStalling(request);
+
+ Future customAndroidEqualizerBandSetGain(
+ AndroidEqualizerBandSetGainRequest request) async =>
+ await (await _player).androidEqualizerBandSetGain(request);
+
+ Future
+ customAndroidEqualizerGetParameters(
+ AndroidEqualizerGetParametersRequest request) async =>
+ await (await _player).androidEqualizerGetParameters(request);
+
+ Future
+ customAndroidLoudnessEnhancerSetTargetGain(
+ AndroidLoudnessEnhancerSetTargetGainRequest request) async =>
+ await (await _player).androidLoudnessEnhancerSetTargetGain(request);
+
+ Future customAudioEffectSetEnabled(
+ AudioEffectSetEnabledRequest request) async =>
+ await (await _player).audioEffectSetEnabled(request);
+
+ Future customSetAllowsExternalPlayback(
+ SetAllowsExternalPlaybackRequest request) async =>
+ await (await _player).setAllowsExternalPlayback(request);
+
+ Future
+ customSetCanUseNetworkResourcesForLiveStreamingWhilePaused(
+ SetCanUseNetworkResourcesForLiveStreamingWhilePausedRequest
+ request) async =>
+ await (await _player)
+ .setCanUseNetworkResourcesForLiveStreamingWhilePaused(request);
+
+ Future customSetPreferredPeakBitRate(
+ SetPreferredPeakBitRateRequest request) async =>
+ await (await _player).setPreferredPeakBitRate(request);
+
+ void _updateQueue() {
+ assert(sequence.every((source) => source.tag is MediaItem),
+ 'Error : When using just_audio_background, you should always use a MediaItem as tag when setting an AudioSource. See AudioSource.uri documentation for more information.');
+ queue.add(sequence.map((source) => source.tag as MediaItem).toList());
+ }
+
+ void _updateShuffleIndices() {
+ _shuffleIndices = _source?.shuffleIndices ?? [];
+ _effectiveIndices = _shuffleMode != AudioServiceShuffleMode.none
+ ? _shuffleIndices
+ : List.generate(sequence.length, (i) => i);
+ _shuffleIndicesInv = List.filled(_effectiveIndices.length, 0);
+ for (var i = 0; i < _effectiveIndices.length; i++) {
+ _shuffleIndicesInv[_effectiveIndices[i]] = i;
+ }
+ _effectiveIndicesInv = _shuffleMode != AudioServiceShuffleMode.none
+ ? _shuffleIndicesInv
+ : List.generate(sequence.length, (i) => i);
+ }
+
+ List get sequence => _source?.sequence ?? [];
+ List get shuffleIndices => _shuffleIndices;
+ List get effectiveIndices => _effectiveIndices;
+ List get shuffleIndicesInv => _shuffleIndicesInv;
+ List get effectiveIndicesInv => _effectiveIndicesInv;
+ int get nextIndex => getRelativeIndex(1);
+ int get previousIndex => getRelativeIndex(-1);
+ bool get hasNext => nextIndex != -1;
+ bool get hasPrevious => previousIndex != -1;
+
+ int getRelativeIndex(int offset) {
+ // if (_repeatMode == AudioServiceRepeatMode.one) return index!;
+ if (effectiveIndices.isEmpty) return -1;
+ if (index! >= effectiveIndicesInv.length) return -1;
+ final invPos = effectiveIndicesInv[index!];
+ var newInvPos = invPos + offset;
+ if (newInvPos >= effectiveIndices.length || newInvPos < 0) {
+ if (_repeatMode == AudioServiceRepeatMode.all ||
+ _repeatMode == AudioServiceRepeatMode.one) {
+ newInvPos %= effectiveIndices.length;
+ } else {
+ return -1;
+ }
+ }
+ final result = effectiveIndices[newInvPos];
+ return result;
+ }
+
+ @override
+ Future skipToQueueItem(int index) async {
+ (await _player).seek(SeekRequest(position: Duration.zero, index: index));
+ }
+
+ @override
+ Future skipToNext() async {
+ if (hasNext) {
+ await skipToQueueItem(nextIndex);
+ if (!_playing) {
+ await play();
+ }
+ }
+ }
+
+ @override
+ Future skipToPrevious() async {
+ if (hasPrevious) {
+ await skipToQueueItem(previousIndex);
+ if (!_playing) {
+ await play();
+ }
+ }
+ }
+
+ @override
+ Future play() async {
+ _updatePosition();
+ _playing = true;
+ _broadcastState();
+ await (await _player).play(PlayRequest());
+ }
+
+ @override
+ Future pause() async {
+ _updatePosition();
+ _playing = false;
+ _broadcastState();
+ await (await _player).pause(PauseRequest());
+ }
+
+ void _updatePosition() {
+ _justAudioEvent = _justAudioEvent.copyWith(
+ updatePosition: currentPosition,
+ updateTime: DateTime.now(),
+ );
+ }
+
+ @override
+ Future seek(Duration position) async =>
+ await (await _player).seek(SeekRequest(position: position));
+
+ @override
+ Future setSpeed(double speed) async {
+ _speed = speed;
+ await (await _player).setSpeed(SetSpeedRequest(speed: speed));
+ }
+
+ @override
+ Future fastForward() =>
+ _seekRelative(AudioService.config.fastForwardInterval);
+
+ @override
+ Future rewind() => _seekRelative(-AudioService.config.rewindInterval);
+
+ @override
+ Future seekForward(bool begin) async => _seekContinuously(begin, 1);
+
+ @override
+ Future seekBackward(bool begin) async => _seekContinuously(begin, -1);
+
+ @override
+ Future setRepeatMode(AudioServiceRepeatMode repeatMode) async {
+ _repeatMode = repeatMode;
+ _broadcastStateIfActive();
+ (await _player).setLoopMode(SetLoopModeRequest(
+ loopMode: LoopModeMessage
+ .values[min(LoopModeMessage.values.length - 1, repeatMode.index)]));
+ }
+
+ @override
+ Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
+ _shuffleMode = shuffleMode;
+ _updateShuffleIndices();
+ _broadcastStateIfActive();
+ (await _player).setShuffleMode(SetShuffleModeRequest(
+ shuffleMode: ShuffleModeMessage.values[
+ min(ShuffleModeMessage.values.length - 1, shuffleMode.index)]));
+ }
+
+ @override
+ Future stop() async {
+ if (_justAudioEvent.processingState == ProcessingStateMessage.idle) {
+ return;
+ }
+ _updatePosition();
+ _playing = false;
+ _broadcastState();
+ // TODO: We should really stop listening to events here to mimic
+ // just_audio's behaviour. E.g. if stop() was called, we actually want to
+ // keep the state around even though the platform may be disposing its own
+ // state.
+ _platform.disposePlayer(DisposePlayerRequest(id: (await _player).id));
+ _justAudioEvent = _justAudioEvent.copyWith(
+ processingState: ProcessingStateMessage.idle,
+ );
+ }
+
+ Duration get currentPosition {
+ if (_playing &&
+ _justAudioEvent.processingState == ProcessingStateMessage.ready) {
+ return Duration(
+ milliseconds: (_justAudioEvent.updatePosition.inMilliseconds +
+ ((DateTime.now().millisecondsSinceEpoch -
+ _justAudioEvent.updateTime.millisecondsSinceEpoch) *
+ _speed))
+ .toInt());
+ } else {
+ return _justAudioEvent.updatePosition;
+ }
+ }
+
+ /// Jumps away from the current position by [offset].
+ Future _seekRelative(Duration offset) async {
+ var newPosition = currentPosition + offset;
+ // Make sure we don't jump out of bounds.
+ if (newPosition < Duration.zero) newPosition = Duration.zero;
+ if (newPosition > currentMediaItem!.duration!) {
+ newPosition = currentMediaItem!.duration!;
+ }
+ // Perform the jump via a seek.
+ await (await _player).seek(SeekRequest(position: newPosition));
+ }
+
+ /// Begins or stops a continuous seek in [direction]. After it begins it will
+ /// continue seeking forward or backward by 10 seconds within the audio, at
+ /// intervals of 1 second in app time.
+ void _seekContinuously(bool begin, int direction) {
+ _seeker?.stop();
+ if (begin) {
+ _seeker = _SeamlessSeeker(this, Duration(seconds: 10 * direction),
+ const Duration(seconds: 1), currentMediaItem!.duration!)
+ ..start();
+ }
+ }
+
+ void _broadcastStateIfActive() {
+ if (_justAudioEvent.processingState != ProcessingStateMessage.idle) {
+ _broadcastState();
+ }
+ }
+
+ /// Broadcasts the current state to all clients.
+ void _broadcastState() {
+ final controls = [
+ if (hasPrevious) MediaControl.skipToPrevious,
+ if (_playing) MediaControl.pause else MediaControl.play,
+ // MediaControl.stop,
+ if (hasNext) MediaControl.skipToNext,
+ ];
+ playbackState.add(playbackState.nvalue!.copyWith(
+ controls: controls,
+ systemActions: {
+ MediaAction.seek,
+ MediaAction.seekForward,
+ MediaAction.seekBackward,
+ },
+ androidCompactActionIndices: List.generate(controls.length, (i) => i)
+ .where((i) => controls[i].action != MediaAction.stop)
+ .toList(),
+ processingState: const {
+ ProcessingStateMessage.idle: AudioProcessingState.idle,
+ ProcessingStateMessage.loading: AudioProcessingState.loading,
+ ProcessingStateMessage.buffering: AudioProcessingState.buffering,
+ ProcessingStateMessage.ready: AudioProcessingState.ready,
+ ProcessingStateMessage.completed: AudioProcessingState.completed,
+ }[_justAudioEvent.processingState]!,
+ playing: _playing,
+ updatePosition: currentPosition,
+ bufferedPosition: _justAudioEvent.bufferedPosition,
+ speed: _speed,
+ queueIndex: _justAudioEvent.currentIndex,
+ ));
+ }
+}
+
+class _SeamlessSeeker {
+ final _SeamlessPlayerAudioHandler handler;
+ final Duration positionInterval;
+ final Duration stepInterval;
+ final Duration duration;
+ bool _running = false;
+
+ _SeamlessSeeker(
+ this.handler,
+ this.positionInterval,
+ this.stepInterval,
+ this.duration,
+ );
+
+ Future start() async {
+ _running = true;
+ while (_running) {
+ Duration newPosition = handler.currentPosition + positionInterval;
+ if (newPosition < Duration.zero) newPosition = Duration.zero;
+ if (newPosition > duration) newPosition = duration;
+ handler.seek(newPosition);
+ await Future.delayed(stepInterval);
+ }
+ }
+
+ void stop() {
+ _running = false;
+ }
+}
+
+extension _SeamlessPlaybackEventMessageExtension on PlaybackEventMessage {
+ PlaybackEventMessage copyWith({
+ ProcessingStateMessage? processingState,
+ DateTime? updateTime,
+ Duration? updatePosition,
+ Duration? bufferedPosition,
+ Duration? duration,
+ IcyMetadataMessage? icyMetadata,
+ int? currentIndex,
+ int? androidAudioSessionId,
+ }) =>
+ PlaybackEventMessage(
+ processingState: processingState ?? this.processingState,
+ updateTime: updateTime ?? this.updateTime,
+ updatePosition: updatePosition ?? this.updatePosition,
+ bufferedPosition: bufferedPosition ?? this.bufferedPosition,
+ duration: duration ?? this.duration,
+ icyMetadata: icyMetadata ?? this.icyMetadata,
+ currentIndex: currentIndex ?? this.currentIndex,
+ androidAudioSessionId:
+ androidAudioSessionId ?? this.androidAudioSessionId,
+ );
+}
+
+extension SeamlessAudioSourceExtension on AudioSourceMessage {
+ ConcatenatingAudioSourceMessage? findCat(String id) {
+ final self = this;
+ if (self is ConcatenatingAudioSourceMessage) {
+ if (self.id == id) return self;
+ return self.children
+ .map((child) => child.findCat(id))
+ .firstWhere((cat) => cat != null, orElse: () => null);
+ } else if (self is LoopingAudioSourceMessage) {
+ return self.child.findCat(id);
+ } else {
+ return null;
+ }
+ }
+
+ List get sequence {
+ final self = this;
+ if (self is ConcatenatingAudioSourceMessage) {
+ return self.children.expand((child) => child.sequence).toList();
+ } else if (self is LoopingAudioSourceMessage) {
+ return List.generate(self.count, (i) => self.child.sequence)
+ .expand((sequence) => sequence)
+ .toList();
+ } else {
+ return [self as IndexedAudioSourceMessage];
+ }
+ }
+
+ List get shuffleIndices {
+ final self = this;
+ if (self is ConcatenatingAudioSourceMessage) {
+ var offset = 0;
+ final childIndicesList = >[];
+ for (final child in self.children) {
+ final childIndices =
+ child.shuffleIndices.map((i) => i + offset).toList();
+ childIndicesList.add(childIndices);
+ offset += childIndices.length;
+ }
+ final indices = [];
+ for (final index in self.shuffleOrder) {
+ indices.addAll(childIndicesList[index]);
+ }
+ return indices;
+ } else if (self is LoopingAudioSourceMessage) {
+ // TODO: This should combine indices of the children, like ConcatenatingAudioSource.
+ // Also should be fixed in the plugin frontend.
+ return List.generate(self.count, (i) => i);
+ } else {
+ return [0];
+ }
+ }
+}
+
+@immutable
+class SeamlessTrackInfo {
+ final int? index;
+ final Duration? duration;
+
+ const SeamlessTrackInfo(this.index, this.duration);
+
+ @override
+ bool operator ==(Object other) =>
+ other is SeamlessTrackInfo &&
+ index == other.index &&
+ duration == other.duration;
+
+ @override
+ int get hashCode => Object.hash(index, duration);
+
+ @override
+ String toString() => '($index, $duration)';
+}
+
+/// Backwards compatible extensions on rxdart's ValueStream
+extension _SeamlessValueStreamExtension on ValueStream {
+ /// Backwards compatible version of valueOrNull.
+ T? get nvalue => hasValue ? value : null;
+}
diff --git a/lib/src/service/search_api.dart b/lib/src/service/search_api.dart
new file mode 100644
index 0000000..ff21b42
--- /dev/null
+++ b/lib/src/service/search_api.dart
@@ -0,0 +1,59 @@
+import 'dart:convert';
+
+import '../models.dart';
+import '../utils.dart';
+import 'remote_api.dart';
+
+class SearchApi {
+ const SearchApi({
+ required this.keyword,
+ this.order = 'totalrank',
+ this.typeId = 0,
+ }) : assert(keyword != '');
+
+ final String keyword;
+ final String order;
+ final int typeId;
+
+ Future> getPage(
+ {int pageKey = 1, int pageSize = 15}) async {
+ final response = await RemoteApi.getBaseSrc(
+ RemoteApiPath.search,
+ query: {
+ 'search_type': 'video',
+ 'keyword': keyword,
+ 'order': order,
+ 'tids': typeId.toString(),
+ 'page': pageKey.toString(),
+ 'page_size': pageSize.toString(),
+ },
+ );
+
+ return await Future.wait(
+ (json.decode(response.body)['data']['result'] as List).map(
+ (jsonData) async {
+ if (jsonData['type'] != 'video') {
+ return AudioSummary.invalid();
+ }
+
+ final parts = (jsonData['duration'] as String).split(':');
+ var minutes = int.parse(parts.first);
+ var seconds = int.parse(parts.last);
+ if (seconds > 0) {
+ seconds--;
+ } else if (minutes > 0) {
+ minutes--;
+ seconds = 59;
+ }
+ jsonData['duration'] = '$minutes:$seconds';
+
+ jsonData['pic'] = (await RemoteApi.get(
+ Format.validUri(jsonData['pic']),
+ ))
+ .bodyBytes;
+ return AudioSummary.fromJson(jsonData);
+ },
+ ),
+ );
+ }
+}
diff --git a/lib/src/service/setting_api.dart b/lib/src/service/setting_api.dart
new file mode 100644
index 0000000..4565c6b
--- /dev/null
+++ b/lib/src/service/setting_api.dart
@@ -0,0 +1,102 @@
+import 'package:shared_preferences/shared_preferences.dart';
+
+class SettingApi {
+ static final SettingApi _instance = SettingApi._();
+
+ factory SettingApi() => _instance;
+
+ SettingApi._();
+
+ static Future init() async {
+ _instance._prefsWithCache = await SharedPreferencesWithCache.create(
+ cacheOptions: const SharedPreferencesWithCacheOptions(),
+ );
+ return;
+ }
+
+ late final SharedPreferencesWithCache _prefsWithCache;
+
+ static Future setBool(
+ String key,
+ bool value,
+ ) async =>
+ await _instance._prefsWithCache.setBool(key, value);
+ static Future setDouble(
+ String key,
+ double value,
+ ) async =>
+ await _instance._prefsWithCache.setDouble(key, value);
+ static Future setInt(
+ String key,
+ int value,
+ ) async =>
+ await _instance._prefsWithCache.setInt(key, value);
+ static Future setString(
+ String key,
+ String value,
+ ) async =>
+ await _instance._prefsWithCache.setString(key, value);
+ static Future setStringList(
+ String key,
+ List value,
+ ) async =>
+ await _instance._prefsWithCache.setStringList(key, value);
+
+ static bool? getBool(String key) => _instance._prefsWithCache.getBool(key);
+ static double? getDouble(String key) =>
+ _instance._prefsWithCache.getDouble(key);
+ static int? getInt(String key) => _instance._prefsWithCache.getInt(key);
+ static String? getString(String key) =>
+ _instance._prefsWithCache.getString(key);
+ static List? getStringList(String key) =>
+ _instance._prefsWithCache.getStringList(key);
+
+ static bool containsKey(String key) =>
+ _instance._prefsWithCache.containsKey(key);
+ static Future remove(String key) async =>
+ await _instance._prefsWithCache.remove(key);
+
+ static Future addUp({
+ required int uid,
+ String? tag,
+ String? pattern,
+ }) async {
+ await setStringList(
+ 'uids',
+ (getStringList('uids') ?? [])..add(uid.toString()),
+ );
+ if (tag != null) {
+ await setString('up_tag_$uid', tag);
+ }
+ if (pattern != null) {
+ await setString('up_pattern_$uid', pattern);
+ }
+ }
+
+ static Future removeUp(int uid) async {
+ await setStringList(
+ 'uids',
+ (getStringList('uids') ?? [])..remove(uid.toString()),
+ );
+ if (containsKey('up_tag_$uid')) {
+ await remove('up_tag_$uid');
+ }
+ if (containsKey('up_pattern_$uid')) {
+ await remove('up_pattern_$uid');
+ }
+ }
+
+ static List<
+ ({
+ int uid,
+ String? tag,
+ String? pattern,
+ })> get Ups => [
+ for (var uid in getStringList('uids') ?? [])
+ (
+ uid: int.parse(uid),
+ tag: getString('up_tag_$uid'),
+ pattern: getString('up_pattern_$uid'),
+ ),
+ ];
+}
diff --git a/lib/src/service/user_api.dart b/lib/src/service/user_api.dart
new file mode 100644
index 0000000..518dab4
--- /dev/null
+++ b/lib/src/service/user_api.dart
@@ -0,0 +1,101 @@
+import 'dart:convert';
+
+import '../models.dart';
+import '../utils.dart';
+import 'remote_api.dart';
+
+class UserApi {
+ UserApi({
+ required this.uid,
+ this.tag,
+ this.keywords = '',
+ });
+
+ final int uid;
+ final String? tag;
+ final String keywords;
+ final List verifiers = [
+ (jsonData) => (jsonData['ugc_pay'] as int) == 0,
+ (jsonData) => !(jsonData['title'] as String).startsWith('【直播回放】'),
+ ];
+
+ Future get userSummary async {
+ final response = await RemoteApi.getBaseSrc(
+ RemoteApiPath.userInfo,
+ query: {'uids': uid.toString()},
+ );
+
+ final jsonData = (jsonDecode(response.body)['data'] as List)[0];
+ jsonData['face'] = (await RemoteApi.get(
+ Format.validUri(jsonData['face']),
+ ))
+ .bodyBytes;
+ jsonData['tag'] = tag;
+
+ return UserSummary.fromJson(jsonData);
+ }
+
+ void addVerifier(bool Function(dynamic) verifier) => verifiers.add(verifier);
+
+ Future<({List audioSummaries, bool isLastPage})> getPage(
+ {int pageKey = 1, int pageSize = 15}) async {
+ final response = await RemoteApi.getBaseSrc(
+ RemoteApiPath.userSpace,
+ query: {
+ 'mid': uid.toString(),
+ 'keywords': keywords,
+ 'ps': pageSize.toString(),
+ 'pn': pageKey.toString(),
+ },
+ );
+ final jsonResponseData = json.decode(response.body)['data'];
+ final isLastPage = (jsonResponseData['page']['num'] as int) *
+ (jsonResponseData['page']['size'] as int) >=
+ (jsonResponseData['page']['total'] as int);
+
+ return (
+ audioSummaries: await Future.wait(
+ (jsonResponseData['archives'] as List).map(
+ (jsonData) async {
+ if (verifiers.any(
+ (verifier) => !verifier(jsonData),
+ )) {
+ return AudioSummary.invalid();
+ }
+
+ final viewResponse = await RemoteApi.getBaseSrc(
+ RemoteApiPath.audioInfo,
+ query: {'bvid': jsonData['bvid']},
+ );
+ var viewJson = json.decode(viewResponse.body)['data'];
+ if (viewJson['is_upower_exclusive'] as bool) {
+ return AudioSummary.invalid();
+ }
+
+ viewJson['author'] = viewJson['owner']['name'] as String;
+ viewJson['mid'] = uid;
+ viewJson['play'] = viewJson['stat']['view'] as int;
+
+ var duration = (viewJson['duration'] as int);
+ if (duration > 0) {
+ duration--;
+ }
+ viewJson['duration'] = '${duration ~/ 60}:${(duration % 60)}';
+
+ viewJson['pic'] = (await RemoteApi.get(
+ Format.validUri(viewJson['pic']),
+ ))
+ .bodyBytes;
+
+ viewJson['description'] = viewJson['desc'] as String;
+ viewJson['like'] = viewJson['stat']['like'] as int;
+ viewJson['favorites'] = viewJson['stat']['favorite'] as int;
+
+ return AudioSummary.fromJson(viewJson);
+ },
+ ),
+ ),
+ isLastPage: isLastPage,
+ );
+ }
+}
diff --git a/lib/src/services.dart b/lib/src/services.dart
new file mode 100644
index 0000000..85c6086
--- /dev/null
+++ b/lib/src/services.dart
@@ -0,0 +1,6 @@
+export 'service/remote_api.dart';
+export 'service/search_api.dart';
+export 'service/user_api.dart';
+export 'service/audio_play_api.dart';
+export 'service/setting_api.dart';
+export 'service/seamless_audio_handler.dart';
diff --git a/lib/src/util/display.dart b/lib/src/util/display.dart
new file mode 100644
index 0000000..9e3a7ef
--- /dev/null
+++ b/lib/src/util/display.dart
@@ -0,0 +1,62 @@
+import 'package:html_unescape/html_unescape_small.dart';
+
+abstract class Display {
+ static String formatTitle(String title, {bool simplify = false}) {
+ final unescapedTitle = HtmlUnescape().convert(title);
+ return simplify
+ ? unescapedTitle.replaceAll(RegExp(r'^【.*?】'), '')
+ : unescapedTitle;
+ }
+
+ static String formatPlay(int play) {
+ if (play < 10000) {
+ return play.toString();
+ }
+ if (play < 100000000) {
+ return '${(play / 10000).toStringAsFixed(1)}万';
+ }
+ return '${(play / 100000000).toStringAsFixed(1)}亿';
+ }
+
+ static String formatPubDate(DateTime pubDate) {
+ final now = DateTime.now();
+ final difference = now.difference(pubDate);
+
+ if (difference.inSeconds < 60) {
+ return '刚刚';
+ }
+ if (difference.inMinutes < 60) {
+ return '${difference.inMinutes}分钟前';
+ }
+ if (difference.inHours < 24) {
+ return '${difference.inHours}小时前';
+ }
+ if (difference.inDays == 1) {
+ return '昨天 ${pubDate.hour}:${pubDate.minute.toString().padLeft(2, '0')}';
+ }
+ if (difference.inDays < 4) {
+ return '${difference.inDays}天前';
+ }
+ if (now.year == pubDate.year) {
+ return '${pubDate.month}月${pubDate.day}日';
+ }
+ return '${pubDate.year}年${pubDate.month}月${pubDate.day}日';
+ }
+
+ static String formatDuration(String duration) {
+ if (duration == '') {
+ return duration;
+ }
+
+ final parts = duration.split(':');
+ if (parts.isEmpty) {
+ return duration;
+ }
+ if (int.parse(parts.first) >= 60) {
+ final hours = int.parse(parts.first) ~/ 60;
+ final minutes = int.parse(parts.first) % 60;
+ return '$hours:${minutes.toString().padLeft(2, '0')}:${parts.last.padLeft(2, '0')}';
+ }
+ return '${parts.first}:${parts.last.padLeft(2, '0')}';
+ }
+}
diff --git a/lib/src/util/format.dart b/lib/src/util/format.dart
new file mode 100644
index 0000000..a318c56
--- /dev/null
+++ b/lib/src/util/format.dart
@@ -0,0 +1,10 @@
+abstract class Format {
+ static String validTitle(String title) =>
+ title.replaceAll(RegExp(r'<[^>]*>'), '');
+
+ static Uri validUri(String uri) => Uri.parse(
+ uri.startsWith('//')
+ ? "https:$uri"
+ : (uri.replaceAll('http://', 'https://')),
+ );
+}
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
new file mode 100644
index 0000000..9586488
--- /dev/null
+++ b/lib/src/utils.dart
@@ -0,0 +1,2 @@
+export 'util/display.dart';
+export 'util/format.dart';
diff --git a/linux/.gitignore b/linux/.gitignore
new file mode 100644
index 0000000..d3896c9
--- /dev/null
+++ b/linux/.gitignore
@@ -0,0 +1 @@
+flutter/ephemeral
diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt
new file mode 100644
index 0000000..4630625
--- /dev/null
+++ b/linux/CMakeLists.txt
@@ -0,0 +1,145 @@
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.10)
+project(runner LANGUAGES CXX)
+
+# The name of the executable created for the application. Change this to change
+# the on-disk name of your application.
+set(BINARY_NAME "railgun_minus")
+# The unique GTK application identifier for this application. See:
+# https://wiki.gnome.org/HowDoI/ChooseApplicationID
+set(APPLICATION_ID "com.ezer.railgun_minus")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(SET CMP0063 NEW)
+
+# Load bundled libraries from the lib/ directory relative to the binary.
+set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
+
+# Root filesystem for cross-building.
+if(FLUTTER_TARGET_PLATFORM_SYSROOT)
+ set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+ set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+endif()
+
+# Define build configuration options.
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE "Debug" CACHE
+ STRING "Flutter build mode" FORCE)
+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
+ "Debug" "Profile" "Release")
+endif()
+
+# Compilation settings that should be applied to most targets.
+#
+# Be cautious about adding new options here, as plugins use this function by
+# default. In most cases, you should add new options to specific targets instead
+# of modifying this function.
+function(APPLY_STANDARD_SETTINGS TARGET)
+ target_compile_features(${TARGET} PUBLIC cxx_std_14)
+ target_compile_options(${TARGET} PRIVATE -Wall -Werror)
+ target_compile_options(${TARGET} PRIVATE "$<$>:-O3>")
+ target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+
+add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
+
+# Define the application target. To change its name, change BINARY_NAME above,
+# not the value here, or `flutter run` will no longer work.
+#
+# Any new source files that you add to the application should be added here.
+add_executable(${BINARY_NAME}
+ "main.cc"
+ "my_application.cc"
+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+)
+
+# Apply the standard set of build settings. This can be removed for applications
+# that need different build settings.
+apply_standard_settings(${BINARY_NAME})
+
+# Add dependency libraries. Add any application-specific dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter)
+target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
+
+# Only the install-generated bundle's copy of the executable will launch
+# correctly, since the resources must in the right relative locations. To avoid
+# people trying to run the unbundled copy, put it in a subdirectory instead of
+# the default top-level location.
+set_target_properties(${BINARY_NAME}
+ PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
+)
+
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# By default, "installing" just makes a relocatable bundle in the build
+# directory.
+set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+# Start with a clean build bundle directory every time.
+install(CODE "
+ file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
+ " COMPONENT Runtime)
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
+
+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
+ install(FILES "${bundled_library}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endforeach(bundled_library)
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+# Fully re-copy the assets directory on each build to avoid having stale files
+# from a previous install.
+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+install(CODE "
+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
+ " COMPONENT Runtime)
+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
+
+# Install the AOT library on non-Debug builds only.
+if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
+ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt
new file mode 100644
index 0000000..d5bd016
--- /dev/null
+++ b/linux/flutter/CMakeLists.txt
@@ -0,0 +1,88 @@
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.10)
+
+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
+
+# Configuration provided via flutter tool.
+include(${EPHEMERAL_DIR}/generated_config.cmake)
+
+# TODO: Move the rest of this into files in ephemeral. See
+# https://github.com/flutter/flutter/issues/57146.
+
+# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
+# which isn't available in 3.10.
+function(list_prepend LIST_NAME PREFIX)
+ set(NEW_LIST "")
+ foreach(element ${${LIST_NAME}})
+ list(APPEND NEW_LIST "${PREFIX}${element}")
+ endforeach(element)
+ set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
+endfunction()
+
+# === Flutter Library ===
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
+pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
+
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
+
+# Published to parent scope for install step.
+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
+set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+ "fl_basic_message_channel.h"
+ "fl_binary_codec.h"
+ "fl_binary_messenger.h"
+ "fl_dart_project.h"
+ "fl_engine.h"
+ "fl_json_message_codec.h"
+ "fl_json_method_codec.h"
+ "fl_message_codec.h"
+ "fl_method_call.h"
+ "fl_method_channel.h"
+ "fl_method_codec.h"
+ "fl_method_response.h"
+ "fl_plugin_registrar.h"
+ "fl_plugin_registry.h"
+ "fl_standard_message_codec.h"
+ "fl_standard_method_codec.h"
+ "fl_string_codec.h"
+ "fl_value.h"
+ "fl_view.h"
+ "flutter_linux.h"
+)
+list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+ "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
+target_link_libraries(flutter INTERFACE
+ PkgConfig::GTK
+ PkgConfig::GLIB
+ PkgConfig::GIO
+)
+add_dependencies(flutter flutter_assemble)
+
+# === Flutter tool backend ===
+# _phony_ is a non-existent file to force this command to run every time,
+# since currently there's no way to get a full input/output list from the
+# flutter tool.
+add_custom_command(
+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+ ${CMAKE_CURRENT_BINARY_DIR}/_phony_
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${FLUTTER_TOOL_ENVIRONMENT}
+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
+ ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
+ VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+ "${FLUTTER_LIBRARY}"
+ ${FLUTTER_LIBRARY_HEADERS}
+)
diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc
new file mode 100644
index 0000000..e71a16d
--- /dev/null
+++ b/linux/flutter/generated_plugin_registrant.cc
@@ -0,0 +1,11 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#include "generated_plugin_registrant.h"
+
+
+void fl_register_plugins(FlPluginRegistry* registry) {
+}
diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h
new file mode 100644
index 0000000..e0f0a47
--- /dev/null
+++ b/linux/flutter/generated_plugin_registrant.h
@@ -0,0 +1,15 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GENERATED_PLUGIN_REGISTRANT_
+#define GENERATED_PLUGIN_REGISTRANT_
+
+#include
+
+// Registers Flutter plugins.
+void fl_register_plugins(FlPluginRegistry* registry);
+
+#endif // GENERATED_PLUGIN_REGISTRANT_
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
new file mode 100644
index 0000000..be1ee3e
--- /dev/null
+++ b/linux/flutter/generated_plugins.cmake
@@ -0,0 +1,24 @@
+#
+# Generated file, do not edit.
+#
+
+list(APPEND FLUTTER_PLUGIN_LIST
+)
+
+list(APPEND FLUTTER_FFI_PLUGIN_LIST
+ jni
+)
+
+set(PLUGIN_BUNDLED_LIBRARIES)
+
+foreach(plugin ${FLUTTER_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
+endforeach(plugin)
+
+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
+endforeach(ffi_plugin)
diff --git a/linux/main.cc b/linux/main.cc
new file mode 100644
index 0000000..e7c5c54
--- /dev/null
+++ b/linux/main.cc
@@ -0,0 +1,6 @@
+#include "my_application.h"
+
+int main(int argc, char** argv) {
+ g_autoptr(MyApplication) app = my_application_new();
+ return g_application_run(G_APPLICATION(app), argc, argv);
+}
diff --git a/linux/my_application.cc b/linux/my_application.cc
new file mode 100644
index 0000000..b94d890
--- /dev/null
+++ b/linux/my_application.cc
@@ -0,0 +1,124 @@
+#include "my_application.h"
+
+#include
+#ifdef GDK_WINDOWING_X11
+#include
+#endif
+
+#include "flutter/generated_plugin_registrant.h"
+
+struct _MyApplication {
+ GtkApplication parent_instance;
+ char** dart_entrypoint_arguments;
+};
+
+G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
+
+// Implements GApplication::activate.
+static void my_application_activate(GApplication* application) {
+ MyApplication* self = MY_APPLICATION(application);
+ GtkWindow* window =
+ GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
+
+ // Use a header bar when running in GNOME as this is the common style used
+ // by applications and is the setup most users will be using (e.g. Ubuntu
+ // desktop).
+ // If running on X and not using GNOME then just use a traditional title bar
+ // in case the window manager does more exotic layout, e.g. tiling.
+ // If running on Wayland assume the header bar will work (may need changing
+ // if future cases occur).
+ gboolean use_header_bar = TRUE;
+#ifdef GDK_WINDOWING_X11
+ GdkScreen* screen = gtk_window_get_screen(window);
+ if (GDK_IS_X11_SCREEN(screen)) {
+ const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
+ if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
+ use_header_bar = FALSE;
+ }
+ }
+#endif
+ if (use_header_bar) {
+ GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
+ gtk_widget_show(GTK_WIDGET(header_bar));
+ gtk_header_bar_set_title(header_bar, "railgun_minus");
+ gtk_header_bar_set_show_close_button(header_bar, TRUE);
+ gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
+ } else {
+ gtk_window_set_title(window, "railgun_minus");
+ }
+
+ gtk_window_set_default_size(window, 1280, 720);
+ gtk_widget_show(GTK_WIDGET(window));
+
+ g_autoptr(FlDartProject) project = fl_dart_project_new();
+ fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
+
+ FlView* view = fl_view_new(project);
+ gtk_widget_show(GTK_WIDGET(view));
+ gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
+
+ fl_register_plugins(FL_PLUGIN_REGISTRY(view));
+
+ gtk_widget_grab_focus(GTK_WIDGET(view));
+}
+
+// Implements GApplication::local_command_line.
+static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
+ MyApplication* self = MY_APPLICATION(application);
+ // Strip out the first argument as it is the binary name.
+ self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
+
+ g_autoptr(GError) error = nullptr;
+ if (!g_application_register(application, nullptr, &error)) {
+ g_warning("Failed to register: %s", error->message);
+ *exit_status = 1;
+ return TRUE;
+ }
+
+ g_application_activate(application);
+ *exit_status = 0;
+
+ return TRUE;
+}
+
+// Implements GApplication::startup.
+static void my_application_startup(GApplication* application) {
+ //MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application startup.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
+}
+
+// Implements GApplication::shutdown.
+static void my_application_shutdown(GApplication* application) {
+ //MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application shutdown.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
+}
+
+// Implements GObject::dispose.
+static void my_application_dispose(GObject* object) {
+ MyApplication* self = MY_APPLICATION(object);
+ g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
+ G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
+}
+
+static void my_application_class_init(MyApplicationClass* klass) {
+ G_APPLICATION_CLASS(klass)->activate = my_application_activate;
+ G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
+ G_APPLICATION_CLASS(klass)->startup = my_application_startup;
+ G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
+ G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
+}
+
+static void my_application_init(MyApplication* self) {}
+
+MyApplication* my_application_new() {
+ return MY_APPLICATION(g_object_new(my_application_get_type(),
+ "application-id", APPLICATION_ID,
+ "flags", G_APPLICATION_NON_UNIQUE,
+ nullptr));
+}
diff --git a/linux/my_application.h b/linux/my_application.h
new file mode 100644
index 0000000..72271d5
--- /dev/null
+++ b/linux/my_application.h
@@ -0,0 +1,18 @@
+#ifndef FLUTTER_MY_APPLICATION_H_
+#define FLUTTER_MY_APPLICATION_H_
+
+#include
+
+G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
+ GtkApplication)
+
+/**
+ * my_application_new:
+ *
+ * Creates a new Flutter-based application.
+ *
+ * Returns: a new #MyApplication.
+ */
+MyApplication* my_application_new();
+
+#endif // FLUTTER_MY_APPLICATION_H_
diff --git a/macos/.gitignore b/macos/.gitignore
new file mode 100644
index 0000000..746adbb
--- /dev/null
+++ b/macos/.gitignore
@@ -0,0 +1,7 @@
+# Flutter-related
+**/Flutter/ephemeral/
+**/Pods/
+
+# Xcode-related
+**/dgph
+**/xcuserdata/
diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig
new file mode 100644
index 0000000..c2efd0b
--- /dev/null
+++ b/macos/Flutter/Flutter-Debug.xcconfig
@@ -0,0 +1 @@
+#include "ephemeral/Flutter-Generated.xcconfig"
diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig
new file mode 100644
index 0000000..c2efd0b
--- /dev/null
+++ b/macos/Flutter/Flutter-Release.xcconfig
@@ -0,0 +1 @@
+#include "ephemeral/Flutter-Generated.xcconfig"
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
new file mode 100644
index 0000000..c7529dd
--- /dev/null
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -0,0 +1,22 @@
+//
+// Generated file. Do not edit.
+//
+
+import FlutterMacOS
+import Foundation
+
+import audio_service
+import audio_session
+import just_audio
+import path_provider_foundation
+import shared_preferences_foundation
+import sqflite
+
+func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+ AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
+ AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
+ JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
+ PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
+ SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+ SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
+}
diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..602405f
--- /dev/null
+++ b/macos/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,705 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXAggregateTarget section */
+ 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
+ isa = PBXAggregateTarget;
+ buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
+ buildPhases = (
+ 33CC111E2044C6BF0003C045 /* ShellScript */,
+ );
+ dependencies = (
+ );
+ name = "Flutter Assemble";
+ productName = FLX;
+ };
+/* End PBXAggregateTarget section */
+
+/* Begin PBXBuildFile section */
+ 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
+ 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
+ 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
+ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
+ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
+ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 33CC10EC2044A3C60003C045;
+ remoteInfo = Runner;
+ };
+ 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 33CC111A2044C6BA0003C045;
+ remoteInfo = FLX;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 33CC110E2044A8840003C045 /* Bundle Framework */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Bundle Framework";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; };
+ 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; };
+ 33CC10ED2044A3C60003C045 /* railgun_minus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "railgun_minus.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; };
+ 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; };
+ 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; };
+ 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; };
+ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; };
+ 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; };
+ 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; };
+ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "