chore: rebuild dev history without streaming-era commits

This commit is contained in:
zarzet
2026-02-27 13:17:32 +07:00
parent c89600591c
commit ab26d84632
111 changed files with 18282 additions and 3459 deletions
+20
View File
@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -21,6 +22,7 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locale_config">
@@ -92,6 +94,24 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Audio playback service for media notification / background audio -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- flutter_local_notifications receivers -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
@@ -4,20 +4,25 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import com.ryanheise.audioservice.AudioServiceFragmentActivity
import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode
import io.flutter.embedding.android.FlutterFragment
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.android.RenderMode
import io.flutter.embedding.android.TransparencyMode
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import gobackend.Gobackend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
@@ -27,13 +32,24 @@ import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
class MainActivity: AudioServiceFragmentActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend"
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/download_progress_stream"
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/library_scan_progress_stream"
private val STREAM_POLLING_INTERVAL_MS = 800L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
private val safDirLock = Any()
private var safScanProgress = SafScanProgress()
private var downloadProgressStreamJob: Job? = null
private var downloadProgressEventSink: EventChannel.EventSink? = null
private var lastDownloadProgressPayload: String? = null
private var libraryScanProgressStreamJob: Job? = null
private var libraryScanProgressEventSink: EventChannel.EventSink? = null
private var lastLibraryScanProgressPayload: String? = null
@Volatile private var safScanCancel = false
@Volatile private var safScanActive = false
private val safTreeLauncher = registerForActivityResult(
@@ -380,6 +396,78 @@ class MainActivity: FlutterFragmentActivity() {
return obj.toString()
}
private fun readLibraryScanProgressJsonForStream(): String {
return if (safScanActive) {
safProgressToJson()
} else {
Gobackend.getLibraryScanProgressJSON()
}
}
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
stopDownloadProgressStream()
downloadProgressEventSink = sink
lastDownloadProgressPayload = null
downloadProgressStreamJob = scope.launch {
while (isActive && downloadProgressEventSink === sink) {
try {
val payload = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgress()
}
if (payload != lastDownloadProgressPayload) {
lastDownloadProgressPayload = payload
sink.success(payload)
}
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Download progress stream poll failed: ${e.message}",
)
}
delay(STREAM_POLLING_INTERVAL_MS)
}
}
}
private fun stopDownloadProgressStream() {
downloadProgressStreamJob?.cancel()
downloadProgressStreamJob = null
downloadProgressEventSink = null
lastDownloadProgressPayload = null
}
private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) {
stopLibraryScanProgressStream()
libraryScanProgressEventSink = sink
lastLibraryScanProgressPayload = null
libraryScanProgressStreamJob = scope.launch {
while (isActive && libraryScanProgressEventSink === sink) {
try {
val payload = withContext(Dispatchers.IO) {
readLibraryScanProgressJsonForStream()
}
if (payload != lastLibraryScanProgressPayload) {
lastLibraryScanProgressPayload = payload
sink.success(payload)
}
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Library scan progress stream poll failed: ${e.message}",
)
}
delay(STREAM_POLLING_INTERVAL_MS)
}
}
}
private fun stopLibraryScanProgressStream() {
libraryScanProgressStreamJob?.cancel()
libraryScanProgressStreamJob = null
libraryScanProgressEventSink = null
lastLibraryScanProgressPayload = null
}
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
val obj = JSONObject()
if (treeUriStr.isBlank() || fileName.isBlank()) {
@@ -1252,16 +1340,79 @@ class MainActivity: FlutterFragmentActivity() {
return respObj.toString()
}
// Disable Flutter's built-in deep linking so that incoming ACTION_VIEW URLs
// (Spotify, Deezer, Tidal, YouTube Music) are NOT forwarded to GoRouter.
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
override fun shouldHandleDeeplinking(): Boolean = false
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data
setIntent(intent)
}
override fun onDestroy() {
try {
Gobackend.cleanupExtensions()
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to cleanup extensions on destroy: ${e.message}")
}
stopDownloadProgressStream()
stopLibraryScanProgressStream()
super.onDestroy()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
// Always-enabled back callback to ensure back presses reach Flutter.
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
// which disables Flutter's own OnBackPressedCallback and causes the
// system default (finish activity) to run. This callback guarantees
// popRoute is always forwarded to Flutter, where PopScope handles it.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
flutterEngine.navigationChannel.popRoute()
}
})
val messenger = flutterEngine.dartExecutor.binaryMessenger
EventChannel(
messenger,
DOWNLOAD_PROGRESS_STREAM_CHANNEL,
).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
if (events != null) {
startDownloadProgressStream(events)
}
}
override fun onCancel(arguments: Any?) {
stopDownloadProgressStream()
}
},
)
EventChannel(
messenger,
LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL,
).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
if (events != null) {
startLibraryScanProgressStream(events)
}
}
override fun onCancel(arguments: Any?) {
stopLibraryScanProgressStream()
}
},
)
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
scope.launch {
try {
when (call.method) {
@@ -1296,6 +1447,14 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getSpotifyRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong())
}
result.success(response)
}
"checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -1973,6 +2132,14 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getDeezerRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerRelatedArtists(artistId, limit.toLong())
}
result.success(response)
}
"getDeezerMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16.5,3C14.76,3 13.09,3.81 12,5.09C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.42 2,8.5C2,12.28 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.28 22,8.5C22,5.42 19.58,3 16.5,3ZM12.1,18.55L12,18.65L11.9,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,5.99 11.07,7.36H12.94C13.46,5.99 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55Z"/>
</vector>
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_stat_favorite,@drawable/ic_stat_favorite_border" />
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<!-- Allow local loopback cleartext for FFmpeg live decrypt tunnel only. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>