diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/RemoteSafPlaybackTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/RemoteSafPlaybackTest.kt new file mode 100644 index 00000000..352dd5fc --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/RemoteSafPlaybackTest.kt @@ -0,0 +1,121 @@ +package com.zarz.spotiflac + +import android.graphics.Bitmap +import android.media.MediaPlayer +import android.os.Handler +import android.os.HandlerThread +import android.os.ParcelFileDescriptor +import android.os.ProxyFileDescriptorCallback +import android.os.storage.StorageManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.spotiflac.backend.readFileMetadata +import com.spotiflac.backend.readLibraryMetadataFromDescriptor +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import xyz.luan.audioplayers.source.UrlSource + +/** AppFuse has the same single-open restriction as rclone's SAF provider. */ +@RunWith(AndroidJUnit4::class) +class RemoteSafPlaybackTest { + private fun withProxy(bytes: ByteArray, size: Long = bytes.size.toLong(), + block: (ParcelFileDescriptor, AtomicLong) -> T): T { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val thread = HandlerThread("remote-document-fixture").apply { start() } + val reads = AtomicLong() + val released = CountDownLatch(1) + val storage = context.getSystemService(StorageManager::class.java) + try { + return storage.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, + object : ProxyFileDescriptorCallback() { + override fun onGetSize() = size + override fun onRelease() { released.countDown() } + override fun onRead(offset: Long, length: Int, data: ByteArray): Int { + val count = minOf(length.toLong(), (size - offset).coerceAtLeast(0)).toInt() + data.fill(0, 0, count) + if (offset < bytes.size) { + val available = minOf(count, bytes.size - offset.toInt()) + bytes.copyInto(data, 0, offset.toInt(), offset.toInt() + available) + } + reads.addAndGet(count.toLong()) + return count + } + }, Handler(thread.looper)).use { block(it, reads) } + } finally { released.await(2, TimeUnit.SECONDS); thread.quitSafely() } + } + + @Test fun proxyPlaybackUsesDescriptorWithoutReopeningOrCopying() { + val dataSize = 44100 * 2 + val wav = ByteBuffer.allocate(44 + dataSize).order(ByteOrder.LITTLE_ENDIAN).apply { + put("RIFF".toByteArray()); putInt(36 + dataSize); put("WAVEfmt ".toByteArray()) + putInt(16); putShort(1); putShort(1); putInt(44100); putInt(88200) + putShort(2); putShort(16); put("data".toByteArray()); putInt(dataSize) + }.array() + withProxy(wav) { fd, _ -> + val path = "/proc/self/fd/${fd.fd}" + // Reproduce the old path before exercising the patched plugin. + assertTrue(runCatching { FileInputStream(path).use { it.read() } }.isFailure) + val prepared = CountDownLatch(1) + val failed = AtomicLong() + lateinit var player: MediaPlayer + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + player = MediaPlayer().apply { + setOnPreparedListener { prepared.countDown() } + setOnErrorListener { _, _, _ -> failed.incrementAndGet(); prepared.countDown(); true } + UrlSource(path, true).setForMediaPlayer(this) + prepareAsync() + } + } + try { + assertTrue("Proxy must prepare without a full file copy", prepared.await(10, TimeUnit.SECONDS)) + assertEquals(0L, failed.get()) + instrumentation.runOnMainSync { assertTrue(player.duration > 0) } + } finally { instrumentation.runOnMainSync { player.release() } } + } + } + + @Test fun proxyMetadataAndCoverReadOnlyTagsAndKeepOriginalLeaseOpen() { + val image = ByteArrayOutputStream().apply { + val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, this) + bitmap.recycle() + }.toByteArray() + val picture = ByteArrayOutputStream().apply { + DataOutputStream(this).apply { + writeInt(3); writeInt(9); write("image/png".toByteArray()); writeInt(0) + writeInt(2); writeInt(2); writeInt(32); writeInt(0) + writeInt(image.size); write(image) + } + }.toByteArray() + val streamInfo = ByteBuffer.allocate(34).apply { + putShort(4096); putShort(4096); position(10) + putLong((44100L shl 44) or (1L shl 41) or (15L shl 36) or 44100L) + }.array() + val header = ByteArrayOutputStream().apply { + write("fLaC".toByteArray()); write(byteArrayOf(0, 0, 0, 34)); write(streamInfo) + write(0x86); write(picture.size shr 16); write(picture.size shr 8); write(picture.size) + write(picture) + write(0xff); write(0xf8) // First audio frame's FLAC sync marker. + }.toByteArray() + withProxy(header, 64L * 1024 * 1024) { fd, reads -> + val result = readLibraryMetadataFromDescriptor(fd.fd, "Remote.flac", "", true, null) + assertTrue(result.coverBytes.isNotEmpty()) + assertEquals(44100, JSONObject(result.metadataJson).getInt("sampleRate")) + val complete = JSONObject(readFileMetadata("/proc/self/fd/${fd.fd}", "Remote.flac", null)) + assertEquals(16, complete.getInt("bit_depth")) + assertTrue("Metadata must not transfer 64 MiB of audio", reads.get() < 1024 * 1024) + assertTrue(fd.fileDescriptor.valid()) + } + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt index 1be1b2b2..8bfe3f7f 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt @@ -211,7 +211,7 @@ internal interface CoreBackend { fun editFileMetadata(path: String, metadataJson: String): String fun reEnrichFile(requestJson: String): String fun rewriteSplitArtistTags(path: String, artist: String, albumArtist: String): String - fun extractCoverToFile(audioPath: String, outputPath: String) + fun extractCoverToFile(audioPath: String, outputPath: String, hint: String = "") fun writeM4aFreeformTags(path: String, metadataJson: String): String fun ensureAc4Config(path: String, reference: String): String fun writeAc4Metadata(path: String, metadataJson: String, coverPath: String): String diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 0b6742d2..30a44daa 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -887,9 +887,9 @@ class MainActivity: FlutterFragmentActivity() { /** * Opens a short-lived descriptor lease for zero-copy SAF playback. The - * returned proc path has no URI scheme, so MediaPlayer opens it in this - * process and duplicates the descriptor before the Dart side closes the - * lease. A small hard cap protects against abandoned method calls. + * returned proc path identifies the lease. The Android audio plugin must + * duplicate this descriptor, never reopen it (AppFuse rejects reopening). + * A small hard cap protects against abandoned method calls. */ private fun openSafPlaybackLease(uriStr: String): Map? { if (!uriStr.startsWith("content://")) return null @@ -900,6 +900,10 @@ class MainActivity: FlutterFragmentActivity() { null } ?: return null + if (!isSeekableSafDescriptor(descriptor)) { + descriptor.close() + return null + } val token = UUID.randomUUID().toString() synchronized(playbackLeaseLock) { while (playbackLeases.size >= 4) { @@ -1732,15 +1736,8 @@ class MainActivity: FlutterFragmentActivity() { val response = withContext(Dispatchers.IO) { try { if (audioPath.startsWith("content://")) { - val uri = Uri.parse(audioPath) - val tempPath = copyUriToTemp(uri) - ?: return@withContext """{"success":false,"error":"Failed to copy SAF file to temp"}""" - try { - coreBackend.extractCoverToFile(tempPath, outputPath) - """{"success":true}""" - } finally { - try { File(tempPath).delete() } catch (_: Exception) {} - } + extractCoverFromUri(Uri.parse(audioPath), outputPath) + """{"success":true}""" } else { coreBackend.extractCoverToFile(audioPath, outputPath) """{"success":true}""" diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index ec73ff4c..3a2d6f70 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -252,7 +252,7 @@ internal fun MainActivity.buildLibraryCoverCacheKey(stablePath: String, lastModi return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath } -private fun isSeekableSafDescriptor(descriptor: ParcelFileDescriptor): Boolean { +internal fun isSeekableSafDescriptor(descriptor: ParcelFileDescriptor): Boolean { return try { val position = Os.lseek( descriptor.fileDescriptor, @@ -340,6 +340,23 @@ internal fun MainActivity.readCompleteMetadataFromUri( JSONObject(coreBackend.readFileMetadata(path, name)).takeUnless { it.has("error") } } +/** Seekable remote documents need only tag reads, not a full audio download. */ +internal fun MainActivity.extractCoverFromUri(uri: Uri, outputPath: String) { + val name = buildUriDisplayName(uri) + contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + if (isSeekableSafDescriptor(descriptor)) { + coreBackend.extractCoverToFile("/proc/self/fd/${descriptor.fd}", outputPath, name) + return + } + } + // Providers exposing a pipe cannot support random-access tag readers. + val tempPath = copyUriToTemp(uri, displayName = name) + ?: error("Failed to read document artwork") + try { + coreBackend.extractCoverToFile(tempPath, outputPath) + } finally { File(tempPath).delete() } +} + internal fun MainActivity.writeUriFromPath(uri: Uri, srcPath: String): Boolean { val srcFile = File(srcPath) if (!srcFile.exists()) return false diff --git a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt index 64e832fe..abde9207 100644 --- a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt @@ -386,7 +386,23 @@ internal object RustCoreBackend : CoreBackend { override fun rewriteSplitArtistTags(path: String, artist: String, albumArtist: String): String = withMediaFiles(listOf(path)) { it.rewriteSplitArtistTags(mediaPath(path), artist, albumArtist, null) } - override fun extractCoverToFile(audioPath: String, outputPath: String) { + override fun extractCoverToFile(audioPath: String, outputPath: String, hint: String) { + val descriptor = if (audioPath.startsWith("/proc/self/fd/")) { + audioPath.removePrefix("/proc/self/fd/").toIntOrNull() + } else null + if (descriptor != null) { + val result = com.spotiflac.backend.readLibraryMetadataFromDescriptor( + descriptor, hint, java.time.Instant.now().toString(), true, null, + ) + check(result.coverBytes.isNotEmpty()) { "No embedded artwork" } + val output = File(outputPath) + val staged = File.createTempFile("cover_", ".tmp", output.parentFile) + try { + staged.writeBytes(result.coverBytes) + check(staged.renameTo(output)) { "Could not publish artwork" } + } finally { staged.delete() } + return + } withMediaFiles(listOf(audioPath, outputPath)) { it.extractCoverToFile(mediaPath(audioPath), mediaPath(outputPath), null) } diff --git a/pubspec.lock b/pubspec.lock index 636d0269..c2d633e3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,12 +113,11 @@ packages: source: hosted version: "6.8.1" audioplayers_android: - dependency: transitive + dependency: "direct overridden" description: - name: audioplayers_android - sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 - url: "https://pub.dev" - source: hosted + path: "third_party/audioplayers_android" + relative: true + source: path version: "5.3.0" audioplayers_darwin: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index e00f99f7..33bbafa9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,11 @@ dependencies: path: third_party/audio_service audio_session: ^0.2.4 +dependency_overrides: + # Preserve Android proxy descriptors supplied by network document providers. + audioplayers_android: + path: third_party/audioplayers_android + dev_dependencies: flutter_test: sdk: flutter diff --git a/rust_backend/crates/mobile/src/library_metadata.rs b/rust_backend/crates/mobile/src/library_metadata.rs index 336c48ca..990c9379 100644 --- a/rust_backend/crates/mobile/src/library_metadata.rs +++ b/rust_backend/crates/mobile/src/library_metadata.rs @@ -14,8 +14,8 @@ pub struct DescriptorLibraryMetadata { } /// The caller keeps the descriptor open until this synchronous read returns. -/// Reopening its descriptor path preserves SAF access instead of resolving a -/// protected /storage or /mnt/media_rw path and copying the entire audio file. +/// Duplicating its descriptor preserves SAF access, including single-open +/// AppFuse proxies, without resolving a protected path or copying the audio. #[uniffi::export] pub fn read_library_metadata_from_descriptor( descriptor: i32, diff --git a/rust_backend/crates/mobile/src/tags.rs b/rust_backend/crates/mobile/src/tags.rs index d72a9f1a..cc085e6d 100644 --- a/rust_backend/crates/mobile/src/tags.rs +++ b/rust_backend/crates/mobile/src/tags.rs @@ -301,6 +301,29 @@ pub(crate) fn check_lease(lease: Option<&RequestLease>) -> Result<(), String> { } pub(crate) fn open_audio_file(path: &str) -> Result { + #[cfg(unix)] + if let Some(descriptor) = path + .strip_prefix("/proc/self/fd/") + .or_else(|| path.strip_prefix("/dev/fd/")) + { + use std::os::fd::BorrowedFd; + let descriptor = descriptor.parse::().map_err(|e| e.to_string())?; + if descriptor < 0 { + return Err("invalid audio descriptor".into()); + } + // The native caller retains this lease for the synchronous operation. + // Reopening /proc/self/fd issues FUSE_OPEN, which Android AppFuse + // explicitly rejects for an already-open proxy. Duplicate instead. + #[allow(unsafe_code)] // Native owner retains the descriptor during this call. + let owned = unsafe { BorrowedFd::borrow_raw(descriptor) } + .try_clone_to_owned() + .map_err(|e| e.to_string())?; + let file = File::from(owned); + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("audio tags require a regular file".into()); + } + return Ok(file); + } let mut options = OpenOptions::new(); options.read(true); #[cfg(unix)] diff --git a/third_party/audioplayers_android/CHANGELOG.md b/third_party/audioplayers_android/CHANGELOG.md new file mode 100644 index 00000000..33d015a2 --- /dev/null +++ b/third_party/audioplayers_android/CHANGELOG.md @@ -0,0 +1,426 @@ +## 5.3.0 + + - **FEAT**(android): Compatibility with Built-In Kotlin ([#1991](https://github.com/bluefireteam/audioplayers/issues/1991)). ([687218b9](https://github.com/bluefireteam/audioplayers/commit/687218b96682a7bcadd9490572db692dbc9a2f07)) + +## 5.2.1 + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +## 5.2.0 + + - **FIX**: Make FocusManager compatible with Android API <= 25 (closes [#1895](https://github.com/bluefireteam/audioplayers/issues/1895)) ([#1904](https://github.com/bluefireteam/audioplayers/issues/1904)). ([41238d48](https://github.com/bluefireteam/audioplayers/commit/41238d4837fb5c59b8aaf2e7e8087268a160ebe7)) + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +## 5.1.0 + + - **FEAT**(android): ExoPlayer for Android ([#1691](https://github.com/bluefireteam/audioplayers/issues/1691)). ([a91c5b18](https://github.com/bluefireteam/audioplayers/commit/a91c5b185054986a2390d41593b5ee502ef96bdd)) + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +## 5.0.3 + + - **FIX**: Raise Android SDK versions ([#1885](https://github.com/bluefireteam/audioplayers/issues/1885)). ([7230bc84](https://github.com/bluefireteam/audioplayers/commit/7230bc84d9dfb0cccfbe5bacb971ef3698495176)) + +## 5.0.2 + + - **FIX**: Change audioFocus dynamically ([#1877](https://github.com/bluefireteam/audioplayers/issues/1877)). ([14f16d9d](https://github.com/bluefireteam/audioplayers/commit/14f16d9dfc52e2eca989e0cc6a27decb8e96af83)) + +## 5.0.1 + + - **FIX**: Avoid multiple audioFocusRequest instances for focus changes ([#1869](https://github.com/bluefireteam/audioplayers/issues/1869)). ([040dde9c](https://github.com/bluefireteam/audioplayers/commit/040dde9c2b1d4601a4c4790fa4a43a4cdd4e9a27)) + - **FIX**(android): Avoid playing after gaining focus in paused state ([#1857](https://github.com/bluefireteam/audioplayers/issues/1857)). ([01726c13](https://github.com/bluefireteam/audioplayers/commit/01726c1362135a4c3595169dcb1adb311f25f683)) + +## 5.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +## 4.0.3 + + - **FIX**(android): Released wrong source in LOW_LATENCY mode ([#1672](https://github.com/bluefireteam/audioplayers/issues/1672)). ([d9c5f693](https://github.com/bluefireteam/audioplayers/commit/d9c5f693cafab21b67b785de6244c3c371344a53)) + +## 4.0.2 + + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Set playback rate only when playing ([#1658](https://github.com/bluefireteam/audioplayers/issues/1658)). ([d73c7d5c](https://github.com/bluefireteam/audioplayers/commit/d73c7d5c2ef13e8eff2c438b96ade6e2483a2014)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + +## 4.0.1 + + - **REVERT**(android): Upgrade androidx.core:core-ktx, restore support for AGP7 ([#1590](https://github.com/bluefireteam/audioplayers/issues/1590)). ([f6bf1260](https://github.com/bluefireteam/audioplayers/commit/f6bf12609ec9e457451f1c786522bff28a1555f4)) + +## 4.0.0 + +> Note: This release has breaking changes. + + - **FIX**(android): Allow AudioFocus.none ([#1534](https://github.com/bluefireteam/audioplayers/issues/1534)). ([858d3f44](https://github.com/bluefireteam/audioplayers/commit/858d3f4410b1bc7b203090c20cf60b5136dad4fe)) + - **FEAT**(android): Add support for AGP 8 in example, add compileOptions to build.gradle ([#1503](https://github.com/bluefireteam/audioplayers/issues/1503)). ([7c08e4e1](https://github.com/bluefireteam/audioplayers/commit/7c08e4e1a524f53294f6967996fd31837e62cb81)) + - **BREAKING** **FIX**: Default audio output to system preferences ([#1563](https://github.com/bluefireteam/audioplayers/issues/1563)). ([381c43e3](https://github.com/bluefireteam/audioplayers/commit/381c43e3725fbb0cb4fd35982893a3c92b188886)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +## 3.0.2 + + - **FIX**(android): `onComplete` is not called when audio has completed playing ([#1523](https://github.com/bluefireteam/audioplayers/issues/1523)). ([293d6c0e](https://github.com/bluefireteam/audioplayers/commit/293d6c0eec1d89ad200b2914cae0adf644b25013)) + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**(android): Add AGP 8 support with namespace property ([#1514](https://github.com/bluefireteam/audioplayers/issues/1514)). ([8d7b322e](https://github.com/bluefireteam/audioplayers/commit/8d7b322e79fd802fb75ca72f5c8ac388754cd406)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + +## 3.0.1 + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +## 3.0.0 + +> Note: This release has breaking changes. + + - **FIX**(android): Avoid calling onDuration on position event (closes [#136](https://github.com/bluefireteam/audioplayers/issues/136)) ([#1460](https://github.com/bluefireteam/audioplayers/issues/1460)). ([6cfb3753](https://github.com/bluefireteam/audioplayers/commit/6cfb3753cd8003f341d97e0b2417d4512f452267)) + - **FIX**(android): reset prepared state on player error ([#1425](https://github.com/bluefireteam/audioplayers/issues/1425)). ([6f24c8f5](https://github.com/bluefireteam/audioplayers/commit/6f24c8f57e4549edbf7d68a021d1d94371c23f3f)) + - **FEAT**(android): add `setBalance` ([#58](https://github.com/bluefireteam/audioplayers/issues/58)) ([#1444](https://github.com/bluefireteam/audioplayers/issues/1444)). ([3b5de50e](https://github.com/bluefireteam/audioplayers/commit/3b5de50ea7fa5248165616fc1ffd80da6c66583a)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + +## 2.0.0 + +> Note: This release has breaking changes. + + - **FIX**: playing at playback rate `1.0` in android API level < 23 (fixes [#1344](https://github.com/bluefireteam/audioplayers/issues/1344)) ([#1390](https://github.com/bluefireteam/audioplayers/issues/1390)). ([b248e71d](https://github.com/bluefireteam/audioplayers/commit/b248e71dabf923072f1fd14355b4e0230c9a6593)) + - **BREAKING** **FEAT**: configurable SoundPool and `AudioManager.mode` ([#1388](https://github.com/bluefireteam/audioplayers/issues/1388)). ([5697f187](https://github.com/bluefireteam/audioplayers/commit/5697f187bcca64de2e519f8f49aaf4817fcf6398)) + +## 1.1.4 + + - Update a dependency to the latest release. + +## 1.1.3 + + - **FIX**: Avoid ConcurrentModificationException ([#1297](https://github.com/bluefireteam/audioplayers/issues/1297)). ([d15ef5ab](https://github.com/bluefireteam/audioplayers/commit/d15ef5ab93f11e2f19089af08f1533fcdc1397e6)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +## 1.1.1 + + - **FIX**: Avoid ConcurrentModificationException ([#1297](https://github.com/bluefireteam/audioplayers/issues/1297)). ([d15ef5ab](https://github.com/bluefireteam/audioplayers/commit/d15ef5ab93f11e2f19089af08f1533fcdc1397e6)) + +## 1.1.0 + + - **FIX**: lowLatency bugs (closes #1176, closes #1193, closes #1165) (#1272). ([541578cc](https://github.com/bluefireteam/audioplayers/commit/541578cc50f3856c23c393faa1a71380b3b49222)) + - **FIX**: revert compileSdkVersion to be compatible with flutter.compileSdkVersion (#1273). ([0b9fed43](https://github.com/bluefireteam/audioplayers/commit/0b9fed43d9dfa90870826dc9a34d1a0d730bd78d)) + - **FIX**: emit onPositionChanged when seek is completed (closes #1259) (#1265). ([be7ac6a9](https://github.com/bluefireteam/audioplayers/commit/be7ac6a957fccadf5bcecf0f1fbea197d32bda21)) + - **FIX**: bugs from integration tests (#1247). ([6fad1cc4](https://github.com/bluefireteam/audioplayers/commit/6fad1cc4443e623e5c94519f130b4004b2dc3857)) + - **FIX**: Fix lowLatency mode for Android (#1193) (#1224). ([a25ca284](https://github.com/bluefireteam/audioplayers/commit/a25ca284835252147c85944575c7e71a3ef6abc4)) + - **FEAT**: wait for source to be prepared (#1191). ([5eeca894](https://github.com/bluefireteam/audioplayers/commit/5eeca8940e764546023567fa2f6b1bc3802f97d3)) + +## 1.0.1 + + - **FIX**: getDuration, getPosition causes MEDIA_ERROR_UNKNOWN (#1172). ([51b4c73e](https://github.com/bluefireteam/audioplayers/commit/51b4c73eaff5c60d1c3c3e42ae783df07d34be09)) + +## 1.0.0 + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +## 1.0.0-rc.2 + +## 1.0.0-rc.1 + + - First release after federation + +# Changelog + +## 0.20.2 +- Fix bug with inversed log levels + +## 0.20.1 +- Fix enum parsing on release mode on android + +## 0.20.0 +- Fix android/kotlin build for old projects +- Add method to clearNotification +- Add currentPosition stream on web +- Add seek on web +- Add a proper Logger +- Make setPlaybackRate signature consistent +- Fix fatal exception on Android API < 21 in WrappedMediaPlayer.kt setAttributes +- Add clearNotification method + +## 0.19.1 +- Add missing awaits for AudioCache +- Fix Kotlin Core version to v1.6.0 +- Fix iOS warning +- Fix README link to audio_cache.md to work on pub +- Fix documentation referencing old class +- Add web support for audioPlayer.getCurrentPosition +- Add web support for audioPlayer.getDuration +- Add web support for audioPlayer.setPlaybackRate +- Fix local file playback in LOW_LATENCY mode on Android + +## 0.19.0 +- Refactor Notifications code (small breaking changes) +- AudioCache for web +- Fixing basic features for Android lower than API 23 +- Fixing error after playing music several times with AudioCache +- Re-organize folder and file structure on the Dart side (project layout) +- Re-organize folders into a mono-repo +- Fix several bugs + +## 0.18.3 +- Fix Float vs Double mixup on Swift that prevent non-integer values for volume/playback +- Fix open sink issue / resource leak + +## 0.18.2 +- Changing Android minSdk verison to 16 +- Improve build processes and other small bug fixes + +## 0.18.1 +- Fix kotlin config issue for some apps +- Fix warning from pub +- Fix iOS lock screen +- Fix setUrl method + +## 0.18.0 +- Stable null-safety release +- Removed all the `@deprecated` code blocks + +## 0.17.4 +- Fix java.lang.UnsupportedOperationException on read-only kotlin map + +## 0.17.3 +- Backport some code to old kt (for now) + +## 0.17.2 +- Fix macos compilation issue +- Fix android for non-kotlin projects + +## 0.17.1 +- Use better algorithm for speed modulation on iOS +- Extracted and refactored all the notifications code onto the new file +- Add more checks and make sure notifcations code is not ran when it shouldn't +- Add more useful info to the troubleshoot guide + +## 0.17.0 +- Swift conversion of the darwin code + +## 0.16.2 +- Overhauled our contributing guidelines +- Improve docs around player state +- Update dependencies versions + +## 0.16.1 +- Fix Exception thrown when calling audioPlayer.dispose +- Fix bug with AudioCache crash on iOS + +## 0.16.0 +- Implemented stream routing for iOS +- Call release on dispose +- Fix iOS build +- Breaking change audio cache prefix in order to allow override 'assets' + +## audioplayers 0.15.1 +- Fix web for release mode + +## audioplayers 0.15.0 +- Improve loop/readme for web support +- Audio cache support for web +- Re-adding partial web support + +## audioplayers 0.14.3 +- Add next and previous command for ios + +## audioplayers 0.14.2 +- Fix pubspec problem because of web file + +## audioplayers 0.14.1 +- Adding linter, tests and flutter_driver integration tests to a CI (github actions) +- Minor fixes to the APIs and documentation +- Fix restarting the playback of a failed AVPlayerItem +- Prevent exceptions when null values are passed to notifications center +- Prevent crash by checking if headlessServiceInitialized before invoking onNotificationBackgroundPlayerStateChanged + +## audioplayers 0.14.0 +- Adding macOs support +- ios:fix lack of seek completion handle +- ios Delay start fixed + +## audioplayers 0.13.7 +- Bump dependencies, improve gitignore +- Upgrade pubspec pattern + +## audioplayers 0.13.6 +- added `setPlaybackRate` feature for Android +- Automatic detect address is local or remote (thanks, @saeed-golshan) + +## audioplayers 0.13.5 +- fixed crash on iOS when `startHeadlessService()` wasn't called on `AudioPlayer` (by @JesseScott) + +## audioplayers 0.13.4 +- fixing missing cleanup on hot restart on Android +- Background notification updates on iOS + +## audioplayers 0.13.3 +- audio notification area fixes +- fix when other apps are playing sounds +- fix android race condition +- Support for registering plugin in background enviroment +- fix typos and docs + +## audioplayers 0.13.2 +- Handling plugin dealloc and onTimeInterval crashs (thanks @chedechao111) +- Audio position update when the audio is paused (thanks @bjornjacobs) + +## audioplayers 0.13.1 +- Added stayAwake feature (thanks, @danielR2001) +- Improved dispose method (thanks, @hugocbpassos) +- Added getCurrentPosition (thanks, @hariom08) +- Some bug fixes and small changes + +## audioplayers 0.13.0 +- Call onDurationChanged after setUrl() to be consistent with ios version (thanks @subhash279) +- Adding getDuration feature iOS/Android (thanks @alecorsino) + +## audioplayers 0.12.1 +- Fixes bug where the stream handlers were not called due to exception on the handler +- Proper error message when errors in the dart handler occurs + +## audioplayers 0.12.0 +- Update to path_provider 1.1.0 +- Upgrade to Swift 5 in example project setting (thanks @jerryzhoujw) + +## audioplayers 0.11.0 +- **Breaking change**. Migrate from the deprecated original Android Support + Library to AndroidX. This shouldn't result in any functional changes, but it + requires any Android apps using this plugin to [also + migrate](https://developer.android.com/jetpack/androidx/migrate) if they're + using the original support library. + +## audioplayers 0.10.1 +- Seek and play now works with milliseconds instead of second (thanks, @catoldcui and @erickzanardo) + +## audioplayers 0.10.0 +- Added a low latency api for android (thanks, @feroult) + +## audioplayers 0.9.0 +- Improved callbacks using Streams to allow for multiple subscibers (thanks, @LucasCLuk) +- Update uuid version to 2.0.0 (thanks, @BeMacized) + +## audioplayers 0.8.2 +- Update path_provider version (thanks, @apiraino) + +## audioplayers 0.8.1 +- Fix for duration when playing a stream +- Added respectSilence flag in audioplayers, or isNotification for play methos in audio_cache + False by default, to use player for local notification. Silent when device is in silent mode. + +## audioplayers 0.8.0 +- Allow setting seek position in play function (thanks @rob-patchett) +- Get duration from the underlaying asset instead of from AVPlayerItem (thanks @andressade) +- Adding player state (thanks @renancaraujo) +- Set the audio session to active (thanks @benwicks) +- Delay seek operations on Android until player is ready (thanks @jeffmikels) + +## audioplayers 0.7.8 +- Fix bug regarding name clash with other plugins (thanks @imtaehyun) + +## audioplayers 0.7.7 +- Fix bug when using nested files with audio cache (thanks @hotstu for reporting and @eclewlow for fixing) + +## audioplayers 0.7.6 +- Fix the nefarious bug of 'sound only playing through headphones' (thanks so much, @tsun424) + +## audioplayers 0.7.5 +- Fix SDK constraint for Dart 2.1 (thanks @snoofer and @sroddy) + +## audioplayers 0.7.4 +- Some more fixes to work without errors with Dart 2 stronger types + +## audioplayers 0.7.3 +- Support Android SDK 16-20 (thanks, @sroddy) +- Avoid restarting a looping player if is stopped (thanks, @sroddy) + +## audioplayers 0.7.2 +- Bug fixes for iOS + +## audioplayers 0.7.1 +- Formatting + +## audioplayers 0.7.0 + +- Improved lifecycle handling for android +- Big performance boots +- Allows for finer control of releasing (with setReleaseMode, setUrl, resume, release) +- Allows for setting the volume at any time (with setVolume) +- Added LOOP as a ReleaseMode options, making it significantly faster +- Some other refactorings + +## audioplayers 0.6.0 + +- Major Refactoring! +- Renaming everything to audioplayers (mind the s) +- Better logging +- Added AudioCache (imported from Flame) +- Adding tests! +- Adding better example +- Greatly improving README +- Lots of other minor tweaks + +## audioplayers 0.5.2 + +- don't call the onClomplete hook when you manually stop the audio + +## audioplayers 0.5.1 + +- fix for dart 2 (thanks to @efortuna) + +## audioplayers 0.5.0 + +- improves Android performance by not calling `prepare` on the main thread + +## audioplayers 0.4.1 + +- fix `seek` for iOS + +## audioplayers 0.4.0 + +- volume controls + +## audioplayers 0.3.0 + +- working on iOS (thanks @feroult <3) + +## audioplayers 0.2.0 + +- adding disable log option + +## audioplayers 0.1.0 + +- support for multiple audios simultaneously + +## 0.2.0 + +- support for local files + +## 0.1.0 + +- update to the current Plugin API +- move to https://github.com/rxlabz/audioplayer + +## 0.0.2 + +Separated handlers for position, duration, completion and errors + +- setDurationHandler(TimeChangeHandler handler) +- setPositionHandler(TimeChangeHandler handler) +- setCompletionHandler(VoidCallback callback) +- setErrorHandler(ErrorHandler handler) + +- new typedef +```dart +typedef void TimeChangeHandler(Duration duration); +typedef void ErrorHandler(String message); +``` + +## 0.0.1 + +- first POC : + - methods : play, pause, stop + - a globalHandler for position, duration, completion and errors diff --git a/third_party/audioplayers_android/LICENSE b/third_party/audioplayers_android/LICENSE new file mode 100644 index 00000000..1a581b05 --- /dev/null +++ b/third_party/audioplayers_android/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/audioplayers_android/README.md b/third_party/audioplayers_android/README.md new file mode 100644 index 00000000..4131dc14 --- /dev/null +++ b/third_party/audioplayers_android/README.md @@ -0,0 +1,23 @@ +

+ + AudioPlayers + +

+ +--- + +# audioplayers_android +

+ + + + +

+ +The Android implementation of [`audioplayers`](https://pub.dev/packages/audioplayers). + +## Usage + +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `audioplayers` normally. +This package will be automatically included in your app when you do, so you do not need to add it to your `pubspec.yaml`. diff --git a/third_party/audioplayers_android/SPOTIFLAC_PATCHES.md b/third_party/audioplayers_android/SPOTIFLAC_PATCHES.md new file mode 100644 index 00000000..0be94fd6 --- /dev/null +++ b/third_party/audioplayers_android/SPOTIFLAC_PATCHES.md @@ -0,0 +1,9 @@ +# Local Android audio patch + +Based on audioplayers_android 5.3.0 (license retained in LICENSE). + +`UrlSource` duplicates app-owned `/proc/self/fd/` sources and passes the +descriptor directly to MediaPlayer. Android AppFuse proxies, including remote +SAF providers, permit only the original open; reopening the proc path fails. +The caller must hold the original lease until source preparation completes. +Other URLs and file paths retain upstream behavior. diff --git a/third_party/audioplayers_android/analysis_options.yaml b/third_party/audioplayers_android/analysis_options.yaml new file mode 100644 index 00000000..85732fa0 --- /dev/null +++ b/third_party/audioplayers_android/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/third_party/audioplayers_android/android/build.gradle b/third_party/audioplayers_android/android/build.gradle new file mode 100644 index 00000000..8b3902bf --- /dev/null +++ b/third_party/audioplayers_android/android/build.gradle @@ -0,0 +1,81 @@ +group 'xyz.luan.audioplayers' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + ext.coroutines_version = '1.6.4' + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "de.mannodermaus.gradle.plugins:android-junit5:1.7.1.1" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'de.mannodermaus.android-junit5' + +android { + compileSdk 36 + + // Conditional for compatibility with AGP <4.2. + if (project.android.hasProperty('namespace')) { + namespace 'xyz.luan.audioplayers' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 19 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + lint { + disable 'InvalidPackage' + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +allprojects { + gradle.projectsEvaluated { + tasks.withType(JavaCompile) { + options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" + } + } +} + +dependencies { + implementation "androidx.core:core-ktx:1.9.0" // Do not pump unless dropping support for AGP7 + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0' + testImplementation 'org.assertj:assertj-core:3.23.1' +} +repositories { + mavenCentral() +} diff --git a/third_party/audioplayers_android/android/gradle.properties b/third_party/audioplayers_android/android/gradle.properties new file mode 100644 index 00000000..d9cf55df --- /dev/null +++ b/third_party/audioplayers_android/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true diff --git a/third_party/audioplayers_android/android/settings.gradle b/third_party/audioplayers_android/android/settings.gradle new file mode 100644 index 00000000..096aeb19 --- /dev/null +++ b/third_party/audioplayers_android/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'audioplayers_android' diff --git a/third_party/audioplayers_android/android/src/main/AndroidManifest.xml b/third_party/audioplayers_android/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0f77d9bf --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt new file mode 100644 index 00000000..eabbb50b --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt @@ -0,0 +1,69 @@ +package xyz.luan.audioplayers + +import android.annotation.SuppressLint +import android.media.AudioAttributes +import android.media.AudioAttributes.Builder +import android.media.AudioAttributes.CONTENT_TYPE_MUSIC +import android.media.AudioAttributes.USAGE_MEDIA +import android.media.AudioAttributes.USAGE_NOTIFICATION_RINGTONE +import android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION +import android.media.AudioManager +import android.media.MediaPlayer +import android.os.Build +import androidx.annotation.RequiresApi +import java.util.* + +data class AudioContextAndroid( + val isSpeakerphoneOn: Boolean, + val stayAwake: Boolean, + val contentType: Int, + val usageType: Int, + val audioFocus: Int, + val audioMode: Int, +) { + @SuppressLint("InlinedApi") // we are just using numerical constants + constructor() : this( + isSpeakerphoneOn = false, + stayAwake = false, + contentType = CONTENT_TYPE_MUSIC, + usageType = USAGE_MEDIA, + audioFocus = AudioManager.AUDIOFOCUS_GAIN, + audioMode = AudioManager.MODE_NORMAL, + ) + + fun setAttributesOnPlayer(player: MediaPlayer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + player.setAudioAttributes(buildAttributes()) + } else { + @Suppress("DEPRECATION") + player.setAudioStreamType(getStreamType()) + } + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + fun buildAttributes(): AudioAttributes { + return Builder() + .setUsage(usageType) + .setContentType(contentType) + .build() + } + + @Deprecated("This is used for Android older than LOLLIPOP", replaceWith = ReplaceWith("buildAttributes")) + private fun getStreamType(): Int { + return when (usageType) { + USAGE_VOICE_COMMUNICATION -> AudioManager.STREAM_VOICE_CALL + USAGE_NOTIFICATION_RINGTONE -> AudioManager.STREAM_RING + else -> AudioManager.STREAM_MUSIC + } + } + + override fun hashCode() = Objects.hash(isSpeakerphoneOn, stayAwake, contentType, usageType, audioFocus, audioMode) + + override fun equals(other: Any?) = (other is AudioContextAndroid) && + isSpeakerphoneOn == other.isSpeakerphoneOn && + stayAwake == other.stayAwake && + contentType == other.contentType && + usageType == other.usageType && + audioFocus == other.audioFocus && + audioMode == other.audioMode +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt new file mode 100644 index 00000000..7bf89495 --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt @@ -0,0 +1,306 @@ +package xyz.luan.audioplayers + +import android.content.Context +import android.media.AudioManager +import android.os.Build +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import xyz.luan.audioplayers.player.SoundPoolManager +import xyz.luan.audioplayers.player.WrappedPlayer +import xyz.luan.audioplayers.source.BytesSource +import xyz.luan.audioplayers.source.UrlSource +import java.io.FileNotFoundException +import java.util.concurrent.ConcurrentHashMap + +typealias FlutterHandler = (call: MethodCall, response: MethodChannel.Result) -> Unit + +class AudioplayersPlugin : FlutterPlugin { + private lateinit var methods: MethodChannel + private lateinit var globalMethods: MethodChannel + private lateinit var globalEvents: EventHandler + private lateinit var context: Context + private lateinit var binaryMessenger: BinaryMessenger + private lateinit var soundPoolManager: SoundPoolManager + + private val players = ConcurrentHashMap() + private var defaultAudioContext = AudioContextAndroid() + + override fun onAttachedToEngine(binding: FlutterPluginBinding) { + context = binding.applicationContext + binaryMessenger = binding.binaryMessenger + soundPoolManager = SoundPoolManager(this) + methods = MethodChannel(binding.binaryMessenger, "xyz.luan/audioplayers") + methods.setMethodCallHandler { call, response -> safeCall(call, response, ::methodHandler) } + globalMethods = MethodChannel(binding.binaryMessenger, "xyz.luan/audioplayers.global") + globalMethods.setMethodCallHandler { call, response -> safeCall(call, response, ::globalMethodHandler) } + globalEvents = EventHandler(EventChannel(binding.binaryMessenger, "xyz.luan/audioplayers.global/events")) + } + + override fun onDetachedFromEngine(binding: FlutterPluginBinding) { + players.values.forEach { it.dispose() } + players.clear() + soundPoolManager.dispose() + globalEvents.dispose() + } + + private fun safeCall( + call: MethodCall, + response: MethodChannel.Result, + handler: FlutterHandler, + ) { + try { + handler(call, response) + } catch (e: Throwable) { + response.error("Unexpected AndroidAudioError", e.message, e) + } + } + + private fun globalMethodHandler(call: MethodCall, response: MethodChannel.Result) { + when (call.method) { + "init" -> { + players.values.forEach { it.dispose() } + players.clear() + } + + "setAudioContext" -> { + val audioManager = getAudioManager() + audioManager.mode = defaultAudioContext.audioMode + audioManager.isSpeakerphoneOn = defaultAudioContext.isSpeakerphoneOn + + defaultAudioContext = call.audioContext() + } + + "emitLog" -> { + val message = call.argument("message") ?: error("message is required") + handleGlobalLog(message) + } + + "emitError" -> { + val code = call.argument("code") ?: error("code is required") + val message = call.argument("message") ?: error("message is required") + handleGlobalError(code, message, null) + } + + else -> { + response.notImplemented() + return + } + } + + response.success(1) + } + + private fun methodHandler(call: MethodCall, response: MethodChannel.Result) { + val playerId = call.argument("playerId") ?: return + if (call.method == "create") { + val eventHandler = EventHandler(EventChannel(binaryMessenger, "xyz.luan/audioplayers/events/$playerId")) + players[playerId] = WrappedPlayer(this, eventHandler, defaultAudioContext.copy(), soundPoolManager) + response.success(1) + return + } + val player = getPlayer(playerId) + try { + when (call.method) { + "setSourceUrl" -> { + val url = call.argument("url") ?: error("url is required") + val isLocal = call.argument("isLocal") ?: false + try { + player.source = UrlSource(url, isLocal) + } catch (e: FileNotFoundException) { + response.error( + "AndroidAudioError", + "Failed to set source. For troubleshooting, see: " + + "https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md", + e, + ) + return + } + } + + "setSourceBytes" -> { + val bytes = call.argument("bytes") ?: error("bytes are required") + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + error("BytesSource is not supported on Android <= M") + } + player.source = BytesSource(bytes) + } + + "resume" -> player.play() + "pause" -> player.pause() + "stop" -> player.stop() + "release" -> player.release() + "seek" -> { + val position = call.argument("position") ?: error("position is required") + player.seek(position) + } + + "setVolume" -> { + val volume = call.argument("volume") ?: error("volume is required") + player.volume = volume.toFloat() + } + + "setBalance" -> { + val balance = call.argument("balance") ?: error("balance is required") + player.balance = balance.toFloat() + } + + "setPlaybackRate" -> { + val rate = call.argument("playbackRate") ?: error("playbackRate is required") + player.rate = rate.toFloat() + } + + "getDuration" -> { + response.success(player.getDuration()) + return + } + + "getCurrentPosition" -> { + response.success(player.getCurrentPosition()) + return + } + + "setReleaseMode" -> { + val releaseMode = call.enumArgument("releaseMode") ?: error("releaseMode is required") + player.releaseMode = releaseMode + } + + "setPlayerMode" -> { + val playerMode = call.enumArgument("playerMode") ?: error("playerMode is required") + player.playerMode = playerMode + } + + "setAudioContext" -> { + val audioContext = call.audioContext() + player.updateAudioContext(audioContext) + } + + "emitLog" -> { + val message = call.argument("message") ?: error("message is required") + player.handleLog(message) + } + + "emitError" -> { + val code = call.argument("code") ?: error("code is required") + val message = call.argument("message") ?: error("message is required") + player.handleError(code, message, null) + } + + "dispose" -> { + player.dispose() + players.remove(playerId) + } + + else -> { + response.notImplemented() + return + } + } + response.success(1) + } catch (e: Exception) { + response.error("AndroidAudioError", e.message, e) + } + } + + private fun getPlayer(playerId: String): WrappedPlayer { + return players[playerId] ?: error("Player has not yet been created or has already been disposed.") + } + + fun getApplicationContext(): Context { + return context.applicationContext + } + + fun getAudioManager(): AudioManager { + return context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + + fun handleDuration(player: WrappedPlayer) { + player.eventHandler.success( + "audio.onDuration", + hashMapOf("value" to (player.getDuration() ?: 0)), + ) + } + + fun handleComplete(player: WrappedPlayer) { + player.eventHandler.success("audio.onComplete") + } + + fun handlePrepared(player: WrappedPlayer, isPrepared: Boolean) { + player.eventHandler.success("audio.onPrepared", hashMapOf("value" to isPrepared)) + } + + fun handleLog(player: WrappedPlayer, message: String) { + player.eventHandler.success("audio.onLog", hashMapOf("value" to message)) + } + + fun handleGlobalLog(message: String) { + globalEvents.success("audio.onLog", hashMapOf("value" to message)) + } + + fun handleError(player: WrappedPlayer, errorCode: String?, errorMessage: String?, errorDetails: Any?) { + player.eventHandler.error(errorCode, errorMessage, errorDetails) + } + + fun handleGlobalError(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + globalEvents.error(errorCode, errorMessage, errorDetails) + } + + fun handleSeekComplete(player: WrappedPlayer) { + player.eventHandler.success("audio.onSeekComplete") + } +} + +private inline fun > MethodCall.enumArgument(name: String): T? { + val enumName = argument(name) ?: return null + return enumValueOf(enumName.split('.').last().toConstantCase()) +} + +fun String.toConstantCase(): String { + return replace(Regex("(.)(\\p{Upper})"), "$1_$2").replace(Regex("(.) (.)"), "$1_$2").uppercase() +} + +private fun MethodCall.audioContext(): AudioContextAndroid { + return AudioContextAndroid( + isSpeakerphoneOn = argument("isSpeakerphoneOn") ?: error("isSpeakerphoneOn is required"), + stayAwake = argument("stayAwake") ?: error("stayAwake is required"), + contentType = argument("contentType") ?: error("contentType is required"), + usageType = argument("usageType") ?: error("usageType is required"), + audioFocus = argument("audioFocus") ?: error("audioFocus is required"), + audioMode = argument("audioMode") ?: error("audioMode is required"), + ) +} + +class EventHandler(private val eventChannel: EventChannel) : EventChannel.StreamHandler { + private var eventSink: EventChannel.EventSink? = null + + init { + eventChannel.setStreamHandler(this) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + fun success(method: String, arguments: Map = HashMap()) { + eventSink?.success(arguments + Pair("event", method)) + } + + fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + eventSink?.error(errorCode, errorMessage, errorDetails) + } + + fun dispose() { + eventSink?.let { + it.endOfStream() + onCancel(null) + } + eventChannel.setStreamHandler(null) + } +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt new file mode 100644 index 00000000..d2a249e2 --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt @@ -0,0 +1,5 @@ +package xyz.luan.audioplayers + +enum class PlayerMode { + MEDIA_PLAYER, LOW_LATENCY +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt new file mode 100644 index 00000000..cadb54b3 --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt @@ -0,0 +1,5 @@ +package xyz.luan.audioplayers + +enum class ReleaseMode { + RELEASE, LOOP, STOP +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt new file mode 100644 index 00000000..8463306b --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt @@ -0,0 +1,155 @@ +package xyz.luan.audioplayers.player + +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import androidx.annotation.RequiresApi +import xyz.luan.audioplayers.AudioContextAndroid + +abstract class FocusManager { + abstract val player: WrappedPlayer + abstract val onGranted: () -> Unit + abstract val onLoss: (isTransient: Boolean) -> Unit + abstract var context: AudioContextAndroid + + companion object { + fun create( + player: WrappedPlayer, + onGranted: () -> Unit, + onLoss: (isTransient: Boolean) -> Unit, + ): FocusManager { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ModernFocusManager(player, onGranted, onLoss) + } else { + LegacyFocusManager(player, onGranted, onLoss) + } + } + } + + protected abstract fun hasAudioFocusRequest(): Boolean + + protected abstract fun updateAudioFocusRequest() + + protected val audioManager: AudioManager + get() = player.audioManager + + fun maybeRequestAudioFocus() { + if (context != player.context) { + context = player.context + updateAudioFocusRequest() + } + if (hasAudioFocusRequest()) { + requestAudioFocus() + } else { + // Grant without requesting focus, if it is AudioManager.AUDIOFOCUS_NONE + onGranted() + } + } + + protected abstract fun requestAudioFocus() + + abstract fun handleStop() + + protected fun handleFocusResult(result: Int) { + when (result) { + AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> { + onGranted() + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + onLoss(true) + } + + AudioManager.AUDIOFOCUS_LOSS -> { + onLoss(false) + } + } + // Keep playing source on `AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK` as sound is ducked. + } +} + +private class LegacyFocusManager( + override val player: WrappedPlayer, + override val onGranted: () -> Unit, + override val onLoss: (isTransient: Boolean) -> Unit, +) : FocusManager() { + override var context: AudioContextAndroid = player.context + + // Deprecated variant of listening to focus changes + private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null + + init { + updateAudioFocusRequest() + } + + override fun hasAudioFocusRequest(): Boolean { + return audioFocusChangeListener != null + } + + override fun updateAudioFocusRequest() { + audioFocusChangeListener = if (context.audioFocus == AudioManager.AUDIOFOCUS_NONE) { + // Mix sound with others + null + } else { + AudioManager.OnAudioFocusChangeListener { handleFocusResult(it) } + } + } + + override fun handleStop() { + if (hasAudioFocusRequest()) { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(audioFocusChangeListener) + } + } + + override fun requestAudioFocus() { + @Suppress("DEPRECATION") + val result = audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + context.audioFocus, + ) + handleFocusResult(result) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private class ModernFocusManager( + override val player: WrappedPlayer, + override val onGranted: () -> Unit, + override val onLoss: (isTransient: Boolean) -> Unit, +) : FocusManager() { + override var context: AudioContextAndroid = player.context + + // Listen also for focus changes, e.g. if interrupt playing with a phone call and resume afterward. + private var audioFocusRequest: AudioFocusRequest? = null + + init { + updateAudioFocusRequest() + } + + override fun hasAudioFocusRequest(): Boolean { + return audioFocusRequest != null + } + + override fun updateAudioFocusRequest() { + audioFocusRequest = if (context.audioFocus == AudioManager.AUDIOFOCUS_NONE) { + // Mix sound with others + null + } else { + AudioFocusRequest.Builder(context.audioFocus).setAudioAttributes(context.buildAttributes()) + .setOnAudioFocusChangeListener { handleFocusResult(it) }.build() + } + } + + override fun handleStop() { + if (hasAudioFocusRequest()) { + audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + } + } + + override fun requestAudioFocus() { + val result = audioManager.requestAudioFocus(audioFocusRequest!!) + handleFocusResult(result) + } +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt new file mode 100644 index 00000000..1e2b3b00 --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt @@ -0,0 +1,99 @@ +package xyz.luan.audioplayers.player + +import android.media.MediaPlayer +import android.os.Build +import android.os.PowerManager +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.source.Source + +class MediaPlayerWrapper( + private val wrappedPlayer: WrappedPlayer, +) : PlayerWrapper { + private val mediaPlayer = createMediaPlayer(wrappedPlayer) + + private fun createMediaPlayer(wrappedPlayer: WrappedPlayer): MediaPlayer { + val mediaPlayer = MediaPlayer().apply { + setOnPreparedListener { wrappedPlayer.onPrepared() } + setOnCompletionListener { wrappedPlayer.onCompletion() } + setOnSeekCompleteListener { wrappedPlayer.onSeekComplete() } + setOnErrorListener { _, what, extra -> wrappedPlayer.onError(what, extra) } + setOnBufferingUpdateListener { _, percent -> wrappedPlayer.onBuffering(percent) } + } + wrappedPlayer.context.setAttributesOnPlayer(mediaPlayer) + return mediaPlayer + } + + override fun getDuration(): Int? { + // media player returns -1 if the duration is unknown + return mediaPlayer.duration.takeUnless { it == -1 } + } + + override fun getCurrentPosition(): Int { + return mediaPlayer.currentPosition + } + + override fun setVolume(leftVolume: Float, rightVolume: Float) { + mediaPlayer.setVolume(leftVolume, rightVolume) + } + + override fun setRate(rate: Float) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(rate) + } else if (rate == 1.0f) { + mediaPlayer.start() + } else { + error("Changing the playback rate is only available for Android M/23+ or using LOW_LATENCY mode.") + } + } + + override fun setSource(source: Source) { + reset() + source.setForMediaPlayer(mediaPlayer) + } + + override fun setLooping(looping: Boolean) { + mediaPlayer.isLooping = looping + } + + override fun start() { + // Setting playback rate instead of mediaPlayer.start(). + setRate(wrappedPlayer.rate) + } + + override fun pause() { + mediaPlayer.pause() + } + + override fun stop() { + mediaPlayer.stop() + } + + override fun release() { + mediaPlayer.reset() + mediaPlayer.release() + } + + override fun seekTo(position: Int) { + mediaPlayer.seekTo(position) + } + + override fun updateContext(context: AudioContextAndroid) { + context.setAttributesOnPlayer(mediaPlayer) + if (context.stayAwake) { + mediaPlayer.setWakeMode(wrappedPlayer.applicationContext, PowerManager.PARTIAL_WAKE_LOCK) + } + } + + override fun prepare() { + mediaPlayer.prepareAsync() + } + + override fun reset() { + mediaPlayer.reset() + } + + override fun isLiveStream(): Boolean { + val duration = getDuration() + return duration == null || duration == 0 + } +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/PlayerWrapper.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/PlayerWrapper.kt new file mode 100644 index 00000000..f6ad22be --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/PlayerWrapper.kt @@ -0,0 +1,25 @@ +package xyz.luan.audioplayers.player + +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.source.Source + +interface PlayerWrapper { + fun getDuration(): Int? + fun getCurrentPosition(): Int? + fun isLiveStream(): Boolean + + fun start() + fun pause() + fun stop() + fun seekTo(position: Int) + + fun setVolume(leftVolume: Float, rightVolume: Float) + fun setRate(rate: Float) + fun setLooping(looping: Boolean) + fun updateContext(context: AudioContextAndroid) + fun setSource(source: Source) + + fun prepare() + fun release() + fun reset() +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt new file mode 100644 index 00000000..ff440354 --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt @@ -0,0 +1,307 @@ +package xyz.luan.audioplayers.player + +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import android.os.Build +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.AudioplayersPlugin +import xyz.luan.audioplayers.source.Source +import xyz.luan.audioplayers.source.UrlSource +import java.util.Collections.synchronizedMap + +/** Value should not exceed 32 */ +// TODO(luan): make this configurable +private const val MAX_STREAMS = 32 + +class SoundPoolPlayer( + val wrappedPlayer: WrappedPlayer, + private val soundPoolManager: SoundPoolManager, +) : PlayerWrapper { + private val mainScope = CoroutineScope(Dispatchers.Main) + + /** The id of the sound of source which will be played */ + var soundId: Int? = null + + /** The id of the stream / player */ + private var streamId: Int? = null + + private var audioContext = wrappedPlayer.context + set(value) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // AudioAttributes are compared by its property values. + if (field.buildAttributes() != value.buildAttributes()) { + release() + soundPoolManager.createSoundPoolWrapper(MAX_STREAMS, value) + soundPoolWrapper = soundPoolManager.getSoundPoolWrapper(value) + ?: error("Could not create SoundPool $value") + } + } + field = value + } + + private var soundPoolWrapper: SoundPoolWrapper + + private val soundPool: SoundPool + get() = soundPoolWrapper.soundPool + + init { + soundPoolManager.createSoundPoolWrapper(MAX_STREAMS, audioContext) + soundPoolWrapper = soundPoolManager.getSoundPoolWrapper(audioContext) + ?: error("Could not create SoundPool $audioContext") + } + + override fun stop() { + streamId?.let { + soundPool.stop(it) + streamId = null + } + } + + override fun release() { + stop() + val soundId = this.soundId ?: return + val urlSource = this.urlSource ?: return + + synchronized(soundPoolWrapper.urlToPlayers) { + val playersForSoundId = soundPoolWrapper.urlToPlayers[urlSource] ?: return + if (playersForSoundId.singleOrNull() === this) { + soundPoolWrapper.urlToPlayers.remove(urlSource) + soundPool.unload(soundId) + soundPoolWrapper.soundIdToPlayer.remove(soundId) + wrappedPlayer.handleLog("unloaded soundId $soundId") + } else { + // This is not the last player using the soundId, just remove it from the list. + playersForSoundId.remove(this) + } + this.soundId = null + this.urlSource = null + } + } + + override fun pause() { + streamId?.let { soundPool.pause(it) } + } + + override fun updateContext(context: AudioContextAndroid) { + audioContext = context + } + + override fun setSource(source: Source) { + source.setForSoundPool(this) + } + + var urlSource: UrlSource? = null + set(value) { + if (value != null) { + synchronized(soundPoolWrapper.urlToPlayers) { + val urlPlayers = soundPoolWrapper.urlToPlayers.getOrPut(value) { mutableListOf() } + val originalPlayer = urlPlayers.firstOrNull() + + if (originalPlayer != null) { + // Sound has already been loaded - reuse the soundId. + val prepared = originalPlayer.wrappedPlayer.prepared + wrappedPlayer.prepared = prepared + soundId = originalPlayer.soundId + wrappedPlayer.handleLog("Reusing soundId $soundId for $value is prepared=$prepared $this") + } else { + // First one for this URL - load it. + val start = System.currentTimeMillis() + + wrappedPlayer.prepared = false + val soundPoolPlayer = this + wrappedPlayer.handleLog("Fetching actual URL for $value") + + // Need to load sound on another thread than main to avoid `NetworkOnMainThreadException` + mainScope.launch(Dispatchers.IO) { + val actualUrl = value.getAudioPathForSoundPool() + // Run on main thread again + mainScope.launch(Dispatchers.Main) { + wrappedPlayer.handleLog("Now loading $actualUrl") + val intSoundId = soundPool.load(actualUrl, 1) + soundPoolWrapper.soundIdToPlayer[intSoundId] = soundPoolPlayer + soundId = intSoundId + + wrappedPlayer.handleLog( + "time to call load() for $value: " + + "${System.currentTimeMillis() - start} player=$this", + ) + } + } + } + urlPlayers.add(this) + } + } + field = value + } + + override fun setVolume(leftVolume: Float, rightVolume: Float) { + streamId?.let { soundPool.setVolume(it, leftVolume, rightVolume) } + } + + override fun setRate(rate: Float) { + streamId?.let { soundPool.setRate(it, rate) } + } + + override fun setLooping(looping: Boolean) { + streamId?.let { soundPool.setLoop(it, looping.loopModeInteger()) } + } + + // Cannot get duration for Sound Pool + override fun getDuration() = null + + // Cannot get current position for Sound Pool + override fun getCurrentPosition() = null + + override fun seekTo(position: Int) { + if (position == 0) { + streamId?.let { + stop() + if (wrappedPlayer.playing) { + soundPool.resume(it) + } + } + } else { + unsupportedOperation("seek") + } + } + + override fun start() { + val streamId = streamId + val soundId = soundId + + if (streamId != null) { + soundPool.resume(streamId) + } else if (soundId != null) { + this.streamId = soundPool.play( + soundId, + wrappedPlayer.volume, + wrappedPlayer.volume, + 0, + wrappedPlayer.isLooping.loopModeInteger(), + wrappedPlayer.rate, + ) + } + } + + override fun prepare() { + // sound pool automatically prepares when source URL is set + } + + override fun reset() { + // TODO(luan): what do I do here? + } + + override fun isLiveStream() = false + + /** Integer representation of the loop mode used by Android */ + private fun Boolean.loopModeInteger(): Int = if (this) -1 else 0 + + private fun unsupportedOperation(message: String): Nothing { + throw UnsupportedOperationException("LOW_LATENCY mode does not support: $message") + } +} + +class SoundPoolManager( + private val ref: AudioplayersPlugin, +) { + + // Only needed for legacy apps with SDK < 21 + private var legacySoundPoolWrapper: SoundPoolWrapper? = null + + /** + * Lazy store one [SoundPoolWrapper] for each [AudioAttributes] configuration. + * [AudioAttributes] are compared by its property values, so it can be used as key. + */ + private val soundPoolWrappers = HashMap() + + /** + * Create a SoundPoolWrapper with the given [maxStreams] and the according [audioContext] and save it to be + * globally accessible for every player. + * + * @param maxStreams the maximum number of simultaneous streams for this + * SoundPool object, see [SoundPool.Builder.setMaxStreams] + */ + fun createSoundPoolWrapper(maxStreams: Int, audioContext: AudioContextAndroid) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val attrs = audioContext.buildAttributes() + if (!soundPoolWrappers.containsKey(attrs)) { + val soundPool = SoundPool.Builder() + .setAudioAttributes(attrs) + .setMaxStreams(maxStreams) + .build() + ref.handleGlobalLog("Create SoundPool with $attrs") + val soundPoolWrapper = SoundPoolWrapper(soundPool) + soundPoolWrapper.soundPool.setOnLoadCompleteListener { _, sampleId, _ -> + ref.handleGlobalLog("Loaded $sampleId") + val loadingPlayer = soundPoolWrapper.soundIdToPlayer[sampleId] + val urlSource = loadingPlayer?.urlSource + if (urlSource != null) { + soundPoolWrapper.soundIdToPlayer.remove(loadingPlayer.soundId) + // Now mark all players using this sound as not loading and start them if necessary + synchronized(soundPoolWrapper.urlToPlayers) { + val urlPlayers = soundPoolWrapper.urlToPlayers[urlSource] ?: listOf() + for (player in urlPlayers) { + player.wrappedPlayer.handleLog("Marking $player as loaded") + player.wrappedPlayer.prepared = true + if (player.wrappedPlayer.playing) { + player.wrappedPlayer.handleLog("Delayed start of $player") + player.start() + } + } + } + } + } + soundPoolWrappers[attrs] = soundPoolWrapper + } + } else if (legacySoundPoolWrapper == null) { + @Suppress("DEPRECATION") + val soundPool = SoundPool(maxStreams, AudioManager.STREAM_MUSIC, 0) + ref.handleGlobalLog("Create legacy SoundPool") + legacySoundPoolWrapper = SoundPoolWrapper(soundPool) + } + } + + /** + * Get the [SoundPoolWrapper] with the given [audioContext]. + */ + fun getSoundPoolWrapper(audioContext: AudioContextAndroid): SoundPoolWrapper? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val attrs = audioContext.buildAttributes() + soundPoolWrappers[attrs] + } else { + legacySoundPoolWrapper + } + } + + fun dispose() { + for (soundPoolEntry in soundPoolWrappers) { + soundPoolEntry.value.dispose() + } + soundPoolWrappers.clear() + } +} + +class SoundPoolWrapper(val soundPool: SoundPool) { + + /** For the onLoadComplete listener, track which sound id is associated with which player. An entry only exists until + * it has been loaded. + */ + val soundIdToPlayer: MutableMap = synchronizedMap(mutableMapOf()) + + /** This is to keep track of the players which share the same sound id, referenced by url. When a player release()s, it + * is removed from the associated player list. The last player to be removed actually unloads() the sound id and then + * the url is removed from this map. + */ + val urlToPlayers: MutableMap> = + synchronizedMap(mutableMapOf>()) + + fun dispose() { + soundPool.release() + soundIdToPlayer.clear() + urlToPlayers.clear() + } +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt new file mode 100644 index 00000000..8a7d9c45 --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt @@ -0,0 +1,395 @@ +package xyz.luan.audioplayers.player + +import android.content.Context +import android.media.AudioManager +import android.media.MediaPlayer +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.AudioplayersPlugin +import xyz.luan.audioplayers.EventHandler +import xyz.luan.audioplayers.PlayerMode +import xyz.luan.audioplayers.PlayerMode.LOW_LATENCY +import xyz.luan.audioplayers.PlayerMode.MEDIA_PLAYER +import xyz.luan.audioplayers.ReleaseMode +import xyz.luan.audioplayers.source.Source +import kotlin.math.min + +// For some reason this cannot be accessed from MediaPlayer.MEDIA_ERROR_SYSTEM +private const val MEDIA_ERROR_SYSTEM = -2147483648 + +class WrappedPlayer internal constructor( + private val ref: AudioplayersPlugin, + val eventHandler: EventHandler, + var context: AudioContextAndroid, + private val soundPoolManager: SoundPoolManager, +) { + private var player: PlayerWrapper? = null + + var source: Source? = null + set(value) { + if (field != value) { + if (value != null) { + val player = getOrCreatePlayer() + player.setSource(value) + player.configAndPrepare() + } else { + released = true + prepared = false + playing = false + player?.release() + } + field = value + } else { + ref.handlePrepared(this, true) + } + } + + var volume = 1.0f + set(value) { + if (field != value) { + field = value + if (!released) { + player?.setVolumeAndBalance(value, balance) + } + } + } + + var balance = 0.0f + set(value) { + if (field != value) { + field = value + if (!released) { + player?.setVolumeAndBalance(volume, value) + } + } + } + + var rate = 1.0f + set(value) { + if (field != value) { + field = value + if (playing) { + player?.setRate(value) + } + } + } + + var releaseMode = ReleaseMode.RELEASE + set(value) { + if (field != value) { + field = value + if (!released) { + player?.setLooping(isLooping) + } + } + } + + val isLooping: Boolean + get() = releaseMode == ReleaseMode.LOOP + + var playerMode: PlayerMode = MEDIA_PLAYER + set(value) { + if (field != value) { + field = value + + // if the player exists, we need to re-create it from scratch; + // this will probably cause music to pause for a second + player?.let { + shouldSeekTo = maybeGetCurrentPosition() + prepared = false + it.release() + } + initPlayer() + } + } + + var released = true + + var prepared: Boolean = false + set(value) { + if (field != value) { + field = value + ref.handlePrepared(this, value) + } + } + + var playing = false + var shouldSeekTo = -1 + + private val focusManager = FocusManager.create( + this, + onGranted = { + // Check if in playing state, as the focus can also be gained e.g. after a phone call, even if not playing. + if (playing) { + player?.start() + } + }, + onLoss = { isTransient -> + if (isTransient) { + // Do not check or set playing state, as the state should be recovered after granting focus again. + player?.pause() + } else { + // Audio focus won't be recovered + pause() + } + }, + ) + + private fun maybeGetCurrentPosition(): Int { + // for Sound Pool, we can't get current position, so we just start over + return runCatching { player?.getCurrentPosition().takeUnless { it == 0 } }.getOrNull() ?: -1 + } + + private fun getOrCreatePlayer(): PlayerWrapper { + val currentPlayer = player + return if (released || currentPlayer == null) { + createPlayer().also { + player = it + released = false + } + } else if (prepared) { + currentPlayer.also { + it.reset() + prepared = false + } + } else { + currentPlayer + } + } + + fun updateAudioContext(audioContext: AudioContextAndroid) { + if (context == audioContext) { + return + } + if (context.audioFocus != AudioManager.AUDIOFOCUS_NONE && + audioContext.audioFocus == AudioManager.AUDIOFOCUS_NONE + ) { + focusManager.handleStop() + } + this.context = audioContext.copy() + + // AudioManager values are set globally + audioManager.mode = context.audioMode + audioManager.isSpeakerphoneOn = context.isSpeakerphoneOn + + player?.let { p -> + p.stop() + prepared = false + // Context is only applied, once the player.reset() was called + p.updateContext(context) + source?.let { + p.setSource(it) + p.configAndPrepare() + } + } + } + + // Getters + + /** + * Returns the duration of the media in milliseconds, if available. + */ + fun getDuration(): Int? { + return if (prepared) player?.getDuration() else null + } + + /** + * Returns the current position of the playback in milliseconds, if available. + */ + fun getCurrentPosition(): Int? { + return if (prepared) player?.getCurrentPosition() else null + } + + val applicationContext: Context + get() = ref.getApplicationContext() + + val audioManager: AudioManager + get() = ref.getAudioManager() + + /** + * Playback handling methods + */ + fun play() { + if (!playing && !released) { + playing = true + if (player == null) { + initPlayer() + } else if (prepared) { + requestFocusAndStart() + } + } + } + + // Try to get audio focus and then start. + private fun requestFocusAndStart() { + focusManager.maybeRequestAudioFocus() + } + + fun stop() { + focusManager.handleStop() + if (released) { + return + } + if (releaseMode != ReleaseMode.RELEASE) { + pause() + if (prepared) { + if (player?.isLiveStream() == true) { + player?.stop() + prepared = false + player?.prepare() + } else { + // MediaPlayer does not allow to call player.seekTo after calling player.stop + seek(0) + } + } + } else { + release() + } + } + + fun release() { + focusManager.handleStop() + if (released) { + return + } + if (playing) { + player?.stop() + } + + // Setting source to null will reset released, prepared and playing + // and also calls player.release() + source = null + player = null + } + + fun pause() { + if (playing) { + playing = false + if (prepared) { + player?.pause() + } + } + } + + // seek operations cannot be called until after + // the player is ready. + fun seek(position: Int) { + shouldSeekTo = if (prepared && player?.isLiveStream() != true) { + player?.seekTo(position) + -1 + } else { + position + } + } + + /** + * Player callbacks + */ + fun onPrepared() { + prepared = true + ref.handleDuration(this) + if (playing) { + requestFocusAndStart() + } + if (shouldSeekTo >= 0 && player?.isLiveStream() != true) { + player?.seekTo(shouldSeekTo) + } + } + + fun onCompletion() { + if (releaseMode != ReleaseMode.LOOP) { + stop() + } + ref.handleComplete(this) + } + + @Suppress("UNUSED_PARAMETER") + fun onBuffering(percent: Int) { + // TODO(luan): expose this as a stream + } + + fun onSeekComplete() { + ref.handleSeekComplete(this) + } + + fun handleLog(message: String) { + ref.handleLog(this, message) + } + + fun handleError(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + ref.handleError(this, errorCode, errorMessage, errorDetails) + } + + fun onError(what: Int, extra: Int): Boolean { + val whatMsg = if (what == MediaPlayer.MEDIA_ERROR_SERVER_DIED) { + "MEDIA_ERROR_SERVER_DIED" + } else { + "MEDIA_ERROR_UNKNOWN {what:$what}" + } + val extraMsg = when (extra) { + MEDIA_ERROR_SYSTEM -> "MEDIA_ERROR_SYSTEM" + MediaPlayer.MEDIA_ERROR_IO -> "MEDIA_ERROR_IO" + MediaPlayer.MEDIA_ERROR_MALFORMED -> "MEDIA_ERROR_MALFORMED" + MediaPlayer.MEDIA_ERROR_UNSUPPORTED -> "MEDIA_ERROR_UNSUPPORTED" + MediaPlayer.MEDIA_ERROR_TIMED_OUT -> "MEDIA_ERROR_TIMED_OUT" + else -> "MEDIA_ERROR_UNKNOWN {extra:$extra}" + } + if (!prepared && extraMsg == "MEDIA_ERROR_SYSTEM") { + handleError( + "AndroidAudioError", + "Failed to set source. For troubleshooting, see: " + + "https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md", + "$whatMsg, $extraMsg", + ) + } else { + // When an error occurs, reset player to not [prepared]. + // Then no functions will be called, which end up in an illegal player state. + prepared = false + handleError("AndroidAudioError", whatMsg, extraMsg) + } + return false + } + + /** + * Internal logic. Private methods + */ + + /** + * Create new player + */ + private fun createPlayer(): PlayerWrapper { + return when (playerMode) { + MEDIA_PLAYER -> MediaPlayerWrapper(this) + LOW_LATENCY -> SoundPoolPlayer(this, soundPoolManager) + } + } + + /** + * Create new player, assign and configure source + */ + private fun initPlayer() { + val player = createPlayer() + // Need to set player before calling prepare, as onPrepared may is called before player is assigned + this.player = player + source?.let { + player.setSource(it) + player.configAndPrepare() + } + } + + private fun PlayerWrapper.configAndPrepare() { + setVolumeAndBalance(volume, balance) + setLooping(isLooping) + prepare() + } + + private fun PlayerWrapper.setVolumeAndBalance(volume: Float, balance: Float) { + val leftVolume = min(1f, 1f - balance) * volume + val rightVolume = min(1f, 1f + balance) * volume + setVolume(leftVolume, rightVolume) + } + + fun dispose() { + release() + eventHandler.dispose() + } +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/ByteDataSource.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/ByteDataSource.kt new file mode 100644 index 00000000..203dc0c0 --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/ByteDataSource.kt @@ -0,0 +1,35 @@ +package xyz.luan.audioplayers.source + +import android.media.MediaDataSource +import android.os.Build +import androidx.annotation.RequiresApi + +@RequiresApi(Build.VERSION_CODES.M) +class ByteDataSource( + private val data: ByteArray, +) : MediaDataSource() { + @Synchronized + override fun getSize(): Long = data.size.toLong() + + @Synchronized + override fun close() = Unit + + @Synchronized + override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { + if (position >= data.size) { + return -1 + } + + val remainingSize = computeRemainingSize(size, position) + System.arraycopy(data, position.toInt(), buffer, offset, remainingSize) + return remainingSize + } + + private fun computeRemainingSize(size: Int, position: Long): Int { + var remainingSize = size.toLong() + if (position + remainingSize > data.size) { + remainingSize -= position + remainingSize - data.size + } + return remainingSize.toInt() + } +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt new file mode 100644 index 00000000..78c94f1d --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt @@ -0,0 +1,21 @@ +package xyz.luan.audioplayers.source + +import android.media.MediaPlayer +import android.os.Build +import androidx.annotation.RequiresApi +import xyz.luan.audioplayers.player.SoundPoolPlayer + +@RequiresApi(Build.VERSION_CODES.M) +data class BytesSource( + val dataSource: ByteDataSource, +) : Source { + constructor(bytes: ByteArray) : this(ByteDataSource(bytes)) + + override fun setForMediaPlayer(mediaPlayer: MediaPlayer) { + mediaPlayer.setDataSource(dataSource) + } + + override fun setForSoundPool(soundPoolPlayer: SoundPoolPlayer) { + error("Bytes sources are not supported on LOW_LATENCY mode yet.") + } +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/Source.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/Source.kt new file mode 100644 index 00000000..8d410bed --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/Source.kt @@ -0,0 +1,10 @@ +package xyz.luan.audioplayers.source + +import android.media.MediaPlayer +import xyz.luan.audioplayers.player.SoundPoolPlayer + +// TODO(luan) replace this indirection with a sealed interface once we have that option! +interface Source { + fun setForMediaPlayer(mediaPlayer: MediaPlayer) + fun setForSoundPool(soundPoolPlayer: SoundPoolPlayer) +} diff --git a/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt new file mode 100644 index 00000000..7a5f1449 --- /dev/null +++ b/third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt @@ -0,0 +1,65 @@ +package xyz.luan.audioplayers.source + +import android.media.MediaPlayer +import android.os.ParcelFileDescriptor +import xyz.luan.audioplayers.player.SoundPoolPlayer +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.net.URI +import java.net.URL + +data class UrlSource( + val url: String, + val isLocal: Boolean, +) : Source { + override fun setForMediaPlayer(mediaPlayer: MediaPlayer) { + val descriptor = if (isLocal && url.startsWith("/proc/self/fd/")) { + url.removePrefix("/proc/self/fd/").toIntOrNull()?.takeIf { it >= 0 } + } else null + if (descriptor != null) { + // AppFuse proxies (e.g. network SAF providers) reject a second + // open. dup() preserves the already granted, seekable descriptor. + ParcelFileDescriptor.fromFd(descriptor).use { + mediaPlayer.setDataSource(it.fileDescriptor) + } + } else { + mediaPlayer.setDataSource(url) + } + } + + override fun setForSoundPool(soundPoolPlayer: SoundPoolPlayer) { + soundPoolPlayer.release() + soundPoolPlayer.urlSource = this + } + + fun getAudioPathForSoundPool(): String { + if (isLocal) { + return url.removePrefix("file://") + } + + return loadTempFileFromNetwork().absolutePath + } + + private fun loadTempFileFromNetwork(): File { + val bytes = downloadUrl(URI.create(url).toURL()) + val tempFile = File.createTempFile("sound", "") + FileOutputStream(tempFile).use { + it.write(bytes) + tempFile.deleteOnExit() + } + return tempFile + } + + private fun downloadUrl(url: URL): ByteArray { + val outputStream = ByteArrayOutputStream() + url.openStream().use { stream -> + val chunk = ByteArray(4096) + while (true) { + val bytesRead = stream.read(chunk).takeIf { it > 0 } ?: break + outputStream.write(chunk, 0, bytesRead) + } + } + return outputStream.toByteArray() + } +} diff --git a/third_party/audioplayers_android/pubspec.yaml b/third_party/audioplayers_android/pubspec.yaml new file mode 100644 index 00000000..056d3f6f --- /dev/null +++ b/third_party/audioplayers_android/pubspec.yaml @@ -0,0 +1,28 @@ +name: audioplayers_android +resolution: workspace +description: Android implementation of audioplayers, a Flutter plugin to play multiple audio files simultaneously +version: 5.3.0 +homepage: https://github.com/bluefireteam/audioplayers +repository: https://github.com/bluefireteam/audioplayers/tree/master/packages/audioplayers_android + +flutter: + plugin: + implements: audioplayers + platforms: + android: + package: xyz.luan.audioplayers + pluginClass: AudioplayersPlugin + +dependencies: + audioplayers_platform_interface: ^7.2.0 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^1.4.3 + flutter_test: + sdk: flutter + +environment: + sdk: ^3.6.0 + flutter: ">=3.44.0"