diff --git a/CHANGELOG.md b/CHANGELOG.md index 3beb9051..77bd754b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 2.1.0 (#unreleased) - Feature [#468](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/468) - Add macOS support +- Feature [#54](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/54) - Support waveform extraction from remote URLs on iOS and macOS ## 2.0.2 diff --git a/doc/documentation.md b/doc/documentation.md index a130d796..44725ab3 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -487,8 +487,18 @@ await playerController.preparePlayer( ### Loading from Network -Currently, playing remote audio files directly isn't supported. You will need to download the file -first, then play it locally. +Remote audio files can be loaded directly by passing an `http`/`https` URL as the `path`: + +```dart +await playerController.preparePlayer( + path: 'https://example.com/audio.mp3', + shouldExtractWaveform: true, +); +``` + +Playback streams the URL on all platforms. Waveform extraction also works from a +remote URL: on iOS/macOS the file is downloaded to a temporary file before +decoding, while Android decodes the network stream directly. ## Start Playing diff --git a/lib/src/base/utils.dart b/lib/src/base/utils.dart index 6073b289..23777701 100644 --- a/lib/src/base/utils.dart +++ b/lib/src/base/utils.dart @@ -204,3 +204,48 @@ enum WaveformRenderMode { /// Checks if the current platform is iOS or macOS. bool get isIosOrMacOS => Platform.isIOS || Platform.isMacOS; + +/// Downloads a remote audio [url] to a temporary file and returns its path. +/// +/// Waveform extraction on iOS/macOS uses `AVAudioFile`, which can only read +/// local files, so a remote URL must be downloaded before it can be decoded. +/// The original file extension is preserved so the native decoder can infer +/// the audio format. The caller is responsible for deleting the returned file +/// once extraction is finished. +Future downloadAudioToTempFile(String url) async { + final uri = Uri.parse(url); + final httpClient = HttpClient(); + try { + final request = await httpClient.getUrl(uri); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + throw HttpException( + 'Failed to download audio for waveform extraction ' + '(status ${response.statusCode}).', + uri: uri, + ); + } + final name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ''; + final extension = name.contains('.') ? '.${name.split('.').last}' : ''; + final tempPath = '${Directory.systemTemp.path}/audio_waveforms_' + '${DateTime.now().microsecondsSinceEpoch}$extension'; + final file = File(tempPath); + try { + await response.pipe(file.openWrite()); + } catch (_) { + // Remove the partially written file so a failed download doesn't leave + // an orphan in the temp directory, then rethrow. + if (file.existsSync()) { + try { + await file.delete(); + } catch (_) { + // Best-effort cleanup; ignore failures. + } + } + rethrow; + } + return file.path; + } finally { + httpClient.close(); + } +} diff --git a/lib/src/controllers/player_controller.dart b/lib/src/controllers/player_controller.dart index 08c4b0c6..6daa1317 100644 --- a/lib/src/controllers/player_controller.dart +++ b/lib/src/controllers/player_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; diff --git a/lib/src/controllers/waveform_extraction_controller.dart b/lib/src/controllers/waveform_extraction_controller.dart index d5f755bd..cc4e2772 100644 --- a/lib/src/controllers/waveform_extraction_controller.dart +++ b/lib/src/controllers/waveform_extraction_controller.dart @@ -99,11 +99,35 @@ class WaveformExtractionController { actualNoOfSamples = noOfSamples ?? 100; } - return await AudioWaveformsInterface.instance.extractWaveformData( - key: _extractorKey, - path: path, - noOfSamples: actualNoOfSamples, - ); + // Waveform extraction on iOS/macOS uses AVAudioFile, which can only read + // local files. Remote URLs are downloaded to a temporary file first and + // cleaned up afterwards. Android's MediaExtractor handles network URIs + // directly, so the path is passed through unchanged there. + var effectivePath = path; + File? tempFile; + final uri = Uri.tryParse(path); + final isRemote = + uri != null && (uri.isScheme('http') || uri.isScheme('https')); + if (isIosOrMacOS && isRemote) { + effectivePath = await downloadAudioToTempFile(path); + tempFile = File(effectivePath); + } + + try { + return await AudioWaveformsInterface.instance.extractWaveformData( + key: _extractorKey, + path: effectivePath, + noOfSamples: actualNoOfSamples, + ); + } finally { + if (tempFile != null && tempFile.existsSync()) { + try { + await tempFile.delete(); + } catch (_) { + // Best-effort cleanup; ignore failures. + } + } + } } /// Stops current waveform extraction, if any.