Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
## 0.2.0-beta.4 - 2026-02-27

### Added

- `asset:///` artwork URI support — bundled Flutter assets are automatically extracted to the cache directory for use in system notifications, Android Auto, and CarPlay.
- `androidNotificationOngoing` option in `MtAudioPlayerConfig` to control whether the Android notification is dismissible when paused (defaults to `false`).
- `onTaskRemoved` handler to clean up playback when the app is swiped away on Android.

### Changed

- `MtArtwork` widget now supports `asset://` and `file://` URI schemes in addition to network URIs, with resolution-aware image caching (`cacheWidth`/`cacheHeight`) and `gaplessPlayback`.

### Fixed

- Skip previous/next controls are now hidden in both system notifications and the `MtTrackSkipButton` widget when the queue contains a single item.

## 0.2.0-beta.3 - 2026-02-16

### Changed
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@
![Stability](https://img.shields.io/badge/stability-beta-orange)
![License](https://img.shields.io/badge/license-MIT-green)

A stream-based audio module for Flutter. Provides background playback, system notifications, queue management, and first-class **Android Auto** & **Apple CarPlay** support -- all behind a single facade class and zero external state management dependencies.

This package reduces implementation overhead when combining packages such as `just_audio` and `audio_service`. It provides a simple wrapper API that captures our long-standing Flutter audio expertise in a single dependency.
A stream-based audio module for Flutter that delivers **background playback**, **system notifications**, **queue management**, and **first-class Android Auto & Apple CarPlay support** — all behind a single facade API and **zero external state management dependencies**.

Built on top of `just_audio` + `audio_service`, mt_audio reduces the glue code and implementation overhead, packaging our production Flutter audio know-how into one dependency.

### Use cases
- Podcast & talk apps (episode queues, resume, skip)
- Online radio / live streams (background playback, simple controls)
- Audiobooks (long-form playback, chapters as queue, progress)
- Learning & courses (lesson playlists, quick navigation)
- Any app that needs reliable background audio with minimal setup

<p align="center">
<img src="assets/home.png" width="220" alt="Now Playing" />
Expand Down
56 changes: 56 additions & 0 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
group = "com.mobitouchos.mt_audio"
version = "1.0"

buildscript {
val agpVersion = if (rootProject.extra.has("agp_version")) {
rootProject.extra["agp_version"] as String
} else {
"8.11.1"
}

val kotlinVersion = if (rootProject.extra.has("kotlin_version")) {
rootProject.extra["kotlin_version"] as String
} else {
"2.2.20"
}

repositories {
google()
mavenCentral()
}

dependencies {
classpath("com.android.tools.build:gradle:$agpVersion")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
}

allprojects {
repositories {
google()
mavenCentral()
}
}

plugins {
id("com.android.library")
id("kotlin-android")
}

android {
namespace = "com.mobitouchos.mt_audio"
compileSdk = 35

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}

defaultConfig {
minSdk = 21
}
}
1 change: 1 addition & 0 deletions android/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "mt_audio"
13 changes: 13 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.mobitouchos.mt_audio">

<application>
<!-- Exported so Android Auto (separate process) can resolve artwork URIs.
Only serves non-sensitive artwork from the sandboxed cache directory. -->
<provider
android:name="com.mobitouchos.mt_audio.MtAudioArtworkProvider"
android:authorities="${applicationId}.mt_audio.artwork"
android:exported="true"
android:grantUriPermissions="false" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.mobitouchos.mt_audio

import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.webkit.MimeTypeMap
import java.io.File

/**
* Read-only [ContentProvider] that serves cached artwork files to Android Auto.
*
* Android Auto runs in a separate process and cannot access `file://` URIs in the
* app's private cache. This provider exposes the cached artwork via `content://`
* URIs that Android Auto can resolve.
*
* URI format: `content://{applicationId}.mt_audio.artwork/{asset_key}`
* Maps to: `{cacheDir}/mt_audio_assets/{asset_key}`
*/
class MtAudioArtworkProvider : ContentProvider() {

override fun onCreate(): Boolean = true

override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
if (mode != "r") return null

val assetKey = uri.path?.removePrefix("/") ?: return null
val context = context ?: return null
val file = File(context.cacheDir, "mt_audio_assets/$assetKey")

// Prevent path traversal by ensuring the resolved path stays within the cache.
// The trailing separator stops prefixes like `${cacheBase}_evil/...` slipping past.
val cacheBase = File(context.cacheDir, "mt_audio_assets").canonicalPath + File.separator
if (!file.canonicalPath.startsWith(cacheBase)) return null

if (!file.exists()) return null

return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}

override fun getType(uri: Uri): String? {
val path = uri.path ?: return null
val ext = MimeTypeMap.getFileExtensionFromUrl(path)
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
?: "application/octet-stream"
}

// This is a read-only provider — write operations are not supported.

override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null

override fun insert(uri: Uri, values: ContentValues?): Uri? = null

override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0

override fun delete(
uri: Uri,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
}
35 changes: 35 additions & 0 deletions android/src/main/kotlin/com/mobitouchos/mt_audio/MtAudioPlugin.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.mobitouchos.mt_audio

import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel

/**
* Minimal Flutter plugin that exposes the artwork [ContentProvider] authority
* to the Dart side, allowing [MtAssetResolver] to construct `content://` URIs
* at runtime without consumer configuration.
*/
class MtAudioPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {

private lateinit var channel: MethodChannel
private lateinit var applicationId: String

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationId = binding.applicationContext.packageName
channel = MethodChannel(binding.binaryMessenger, "com.mobitouchos.mt_audio/artwork")
channel.setMethodCallHandler(this)
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getContentProviderAuthority" -> {
result.success("$applicationId.mt_audio.artwork")
}
else -> result.notImplemented()
}
}

override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
Binary file added example/assets/images/sample_cover.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions example/lib/sample_data/sample_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ final sampleTracks = [
),
duration: const Duration(minutes: 5, seconds: 24),
),
MtAudioItem(
id: 'track-asset',
uri: Uri.parse(
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-4.mp3',
),
title: 'SoundHelix Song 4 (Asset Art)',
artist: 'T. Schürger',
album: 'SoundHelix',
artworkUri: Uri.parse('asset:///assets/images/sample_cover.jpg'),
duration: const Duration(minutes: 7, seconds: 23),
),
];

/// Live radio streams from Public Domain Radio.
Expand Down
14 changes: 7 additions & 7 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.18"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
Expand All @@ -268,17 +268,17 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mt_audio:
dependency: "direct main"
description:
path: ".."
relative: true
source: path
version: "0.2.0-beta.2"
version: "0.2.0-beta.3"
mt_carplay:
dependency: transitive
description:
Expand Down Expand Up @@ -488,10 +488,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.9"
version: "0.7.11"
typed_data:
dependency: transitive
description:
Expand Down
2 changes: 2 additions & 0 deletions example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ dev_dependencies:

flutter:
uses-material-design: true
assets:
- assets/images/
22 changes: 18 additions & 4 deletions lib/src/carplay/mt_carplay_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ enum MtCarPlayTemplateType {
grid,
}

/// Converts a URI to a string suitable for mt_carplay's image field.
///
/// - `asset:///` URIs are stripped to bare asset paths (mt_carplay handles
/// these natively on iOS).
/// - All other URIs are passed through as-is.
String? _cpImageFromUri(Uri? uri) {
if (uri == null) return null;
if (uri.scheme == 'asset') {
final path = uri.path;
return path.startsWith('/') ? path.substring(1) : path;
}
return uri.toString();
}

/// Sealed class representing items in a CarPlay media library.
///
/// Use [MtCarPlayBrowsableItem] for navigable directories (folders, categories).
Expand Down Expand Up @@ -85,7 +99,7 @@ final class MtCarPlayBrowsableItem extends MtCarPlayItem {
return CPListItem(
text: title,
detailText: subtitle ?? '',
image: imageUri?.toString(),
image: _cpImageFromUri(imageUri),
accessoryType: CPListItemAccessoryTypes.disclosureIndicator,
onPress: (complete, self) async {
await onSelect(id).timeout(const Duration(seconds: 5));
Expand All @@ -102,7 +116,7 @@ final class MtCarPlayBrowsableItem extends MtCarPlayItem {
titleVariants: [
title,
],
image: imageUri?.toString() ?? '',
image: _cpImageFromUri(imageUri) ?? '',
onPress: () {
onSelect(id);
},
Expand Down Expand Up @@ -140,7 +154,7 @@ final class MtCarPlayPlayableItem extends MtCarPlayItem {
return CPListItem(
text: item.title,
detailText: item.artist ?? item.album ?? '',
image: item.artworkUri?.toString(),
image: _cpImageFromUri(item.artworkUri),
isPlaying: isPlaying,
playbackProgress: playbackProgress,
onPress: (complete, self) async {
Expand All @@ -158,7 +172,7 @@ final class MtCarPlayPlayableItem extends MtCarPlayItem {
titleVariants: [
item.title,
],
image: item.artworkUri?.toString() ?? '',
image: _cpImageFromUri(item.artworkUri) ?? '',
onPress: () {
onSelect(item.id);
},
Expand Down
Loading
Loading