Skip to content
Merged
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
26 changes: 16 additions & 10 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,16 +337,22 @@ Each item:
- **Acceptance:** At minimum `getParameters` / `setParameters` on the sender and
a public `RtpTransceiver`; `RtpTransceiverDirection` enum introduced.

### MediaStreamTrack: missing settings/constraints surface

- **Found:** 2026-05-29, RFC/W3C divergence audit. **Unverified.**
- **Detail:** W3C Media Capture §4.3. Missing `getSettings()`,
`getCapabilities()`, `getConstraints()`, `applyConstraints()`, `muted`,
`onMute` / `onUnmute` / `onEnded`. `stop()` is fire-and-forget (no `onEnded`).
[dart/lib/media/media_stream_track.dart](dart/lib/media/media_stream_track.dart).
- **Why deferred:** Capture-side feature work.
- **Acceptance:** `getSettings()` + `onEnded` at minimum; constraint methods as
capture grows.
### MediaStreamTrack: constraints surface

- **Found:** 2026-05-29, RFC/W3C divergence audit. Partially shipped 2026-06.
- **Detail:** W3C Media Capture §4.3. `getSettings()`, `muted`,
`onMute`/`onUnmute`/`onEnded` are now implemented on
[dart/lib/media/media_stream_track.dart](dart/lib/media/media_stream_track.dart)
(capture tracks populate `MediaTrackSettings`; `stop()` fires `onEnded`).
Still missing: `getCapabilities()`, `getConstraints()`,
`applyConstraints()`.
- **Why deferred:** Constraint application means reconfiguring the capture
source mid-stream (re-opening the camera/mic at a new resolution / rate) —
a much larger, capture-backend-specific change than the read-only settings
surface already shipped.
- **Acceptance:** `applyConstraints()` reconfigures the live source (at least
resolution + frame rate for video), `getCapabilities()` reports the
device's supported ranges.

### getStats: missing `remote-outbound-rtp` and inbound-rtp fields

