mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
fix(android): preserve remote SAF descriptors for audio and artwork
This commit is contained in:
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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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"/>
|
||||
+69
@@ -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
|
||||
}
|
||||
third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt
Vendored
+306
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
package xyz.luan.audioplayers
|
||||
|
||||
enum class PlayerMode {
|
||||
MEDIA_PLAYER, LOW_LATENCY
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
package xyz.luan.audioplayers
|
||||
|
||||
enum class ReleaseMode {
|
||||
RELEASE, LOOP, STOP
|
||||
}
|
||||
+155
@@ -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)
|
||||
}
|
||||
}
|
||||
+99
@@ -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
|
||||
}
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
+307
@@ -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()
|
||||
}
|
||||
}
|
||||
+395
@@ -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()
|
||||
}
|
||||
}
|
||||
+35
@@ -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()
|
||||
}
|
||||
}
|
||||
third_party/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt
Vendored
+21
@@ -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.")
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -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)
|
||||
}
|
||||
Vendored
+65
@@ -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
@@ -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"
|
||||
Reference in new issue
Block a user