fix(android): preserve remote SAF descriptors for audio and artwork

This commit is contained in:
zarzet committed 2026-10-03 02:14:06 +07:00
1 parent b767bd16d8
commit bbe2caa754
32 files changed
+2290 -22

No files matched your search

@@ -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 <T> 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())
}
}
}
@@ -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
@@ -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<String, String>? {
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}"""
@@ -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
@@ -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)
}
+4 -5
View File
@@ -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
+5
View File
@@ -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
@@ -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,
+23
View File
@@ -301,6 +301,29 @@ pub(crate) fn check_lease(lease: Option<&RequestLease>) -> Result<(), String> {
}
pub(crate) fn open_audio_file(path: &str) -> Result<File, String> {
#[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::<i32>().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)]
+426
View File
@@ -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
+21
View File
@@ -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.
+23
View File
@@ -0,0 +1,23 @@
<p align="center">
<a href="https://pub.dev/packages/audioplayers">
<img alt="AudioPlayers" height="150px" src="https://raw.githubusercontent.com/bluefireteam/audioplayers/main/images/logo_ap_compact.svg">
</a>
</p>
---
# audioplayers_android
<p>
<a title="Pub" href="https://pub.dev/packages/audioplayers_android"><img src="https://img.shields.io/pub/v/audioplayers_android.svg?style=popout&include_prereleases"/></a>
<a title="Build Status" href="https://github.com/bluefireteam/audioplayers/actions?query=workflow%3Abuild+branch%3Amain"><img src="https://github.com/bluefireteam/audioplayers/actions/workflows/build.yml/badge.svg?branch=main"/></a>
<a title="Discord" href="https://discord.gg/pxrBmy4"><img src="https://img.shields.io/discord/509714518008528896.svg"/></a>
<a title="Melos" href="https://github.com/invertase/melos"><img src="https://img.shields.io/badge/maintained%20with-melos-f700ff.svg"/></a>
</p>
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`.
+9
View File
@@ -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/<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.
@@ -0,0 +1 @@
include: package:flame_lint/analysis_options.yaml
+81
View File
@@ -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()
}
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
@@ -0,0 +1 @@
rootProject.name = 'audioplayers_android'
@@ -0,0 +1 @@
<manifest package="xyz.luan.audioplayers"/>
@@ -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
}
@@ -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<String, WrappedPlayer>()
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<String>("message") ?: error("message is required")
handleGlobalLog(message)
}
"emitError" -> {
val code = call.argument<String>("code") ?: error("code is required")
val message = call.argument<String>("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<String>("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<String>("url") ?: error("url is required")
val isLocal = call.argument<Boolean>("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<ByteArray>("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<Int>("position") ?: error("position is required")
player.seek(position)
}
"setVolume" -> {
val volume = call.argument<Double>("volume") ?: error("volume is required")
player.volume = volume.toFloat()
}
"setBalance" -> {
val balance = call.argument<Double>("balance") ?: error("balance is required")
player.balance = balance.toFloat()
}
"setPlaybackRate" -> {
val rate = call.argument<Double>("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>("releaseMode") ?: error("releaseMode is required")
player.releaseMode = releaseMode
}
"setPlayerMode" -> {
val playerMode = call.enumArgument<PlayerMode>("playerMode") ?: error("playerMode is required")
player.playerMode = playerMode
}
"setAudioContext" -> {
val audioContext = call.audioContext()
player.updateAudioContext(audioContext)
}
"emitLog" -> {
val message = call.argument<String>("message") ?: error("message is required")
player.handleLog(message)
}
"emitError" -> {
val code = call.argument<String>("code") ?: error("code is required")
val message = call.argument<String>("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 <reified T : Enum<T>> MethodCall.enumArgument(name: String): T? {
val enumName = argument<String>(name) ?: return null
return enumValueOf<T>(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<Boolean>("isSpeakerphoneOn") ?: error("isSpeakerphoneOn is required"),
stayAwake = argument<Boolean>("stayAwake") ?: error("stayAwake is required"),
contentType = argument<Int>("contentType") ?: error("contentType is required"),
usageType = argument<Int>("usageType") ?: error("usageType is required"),
audioFocus = argument<Int>("audioFocus") ?: error("audioFocus is required"),
audioMode = argument<Int>("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<String, Any> = 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)
}
}
@@ -0,0 +1,5 @@
package xyz.luan.audioplayers
enum class PlayerMode {
MEDIA_PLAYER, LOW_LATENCY
}
@@ -0,0 +1,5 @@
package xyz.luan.audioplayers
enum class ReleaseMode {
RELEASE, LOOP, STOP
}
@@ -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)
}
}
@@ -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
}
}
@@ -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()
}
@@ -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<AudioAttributes, SoundPoolWrapper>()
/**
* 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<Int, SoundPoolPlayer> = synchronizedMap(mutableMapOf<Int, SoundPoolPlayer>())
/** 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<UrlSource, MutableList<SoundPoolPlayer>> =
synchronizedMap(mutableMapOf<UrlSource, MutableList<SoundPoolPlayer>>())
fun dispose() {
soundPool.release()
soundIdToPlayer.clear()
urlToPlayers.clear()
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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.")
}
}
@@ -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)
}
@@ -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()
}
}
+28
View File
@@ -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"