Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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) ([#502](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/502)) - Support waveform extraction from remote URLs on iOS and macOS

## 2.0.2

Expand Down
25 changes: 23 additions & 2 deletions doc/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,29 @@ 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.
**Android**: remote audio files can be played directly by passing an `http`/`https` URL as the `path`:

```dart
await playerController.preparePlayer(
path: 'https://example.com/audio.mp3',
shouldExtractWaveform: true,
);
```

**iOS/macOS**: streaming a remote URL for playback isn't supported (`AVAudioPlayer` can't open
`http(s)` URLs), so `preparePlayer` fails for a URL. Download the file first and play it locally.
You can still extract a waveform straight from a URL with `WaveformExtractionController`:

```dart
final waveformData = await WaveformExtractionController().extractWaveformData(
path: 'https://example.com/audio.mp3',
noOfSamples: 100,
);
```

On iOS/macOS the file is downloaded to a temporary file before decoding and deleted afterwards. The
download fails if the server doesn't respond for 30 seconds, and calling `stopWaveformExtraction()`
aborts it. Android decodes the network stream directly.

## Start Playing

Expand Down
70 changes: 70 additions & 0 deletions lib/src/base/utils.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';

import 'player_identifier.dart';
Expand Down Expand Up @@ -204,3 +205,72 @@ enum WaveformRenderMode {

/// Checks if the current platform is iOS or macOS.
bool get isIosOrMacOS => Platform.isIOS || Platform.isMacOS;

const Duration _downloadTimeout = Duration(seconds: 30);

/// 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.
///
/// A stalled server cannot keep this pending forever: connecting, receiving
/// the response headers and each chunk of the body time out after
/// [_downloadTimeout]. Pass a [client] to be able to abort the download with
/// `client.close(force: true)`.
Future<String> downloadAudioToTempFile(String url, {HttpClient? client}) async {
final uri = Uri.parse(url);
final httpClient = client ?? HttpClient();
httpClient.connectionTimeout = _downloadTimeout;
try {
final request = await httpClient.getUrl(uri).timeout(_downloadTimeout);
final response = await request.close().timeout(_downloadTimeout);
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 {
// Per-chunk idle timeout: a download that stops sending data fails
// instead of hanging, while a slow but steady one is allowed to finish.
await response
.timeout(
_downloadTimeout,
onTimeout: (sink) {
sink
..addError(
TimeoutException(
'Timed out while downloading audio for waveform extraction.',
_downloadTimeout,
),
)
..close();
},
)
.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();
}
}
1 change: 1 addition & 0 deletions lib/src/controllers/player_controller.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
Comment thread
vasu-nageshri-simform marked this conversation as resolved.
Resolved

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
Expand Down
60 changes: 55 additions & 5 deletions lib/src/controllers/waveform_extraction_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ class WaveformExtractionController {

final String _extractorKey;

/// Client of the in-progress remote download (iOS/macOS), so
/// [stopWaveformExtraction] can abort it.
HttpClient? _downloadClient;

/// Set by [stopWaveformExtraction] and reset when a new extraction starts.
/// Checked after a download so a stop during it isn't silently ignored.
bool _isStopRequested = false;

final List<double> _waveformData = [];

/// This returns waveform data which can be used by [AudioFileWaveforms]
Expand Down Expand Up @@ -99,16 +107,58 @@ 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.
_isStopRequested = false;
var effectivePath = path;
File? tempFile;
final uri = Uri.tryParse(path);
final isRemote =
uri != null && (uri.isScheme('http') || uri.isScheme('https'));
if (isIosOrMacOS && isRemote) {
final client = HttpClient();
_downloadClient = client;
try {
effectivePath = await downloadAudioToTempFile(path, client: client);
tempFile = File(effectivePath);
} catch (_) {
// The download was aborted by stopWaveformExtraction().
if (_isStopRequested) throw StateError(_stoppedMessage);
rethrow;
} finally {
_downloadClient = null;
}
}

try {
// Nothing is extracting natively while downloading, so a stop during the
// download only reaches us through this flag.
if (_isStopRequested) throw StateError(_stoppedMessage);
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.
Future<void> stopWaveformExtraction() async {
_isStopRequested = true;
_downloadClient?.close(force: true);
return await AudioWaveformsInterface.instance
.stopWaveformExtraction(_extractorKey);
}
}

const String _stoppedMessage = 'Waveform extraction was stopped.';