Expand Down
28 changes: 22 additions & 6 deletions dart/lib/media/macos/avf_capture_track.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ abstract base class _AvfCaptureTrack<TEvent> extends MediaStreamTrack {
final String _id;
final String _label;
final Duration _pollInterval;
final MediaTrackSettings _settings;
bool _enabled = true;
// Eagerly constructed so the `close_sinks` lint can trace the
// controller's lifetime through [stop]. Polling starts when a listener
Expand All @@ -30,7 +31,10 @@ abstract base class _AvfCaptureTrack<TEvent> extends MediaStreamTrack {
);
Timer? _timer;

_AvfCaptureTrack(this._id, this._label, this._pollInterval);
_AvfCaptureTrack(this._id, this._label, this._pollInterval, this._settings);

@override
MediaTrackSettings getSettings() => _settings;

/// Pop and emit a single native frame. Returns false when the queue is
/// empty so the drain loop can stop.
Expand Down Expand Up @@ -67,6 +71,7 @@ abstract base class _AvfCaptureTrack<TEvent> extends MediaStreamTrack {
_timer = null;
_disposeNativeCapture();
unawaited(_events.close());
notifyEnded();
}

void _ensureTimer() {
Expand All @@ -91,7 +96,8 @@ abstract base class _AvfCaptureTrack<TEvent> extends MediaStreamTrack {
final class AvfCaptureVideoTrack extends _AvfCaptureTrack<VideoFrame> {
final NativeVideoCapture _capture;

AvfCaptureVideoTrack._(this._capture, super.id, super.label, super.interval);
AvfCaptureVideoTrack._(
this._capture, super.id, super.label, super.interval, super.settings);

static AvfCaptureVideoTrack? create({
String? deviceId,
Expand All @@ -112,7 +118,12 @@ final class AvfCaptureVideoTrack extends _AvfCaptureTrack<VideoFrame> {
microseconds:
((1000000 / framerate) / 2).round().clamp(2000, 33000));
return AvfCaptureVideoTrack._(
cap, Csprng.randomHex(16), label, interval);
cap, Csprng.randomHex(16), label, interval,
MediaTrackSettings(
deviceId: deviceId,
width: width,
height: height,
frameRate: framerate));
}

@override
Expand Down Expand Up @@ -153,8 +164,9 @@ final class AvfCaptureVideoTrack extends _AvfCaptureTrack<VideoFrame> {
final class AvfCaptureAudioTrack extends _AvfCaptureTrack<AudioData> {
final NativeAudioCapture _capture;

AvfCaptureAudioTrack._(this._capture, String id, String label)
: super(id, label, const Duration(milliseconds: 10));
AvfCaptureAudioTrack._(
this._capture, String id, String label, MediaTrackSettings settings)
: super(id, label, const Duration(milliseconds: 10), settings);

static AvfCaptureAudioTrack? create({
String? deviceId,
Expand All @@ -169,7 +181,11 @@ final class AvfCaptureAudioTrack extends _AvfCaptureTrack<AudioData> {
cap.release();
return null;
}
return AvfCaptureAudioTrack._(cap, Csprng.randomHex(16), label);
return AvfCaptureAudioTrack._(cap, Csprng.randomHex(16), label,
MediaTrackSettings(
deviceId: deviceId,
sampleRate: sampleRate,
channelCount: channels));
}

@override
Expand Down
78 changes: 78 additions & 0 deletions dart/lib/media/media_stream_track.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,45 @@
/// https://www.w3.org/TR/mediacapture-streams/#mediastreamtrack
library;

import 'dart:async';

import 'audio_data.dart';
import 'video_frame.dart';

/// Track state per W3C spec.
enum MediaStreamTrackState { live, ended }

/// Snapshot of a track's current settings (W3C `MediaTrackSettings`).
/// Only the subset webdartc tracks populate is modelled; fields that don't
/// apply (e.g. audio fields on a video track) stay null.
final class MediaTrackSettings {
/// Source device identifier, if known.
final String? deviceId;

// Video
final int? width;
final int? height;
final double? frameRate;

// Audio
final int? sampleRate;
final int? channelCount;

const MediaTrackSettings({
this.deviceId,
this.width,
this.height,
this.frameRate,
this.sampleRate,
this.channelCount,
});
}

/// Abstract media track — subclassed for local (capture) and remote (RTP) sources.
///
/// The W3C `ended` / `mute` / `unmute` event machinery and the `muted` state
/// live here so subclasses get them for free; a subclass signals them through
/// [notifyEnded] / [setMuted] from its source.
abstract class MediaStreamTrack {
/// Unique identifier for this track.
String get id;
Expand Down Expand Up @@ -39,4 +71,50 @@ abstract class MediaStreamTrack {
/// Stream of decoded audio samples (audio tracks only).
/// Throws [UnsupportedError] on video tracks.
Stream<AudioData> get onAudioData;

// ── Settings (W3C getSettings) ────────────────────────────────────────────

/// The track's current settings (W3C `getSettings()`). The base returns an
/// empty snapshot; capture tracks override it with real device values.
MediaTrackSettings getSettings() => const MediaTrackSettings();

// ── ended / mute / unmute events ──────────────────────────────────────────

final _endedController = StreamController<void>.broadcast();
final _muteController = StreamController<void>.broadcast();
final _unmuteController = StreamController<void>.broadcast();
bool _muted = false;

/// Fires once when the track ends (W3C `ended`).
Stream<void> get onEnded => _endedController.stream;

/// Fires when the source becomes temporarily unable to provide data
/// (W3C `mute`).
Stream<void> get onMute => _muteController.stream;

/// Fires when the source resumes providing data (W3C `unmute`).
Stream<void> get onUnmute => _unmuteController.stream;

/// Whether the source is temporarily unable to provide media data (W3C
/// `muted`). Distinct from [enabled], which the application controls.
bool get muted => _muted;

/// Update [muted] and fire `mute`/`unmute`. Idempotent; a no-op once the
/// track has ended. Called by a subclass when its source signals a
/// (un)mute — not part of the public W3C surface.
void setMuted(bool value) {
if (_muted == value || _endedController.isClosed) return;
_muted = value;
(value ? _muteController : _unmuteController).add(null);
}

/// Fire `ended` once and release the event controllers. A subclass calls
/// this from [stop] (and whenever its source ends on its own).
void notifyEnded() {
if (_endedController.isClosed) return;
_endedController.add(null);
unawaited(_endedController.close());
unawaited(_muteController.close());
unawaited(_unmuteController.close());
}
}
142 changes: 142 additions & 0 deletions dart/test/media/media_stream_track_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import 'package:test/test.dart';
import 'package:webdartc/webdartc.dart';

/// Concrete track exercising the shared MediaStreamTrack machinery. A real
/// capture track is macOS-only (AVFoundation FFI), so the base behaviour is
/// covered here through a minimal in-memory implementation.
final class _TestTrack extends MediaStreamTrack {
@override
final String id;
@override
final String kind;
final MediaTrackSettings _settings;
bool _ended = false;

_TestTrack({
this.id = 'track-1',
this.kind = 'audio',
MediaTrackSettings settings = const MediaTrackSettings(),
}) : _settings = settings;

@override
String get label => 'test';

@override
bool enabled = true;

@override
MediaStreamTrackState get readyState =>
_ended ? MediaStreamTrackState.ended : MediaStreamTrackState.live;

@override
MediaTrackSettings getSettings() => _settings;

@override
MediaStreamTrack clone() => _TestTrack(id: id, kind: kind, settings: _settings);

@override
void stop() {
if (_ended) return;
_ended = true;
notifyEnded();
}

@override
Stream<VideoFrame> get onVideoFrame => throw UnsupportedError('audio');
@override
Stream<AudioData> get onAudioData => const Stream<AudioData>.empty();
}

void main() {
group('MediaStreamTrack — ended', () {
test('onEnded fires once when the track is stopped', () async {
final track = _TestTrack();
var endedCount = 0;
track.onEnded.listen((_) => endedCount++);

expect(track.readyState, MediaStreamTrackState.live);
track.stop();
expect(track.readyState, MediaStreamTrackState.ended);

await Future<void>.delayed(Duration.zero);
expect(endedCount, 1);
});

test('stop is idempotent — onEnded fires only once', () async {
final track = _TestTrack();
var endedCount = 0;
track.onEnded.listen((_) => endedCount++);

track.stop();
track.stop();
track.stop();

await Future<void>.delayed(Duration.zero);
expect(endedCount, 1);
});
});

group('MediaStreamTrack — muted / mute / unmute', () {
test('setMuted toggles muted and fires mute/unmute', () async {
final track = _TestTrack();
final events = <String>[];
track.onMute.listen((_) => events.add('mute'));
track.onUnmute.listen((_) => events.add('unmute'));

expect(track.muted, isFalse);
track.setMuted(true);
expect(track.muted, isTrue);
track.setMuted(false);
expect(track.muted, isFalse);

await Future<void>.delayed(Duration.zero);
expect(events, ['mute', 'unmute']);
});

test('setMuted to the same value is a no-op', () async {
final track = _TestTrack();
final events = <String>[];
track.onMute.listen((_) => events.add('mute'));

track.setMuted(true);
track.setMuted(true); // no second event
await Future<void>.delayed(Duration.zero);
expect(events, ['mute']);
});

test('setMuted after end is ignored', () async {
final track = _TestTrack();
track.stop();
expect(() => track.setMuted(true), returnsNormally);
expect(track.muted, isFalse);
});

test('muted is independent of enabled', () {
final track = _TestTrack();
track.enabled = false;
expect(track.muted, isFalse); // app-controlled enabled ≠ source muted
});
});

group('MediaStreamTrack — getSettings', () {
test('base track returns an empty settings snapshot', () {
final s = _TestTrack().getSettings();
expect(s.width, isNull);
expect(s.sampleRate, isNull);
});

test('a track surfaces its configured settings', () {
final video = _TestTrack(
kind: 'video',
settings: const MediaTrackSettings(
deviceId: 'cam0', width: 1280, height: 720, frameRate: 30),
);
final s = video.getSettings();
expect(s.deviceId, 'cam0');
expect(s.width, 1280);
expect(s.height, 720);
expect(s.frameRate, 30);
expect(s.channelCount, isNull); // audio field unset on a video track
});
});
}
Loading