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 @@ ![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

Now Playing 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: