feat(extension): add custom URL handler support for extensions

- Add URLHandlerConfig to extension manifest (Go)
- Add HandleURL method to extension providers (Go)
- Add export functions for URL handling (Go)
- Add URLHandler class to extension_provider.dart (Flutter)
- Add platform bridge methods for URL handling (Flutter)
- Update fetchFromUrl to check extension URL handlers first
- Add Android/iOS native handlers for extension URL routing
- Update CHANGELOG with new feature
This commit is contained in:
zarzet
2026-01-12 22:22:25 +07:00
parent 4966a84614
commit 523b1edc44
9 changed files with 427 additions and 0 deletions
+38
View File
@@ -24,6 +24,7 @@ class Extension {
final bool hasDownloadProvider;
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior; // Custom search behavior
final URLHandler? urlHandler; // Custom URL handling
final TrackMatching? trackMatching; // Custom track matching
final PostProcessing? postProcessing; // Post-processing hooks
@@ -45,6 +46,7 @@ class Extension {
this.hasDownloadProvider = false,
this.skipMetadataEnrichment = false,
this.searchBehavior,
this.urlHandler,
this.trackMatching,
this.postProcessing,
});
@@ -74,6 +76,9 @@ class Extension {
searchBehavior: json['search_behavior'] != null
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
: null,
urlHandler: json['url_handler'] != null
? URLHandler.fromJson(json['url_handler'] as Map<String, dynamic>)
: null,
trackMatching: json['track_matching'] != null
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
: null,
@@ -101,6 +106,7 @@ class Extension {
bool? hasDownloadProvider,
bool? skipMetadataEnrichment,
SearchBehavior? searchBehavior,
URLHandler? urlHandler,
TrackMatching? trackMatching,
PostProcessing? postProcessing,
}) {
@@ -122,12 +128,14 @@ class Extension {
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
searchBehavior: searchBehavior ?? this.searchBehavior,
urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching,
postProcessing: postProcessing ?? this.postProcessing,
);
}
bool get hasCustomSearch => searchBehavior?.enabled ?? false;
bool get hasURLHandler => urlHandler?.enabled ?? false;
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
bool get hasPostProcessing => postProcessing?.enabled ?? false;
}
@@ -226,6 +234,36 @@ class PostProcessing {
}
}
/// URL handler configuration for custom URL patterns
class URLHandler {
final bool enabled;
final List<String> patterns;
const URLHandler({
required this.enabled,
this.patterns = const [],
});
factory URLHandler.fromJson(Map<String, dynamic> json) {
return URLHandler(
enabled: json['enabled'] as bool? ?? false,
patterns: (json['patterns'] as List<dynamic>?)?.cast<String>() ?? [],
);
}
/// Check if a URL matches any of the patterns
bool matchesURL(String url) {
if (!enabled || patterns.isEmpty) return false;
final lowerUrl = url.toLowerCase();
for (final pattern in patterns) {
if (lowerUrl.contains(pattern.toLowerCase())) {
return true;
}
}
return false;
}
}
/// A post-processing hook
class PostProcessingHook {
final String id;
+39
View File
@@ -131,6 +131,45 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// First, check if any extension can handle this URL
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
final result = await PlatformBridge.handleURLWithExtension(url);
if (!_isRequestValid(requestId)) return;
if (result != null) {
final type = result['type'] as String?;
final extensionId = result['extension_id'] as String?;
if (type == 'track' && result['track'] != null) {
final trackData = result['track'] as Map<String, dynamic>;
final track = _parseSearchTrack(trackData, source: extensionId);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
searchExtensionId: extensionId,
);
return;
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: result['album']?['id'] as String?,
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
playlistName: type == 'playlist' ? result['name'] as String? : null,
coverUrl: result['cover_url'] as String?,
searchExtensionId: extensionId,
);
return;
}
}
}
// No extension handler found, try Spotify URL parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
+34
View File
@@ -753,6 +753,40 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
// ==================== EXTENSION URL HANDLER ====================
/// Handle a URL with any matching extension
/// Returns null if no extension can handle the URL
static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async {
try {
final result = await _channel.invokeMethod('handleURLWithExtension', {
'url': url,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
// No extension found or error handling URL
return null;
}
}
/// Find an extension that can handle the given URL
/// Returns extension ID or null if none found
static Future<String?> findURLHandler(String url) async {
final result = await _channel.invokeMethod('findURLHandler', {
'url': url,
});
if (result == null || result == '') return null;
return result as String;
}
/// Get all extensions that handle custom URLs
static Future<List<Map<String, dynamic>>> getURLHandlers() async {
final result = await _channel.invokeMethod('getURLHandlers');
final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
// ==================== EXTENSION POST-PROCESSING ====================
/// Run post-processing hooks on a file