diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f324e7..8cf0721 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 94f5d31..3790c7a 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,16 @@


-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
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..27721ed
--- /dev/null
+++ b/android/build.gradle.kts
@@ -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
+ }
+}
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 0000000..3ebf12a
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = "mt_audio"
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..09c7e97
--- /dev/null
+++ b/android/src/main/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/android/src/main/kotlin/com/mobitouchos/mt_audio/MtAudioArtworkProvider.kt b/android/src/main/kotlin/com/mobitouchos/mt_audio/MtAudioArtworkProvider.kt
new file mode 100644
index 0000000..ff095bb
--- /dev/null
+++ b/android/src/main/kotlin/com/mobitouchos/mt_audio/MtAudioArtworkProvider.kt
@@ -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?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor? = null
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Int = 0
+
+ override fun delete(
+ uri: Uri,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Int = 0
+}
diff --git a/android/src/main/kotlin/com/mobitouchos/mt_audio/MtAudioPlugin.kt b/android/src/main/kotlin/com/mobitouchos/mt_audio/MtAudioPlugin.kt
new file mode 100644
index 0000000..dfc4037
--- /dev/null
+++ b/android/src/main/kotlin/com/mobitouchos/mt_audio/MtAudioPlugin.kt
@@ -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)
+ }
+}
diff --git a/example/assets/images/sample_cover.jpg b/example/assets/images/sample_cover.jpg
new file mode 100644
index 0000000..25a71a0
Binary files /dev/null and b/example/assets/images/sample_cover.jpg differ
diff --git a/example/lib/sample_data/sample_data.dart b/example/lib/sample_data/sample_data.dart
index 7c2e90a..e5543b8 100644
--- a/example/lib/sample_data/sample_data.dart
+++ b/example/lib/sample_data/sample_data.dart
@@ -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.
diff --git a/example/pubspec.lock b/example/pubspec.lock
index 2147bf2..ce4557a 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -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:
@@ -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:
@@ -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:
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index 03b0800..3413a8c 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -20,3 +20,5 @@ dev_dependencies:
flutter:
uses-material-design: true
+ assets:
+ - assets/images/
diff --git a/lib/src/carplay/mt_carplay_item.dart b/lib/src/carplay/mt_carplay_item.dart
index 72d2b00..c730dd1 100644
--- a/lib/src/carplay/mt_carplay_item.dart
+++ b/lib/src/carplay/mt_carplay_item.dart
@@ -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).
@@ -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));
@@ -102,7 +116,7 @@ final class MtCarPlayBrowsableItem extends MtCarPlayItem {
titleVariants: [
title,
],
- image: imageUri?.toString() ?? '',
+ image: _cpImageFromUri(imageUri) ?? '',
onPress: () {
onSelect(id);
},
@@ -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 {
@@ -158,7 +172,7 @@ final class MtCarPlayPlayableItem extends MtCarPlayItem {
titleVariants: [
item.title,
],
- image: item.artworkUri?.toString() ?? '',
+ image: _cpImageFromUri(item.artworkUri) ?? '',
onPress: () {
onSelect(item.id);
},
diff --git a/lib/src/handler/mt_audio_handler.dart b/lib/src/handler/mt_audio_handler.dart
index 1aa79f2..dd24865 100644
--- a/lib/src/handler/mt_audio_handler.dart
+++ b/lib/src/handler/mt_audio_handler.dart
@@ -5,6 +5,7 @@ import 'package:just_audio/just_audio.dart';
import 'package:mt_audio/mt_audio.dart';
import 'package:mt_audio/src/android_auto/late_binding_android_auto_delegate.dart';
import 'package:mt_audio/src/android_auto/mt_android_auto_handler.dart';
+import 'package:mt_audio/src/utils/mt_asset_resolver.dart';
import 'package:rxdart/rxdart.dart';
/// Internal audio handler that manages audio playback.
@@ -15,13 +16,16 @@ class MtAudioHandler extends BaseAudioHandler
with QueueHandler, SeekHandler, MtAndroidAutoHandler {
/// Creates an [MtAudioHandler].
MtAudioHandler({
+ required MtAssetResolver assetResolver,
Duration ffRewindInterval = const Duration(seconds: 10),
- }) : _ffRewindInterval = ffRewindInterval,
+ }) : _assetResolver = assetResolver,
+ _ffRewindInterval = ffRewindInterval,
_androidAutoDelegate = LateBindingAndroidAutoDelegate() {
_init();
}
final AudioPlayer _player = AudioPlayer();
+ final MtAssetResolver _assetResolver;
/// Fast-forward and rewind interval
final Duration _ffRewindInterval;
@@ -116,12 +120,21 @@ class MtAudioHandler extends BaseAudioHandler
_player.shuffleModeEnabled,
_player.shuffleIndices,
);
+ final queueLength = queue.valueOrNull?.length ?? 0;
+ final controls = _getControls(
+ playing: playing,
+ isLive: isLive,
+ queueLength: queueLength,
+ );
playbackState.add(
playbackState.value.copyWith(
- controls: _getControls(playing: playing, isLive: isLive),
+ controls: controls,
systemActions: _getSystemActions(isLive: isLive),
- androidCompactActionIndices: const [0, 1, 2],
+ androidCompactActionIndices: List.generate(
+ controls.length.clamp(0, 3),
+ (i) => i,
+ ),
processingState: _mapProcessingState(processingState),
playing: playing,
updatePosition: _optimisticPosition ?? _player.position,
@@ -183,11 +196,13 @@ class MtAudioHandler extends BaseAudioHandler
List _getControls({
required bool playing,
required bool isLive,
+ required int queueLength,
}) {
+ final showSkipControls = !isLive && queueLength > 1;
return [
- if (!isLive) MediaControl.skipToPrevious,
+ if (showSkipControls) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
- if (!isLive) MediaControl.skipToNext,
+ if (showSkipControls) MediaControl.skipToNext,
];
}
@@ -219,7 +234,8 @@ class MtAudioHandler extends BaseAudioHandler
/// Sets a single audio item as the source.
Future setItem(MtAudioItem item) async {
try {
- final audioSource = _createAudioSource(item);
+ final resolved = await _assetResolver.resolveItem(item);
+ final audioSource = _createAudioSource(resolved);
await _player.setAudioSource(audioSource);
_errorSubject.add(null);
} catch (e) {
@@ -239,8 +255,9 @@ class MtAudioHandler extends BaseAudioHandler
int initialIndex = 0,
}) async {
try {
+ final resolved = await Future.wait(items.map(_assetResolver.resolveItem));
await _player.setAudioSources(
- items.map(_createAudioSource).toList(),
+ resolved.map(_createAudioSource).toList(),
initialIndex: initialIndex,
);
_errorSubject.add(null);
@@ -256,9 +273,17 @@ class MtAudioHandler extends BaseAudioHandler
}
AudioSource _createAudioSource(MtAudioItem item) {
+ var mediaItem = item.toMediaItem();
+ // Replace file:// artUri with content:// for Android Auto (cross-process).
+ // Returns null for non-file URIs (network, or already content:// during reorder),
+ // leaving the artUri unchanged in those cases.
+ final contentUri = _assetResolver.toContentUri(mediaItem.artUri);
+ if (contentUri != null) {
+ mediaItem = mediaItem.copyWith(artUri: contentUri);
+ }
return AudioSource.uri(
item.uri,
- tag: item.toMediaItem(),
+ tag: mediaItem,
headers: item.headers,
);
}
@@ -271,7 +296,8 @@ class MtAudioHandler extends BaseAudioHandler
/// Adds an audio item to the end of the queue.
Future addAudioItem(MtAudioItem item) async {
- await _player.addAudioSource(_createAudioSource(item));
+ final resolved = await _assetResolver.resolveItem(item);
+ await _player.addAudioSource(_createAudioSource(resolved));
}
/// Inserts an audio item at the specified index.
@@ -279,7 +305,8 @@ class MtAudioHandler extends BaseAudioHandler
final sourceIndex = _effectiveToSourceIndex(index, allowEnd: true);
if (sourceIndex == null) return;
- await _player.insertAudioSource(sourceIndex, _createAudioSource(item));
+ final resolved = await _assetResolver.resolveItem(item);
+ await _player.insertAudioSource(sourceIndex, _createAudioSource(resolved));
}
/// Removes an audio item at the specified index.
@@ -422,6 +449,11 @@ class MtAudioHandler extends BaseAudioHandler
await super.stop();
}
+ @override
+ Future onTaskRemoved() async {
+ await stop();
+ }
+
@override
Future seek(Duration position) async {
final targetPosition = _clampToDuration(position);
@@ -536,6 +568,28 @@ class MtAudioHandler extends BaseAudioHandler
@override
MtAndroidAutoDelegate get androidAutoDelegate => _androidAutoDelegate;
+ @override
+ Future> getChildren(
+ String parentMediaId, [
+ Map? options,
+ ]) async {
+ final items = await super.getChildren(parentMediaId, options);
+ return Future.wait(
+ items.map(_assetResolver.resolveMediaItemForExternalAccess),
+ );
+ }
+
+ @override
+ Future> search(
+ String query, [
+ Map? extras,
+ ]) async {
+ final items = await super.search(query, extras);
+ return Future.wait(
+ items.map(_assetResolver.resolveMediaItemForExternalAccess),
+ );
+ }
+
/// Disposes of this handler and releases resources.
Future dispose() async {
_optimisticPositionResetTimer?.cancel();
diff --git a/lib/src/models/mt_audio_item.dart b/lib/src/models/mt_audio_item.dart
index f7bb74d..7516d3a 100644
--- a/lib/src/models/mt_audio_item.dart
+++ b/lib/src/models/mt_audio_item.dart
@@ -25,6 +25,7 @@ class MtAudioItem extends Equatable {
final extras = mediaItem.extras ?? {};
final uriString = extras['uri'] as String?;
final isLive = extras['isLive'] as bool? ?? false;
+ final artworkUriString = extras['_artworkUri'] as String?;
final headers = switch (extras['headers']) {
final Map rawHeaders => rawHeaders.map(
(key, value) => MapEntry(key.toString(), value.toString()),
@@ -36,7 +37,8 @@ class MtAudioItem extends Equatable {
final cleanExtras = Map.from(extras)
..remove('uri')
..remove('isLive')
- ..remove('headers');
+ ..remove('headers')
+ ..remove('_artworkUri');
return MtAudioItem(
id: mediaItem.id,
@@ -44,7 +46,9 @@ class MtAudioItem extends Equatable {
title: mediaItem.title,
artist: mediaItem.artist,
album: mediaItem.album,
- artworkUri: mediaItem.artUri,
+ artworkUri: artworkUriString != null
+ ? Uri.parse(artworkUriString)
+ : mediaItem.artUri,
duration: mediaItem.duration,
isLive: isLive,
extras: cleanExtras.isEmpty ? null : cleanExtras,
@@ -93,10 +97,11 @@ class MtAudioItem extends Equatable {
duration: duration,
isLive: isLive,
extras: {
- if (extras != null) ...extras!,
+ ...?extras,
'uri': uri.toString(),
'isLive': isLive,
if (headers != null) 'headers': headers,
+ if (artworkUri != null) '_artworkUri': artworkUri.toString(),
},
);
}
diff --git a/lib/src/player/mt_audio_player.dart b/lib/src/player/mt_audio_player.dart
index 11ffec4..21d57b7 100644
--- a/lib/src/player/mt_audio_player.dart
+++ b/lib/src/player/mt_audio_player.dart
@@ -12,6 +12,7 @@ import 'package:mt_audio/src/models/mt_position_state.dart';
import 'package:mt_audio/src/models/mt_queue_state.dart';
import 'package:mt_audio/src/player/mt_audio_player_config.dart';
import 'package:mt_audio/src/session/mt_audio_session_manager.dart';
+import 'package:mt_audio/src/utils/mt_asset_resolver.dart';
import 'package:rxdart/rxdart.dart';
/// Main public API for the mt_audio package.
@@ -60,10 +61,14 @@ class MtAudioPlayer {
static Future init({
required MtAudioPlayerConfig config,
}) async {
+ // Initialize asset resolver for extracting asset:/// artwork to file://
+ final assetResolver = await MtAssetResolver.init();
+
// Create handler - includes Android Auto mixin; the AA mixin methods
// are harmless on iOS (never called by the system) and return safe defaults
// when unbound.
final handler = MtAudioHandler(
+ assetResolver: assetResolver,
ffRewindInterval: config.ffRewindInterval,
);
@@ -77,7 +82,7 @@ class MtAudioPlayer {
config.notificationIcon ?? 'mipmap/ic_launcher',
androidShowNotificationBadge: true,
preloadArtwork: true,
- androidNotificationOngoing: true,
+ androidNotificationOngoing: config.androidNotificationOngoing,
fastForwardInterval: config.ffRewindInterval,
rewindInterval: config.ffRewindInterval,
),
diff --git a/lib/src/player/mt_audio_player_config.dart b/lib/src/player/mt_audio_player_config.dart
index 1346cd9..848673d 100644
--- a/lib/src/player/mt_audio_player_config.dart
+++ b/lib/src/player/mt_audio_player_config.dart
@@ -47,6 +47,7 @@ class MtAudioPlayerConfig {
this.carPlayDelegateFactory,
this.androidAutoDelegateFactory,
this.handleInterruptions = true,
+ this.androidNotificationOngoing = false,
});
/// Notification channel ID for Android.
@@ -91,4 +92,10 @@ class MtAudioPlayerConfig {
///
/// Defaults to true.
final bool handleInterruptions;
+
+ /// Whether the Android notification should be ongoing (non-dismissible).
+ ///
+ /// When false (the default), the notification can be dismissed when paused.
+ /// Set to true to keep the notification persistent at all times.
+ final bool androidNotificationOngoing;
}
diff --git a/lib/src/utils/mt_asset_resolver.dart b/lib/src/utils/mt_asset_resolver.dart
new file mode 100644
index 0000000..ead2ba9
--- /dev/null
+++ b/lib/src/utils/mt_asset_resolver.dart
@@ -0,0 +1,199 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:audio_service/audio_service.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:mt_audio/src/models/mt_audio_item.dart';
+import 'package:path_provider/path_provider.dart';
+
+const _channel = MethodChannel('com.mobitouchos.mt_audio/artwork');
+
+/// Internal utility that resolves `asset:///` URIs to `file://` URIs
+/// by extracting assets from the Flutter bundle to the cache directory.
+///
+/// On Android, also supports resolving to `content://` URIs for cross-process
+/// access (required by Android Auto, which runs in a separate process).
+class MtAssetResolver {
+ MtAssetResolver._(this._cacheDir, this._contentAuthority);
+
+ final String _cacheDir;
+ final String? _contentAuthority;
+ final Map _resolved = {};
+ final Map> _inFlight = {};
+
+ /// Initializes the resolver with a cache directory.
+ ///
+ /// On Android, queries the native plugin for the artwork ContentProvider
+ /// authority so that [resolveForExternalAccess] can produce `content://`
+ /// URIs accessible by Android Auto.
+ static Future init() async {
+ final dir = await getApplicationCacheDirectory();
+ final cacheDir = '${dir.path}/mt_audio_assets';
+ await Directory(cacheDir).create(recursive: true);
+
+ String? authority;
+ if (Platform.isAndroid) {
+ try {
+ authority = await _channel.invokeMethod(
+ 'getContentProviderAuthority',
+ );
+ } on MissingPluginException {
+ debugPrint(
+ 'mt_audio: MtAudioPlugin not registered; '
+ 'asset:// artwork will fall back to file:// URIs that Android Auto '
+ "cannot read across processes. Artwork won't appear on Android Auto. "
+ 'Ensure the plugin is registered (rerun `flutter pub get` and a '
+ 'full rebuild after upgrading mt_audio).',
+ );
+ }
+ }
+
+ return MtAssetResolver._(cacheDir, authority);
+ }
+
+ /// Returns `true` if [uri] uses the `asset` scheme.
+ static bool isAssetUri(Uri? uri) => uri != null && uri.scheme == 'asset';
+
+ /// Extracts the Flutter asset key from an `asset:///` URI.
+ ///
+ /// Example: `asset:///assets/images/cover.png` → `assets/images/cover.png`
+ static String assetKey(Uri uri) {
+ final path = uri.path;
+ return path.startsWith('/') ? path.substring(1) : path;
+ }
+
+ /// Resolves [uri] to a `file://` URI if it uses the `asset` scheme.
+ ///
+ /// Returns the original URI unchanged for non-asset schemes.
+ /// Use this for in-process access (notifications, just_audio).
+ Future resolve(Uri uri) async {
+ if (!isAssetUri(uri)) return uri;
+
+ final key = assetKey(uri);
+ final cached = _resolved[key];
+ if (cached != null) return cached;
+
+ final inFlight = _inFlight[key];
+ if (inFlight != null) return inFlight;
+
+ final future = _extract(key);
+ _inFlight[key] = future;
+ try {
+ return await future;
+ } finally {
+ unawaited(_inFlight.remove(key));
+ }
+ }
+
+ /// Resolves [uri] for cross-process access (Android Auto).
+ ///
+ /// On Android (when the ContentProvider authority is available), this
+ /// extracts the asset to the cache directory and returns a `content://`
+ /// URI served by the native `MtAudioArtworkProvider`. On other platforms or when no
+ /// authority is configured, falls back to [resolve].
+ Future resolveForExternalAccess(Uri uri) async {
+ if (!isAssetUri(uri)) return uri;
+
+ // Ensure the asset is extracted to the cache directory.
+ final fileUri = await resolve(uri);
+
+ if (_contentAuthority != null) {
+ final key = assetKey(uri);
+ return Uri(scheme: 'content', host: _contentAuthority, path: '/$key');
+ }
+
+ return fileUri;
+ }
+
+ Future _extract(String key) async {
+ final ByteData data;
+ try {
+ data = await rootBundle.load(key);
+ } on Exception catch (e) {
+ throw Exception(
+ "Failed to load asset '$key' from bundle. "
+ 'Ensure the asset is declared in pubspec.yaml. '
+ 'Original error: $e',
+ );
+ }
+
+ final bytes = data.buffer.asUint8List(
+ data.offsetInBytes,
+ data.lengthInBytes,
+ );
+ final file = File('$_cacheDir/$key');
+
+ // Skip the write only if the cached file exists AND its bytes match the
+ // bundled asset. Length alone is insufficient — a same-size update (e.g.
+ // a re-encoded image with identical byte count) would leave stale artwork
+ // in the cache across sessions.
+ if (!await _cachedFileMatches(file, bytes)) {
+ await file.parent.create(recursive: true);
+ await file.writeAsBytes(bytes);
+ }
+
+ final fileUri = Uri.file(file.path);
+ _resolved[key] = fileUri;
+ return fileUri;
+ }
+
+ Future _cachedFileMatches(File file, Uint8List bytes) async {
+ final existingLength = await file.length().onError((_, _) => -1);
+ if (existingLength != bytes.length) return false;
+
+ final Uint8List existing;
+ try {
+ existing = await file.readAsBytes();
+ } on FileSystemException {
+ return false;
+ }
+
+ for (var i = 0; i < bytes.length; i++) {
+ if (existing[i] != bytes[i]) return false;
+ }
+ return true;
+ }
+
+ /// Resolves the artwork URI of an [MtAudioItem].
+ ///
+ /// Returns the item unchanged if its artwork is not an asset URI.
+ Future resolveItem(MtAudioItem item) async {
+ if (!isAssetUri(item.artworkUri)) return item;
+ final resolved = await resolve(item.artworkUri!);
+ return item.copyWith(artworkUri: resolved);
+ }
+
+ /// Resolves the artwork URI of a [MediaItem] for in-process access.
+ ///
+ /// Returns the item unchanged if its artwork is not an asset URI.
+ Future resolveMediaItem(MediaItem item) async {
+ if (!isAssetUri(item.artUri)) return item;
+ final resolved = await resolve(item.artUri!);
+ return item.copyWith(artUri: resolved);
+ }
+
+ /// Resolves the artwork URI of a [MediaItem] for cross-process access.
+ ///
+ /// On Android, produces `content://` URIs accessible by Android Auto.
+ /// Returns the item unchanged if its artwork is not an asset URI.
+ Future resolveMediaItemForExternalAccess(MediaItem item) async {
+ if (!isAssetUri(item.artUri)) return item;
+ final resolved = await resolveForExternalAccess(item.artUri!);
+ return item.copyWith(artUri: resolved);
+ }
+
+ /// Converts a `file://` URI in the asset cache to a `content://` URI.
+ ///
+ /// Returns `null` if [uri] is not a `file://` URI within the cache
+ /// directory or if no ContentProvider authority is configured.
+ Uri? toContentUri(Uri? uri) {
+ if (_contentAuthority == null || uri == null || uri.scheme != 'file') {
+ return null;
+ }
+ final path = uri.toFilePath();
+ if (!path.startsWith('$_cacheDir/')) return null;
+ final key = path.substring(_cacheDir.length + 1);
+ return Uri(scheme: 'content', host: _contentAuthority, path: '/$key');
+ }
+}
diff --git a/lib/src/widgets/controls/mt_track_skip_button.dart b/lib/src/widgets/controls/mt_track_skip_button.dart
index eefb6dc..bbfc612 100644
--- a/lib/src/widgets/controls/mt_track_skip_button.dart
+++ b/lib/src/widgets/controls/mt_track_skip_button.dart
@@ -62,6 +62,8 @@ class MtTrackSkipButton extends StatelessWidget {
stream: player.queueStateStream,
builder: (context, snapshot) {
final queueState = snapshot.data ?? player.currentQueueState;
+ if (queueState.length <= 1) return const SizedBox.shrink();
+
final canSkip = isNext ? queueState.hasNext : queueState.hasPrevious;
return IconButton(
diff --git a/lib/src/widgets/player_info/mt_artwork.dart b/lib/src/widgets/player_info/mt_artwork.dart
index dbd1a5c..763f8cf 100644
--- a/lib/src/widgets/player_info/mt_artwork.dart
+++ b/lib/src/widgets/player_info/mt_artwork.dart
@@ -1,4 +1,7 @@
+import 'dart:io';
+
import 'package:flutter/material.dart';
+import 'package:mt_audio/src/utils/mt_asset_resolver.dart';
/// Artwork image widget with placeholder and error handling.
///
@@ -53,19 +56,58 @@ class MtArtwork extends StatelessWidget {
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
- child: Image.network(
- artworkUri.toString(),
- width: size,
- height: size,
- fit: BoxFit.cover,
- loadingBuilder: (context, child, loadingProgress) {
- if (loadingProgress == null) return child;
- return placeholder ?? defaultPlaceholder;
- },
- errorBuilder: (context, error, stackTrace) {
- return errorWidget ?? defaultPlaceholder;
- },
+ child: _buildImage(
+ artworkUri!,
+ placeholder: placeholder ?? defaultPlaceholder,
+ errorWidget: errorWidget ?? defaultPlaceholder,
),
);
}
+
+ Widget _buildImage(
+ Uri uri, {
+ required Widget placeholder,
+ required Widget errorWidget,
+ }) {
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final pixelSize = (size * MediaQuery.devicePixelRatioOf(context))
+ .ceil();
+
+ return switch (uri.scheme) {
+ 'asset' => Image.asset(
+ MtAssetResolver.assetKey(uri),
+ width: size,
+ height: size,
+ fit: BoxFit.cover,
+ cacheWidth: pixelSize,
+ gaplessPlayback: true,
+ errorBuilder: (context, error, stackTrace) => errorWidget,
+ ),
+ 'file' => Image.file(
+ File(uri.toFilePath()),
+ width: size,
+ height: size,
+ fit: BoxFit.cover,
+ cacheWidth: pixelSize,
+ gaplessPlayback: true,
+ errorBuilder: (context, error, stackTrace) => errorWidget,
+ ),
+ _ => Image.network(
+ uri.toString(),
+ width: size,
+ height: size,
+ fit: BoxFit.cover,
+ cacheWidth: pixelSize,
+ gaplessPlayback: true,
+ loadingBuilder: (context, child, loadingProgress) {
+ if (loadingProgress == null) return child;
+ return placeholder;
+ },
+ errorBuilder: (context, error, stackTrace) => errorWidget,
+ ),
+ };
+ },
+ );
+ }
}
diff --git a/lib/src/widgets/queue/mt_queue_list_view.dart b/lib/src/widgets/queue/mt_queue_list_view.dart
index beec30f..727d541 100644
--- a/lib/src/widgets/queue/mt_queue_list_view.dart
+++ b/lib/src/widgets/queue/mt_queue_list_view.dart
@@ -85,12 +85,8 @@ class MtQueueListView extends StatelessWidget {
if (enableReorder) {
return ReorderableListView.builder(
itemCount: queueState.queue.length,
- onReorder: (oldIndex, newIndex) {
- var adjustedNewIndex = newIndex;
- if (oldIndex < newIndex) {
- adjustedNewIndex -= 1;
- }
- unawaited(player.reorderQueue(oldIndex, adjustedNewIndex));
+ onReorderItem: (oldIndex, newIndex) {
+ unawaited(player.reorderQueue(oldIndex, newIndex));
},
itemBuilder: (context, index) {
final item = queueState.queue[index];
diff --git a/pubspec.lock b/pubspec.lock
index 3466e09..f677e96 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -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:
@@ -268,10 +268,10 @@ 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_carplay:
dependency: "direct main"
description:
@@ -305,7 +305,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
- dependency: transitive
+ dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -481,10 +481,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:
diff --git a/pubspec.yaml b/pubspec.yaml
index cb327f0..11c2daa 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
name: mt_audio
-version: 0.2.0-beta.3
+version: 0.2.0-beta.4
description: A beta, streams-based Flutter audio package with background playback, queue management, Android Auto, and Apple CarPlay support.
homepage: https://github.com/mobitouchOS/mt_audio
repository: https://github.com/mobitouchOS/mt_audio
@@ -23,6 +23,14 @@ dependencies:
rxdart: ^0.28.0
equatable: ^2.0.8
mt_carplay: ^1.2.11
+ path_provider: ^2.1.0
+
+flutter:
+ plugin:
+ platforms:
+ android:
+ package: com.mobitouchos.mt_audio
+ pluginClass: MtAudioPlugin
dev_dependencies:
flutter_test: