diff --git a/BACKLOG.md b/BACKLOG.md index 93c62f3..ef540f1 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/dart/lib/media/macos/avf_capture_track.dart b/dart/lib/media/macos/avf_capture_track.dart index 9f5bece..5b0d5f5 100644 --- a/dart/lib/media/macos/avf_capture_track.dart +++ b/dart/lib/media/macos/avf_capture_track.dart @@ -19,6 +19,7 @@ abstract base class _AvfCaptureTrack 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 @@ -30,7 +31,10 @@ abstract base class _AvfCaptureTrack 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. @@ -67,6 +71,7 @@ abstract base class _AvfCaptureTrack extends MediaStreamTrack { _timer = null; _disposeNativeCapture(); unawaited(_events.close()); + notifyEnded(); } void _ensureTimer() { @@ -91,7 +96,8 @@ abstract base class _AvfCaptureTrack extends MediaStreamTrack { final class AvfCaptureVideoTrack extends _AvfCaptureTrack { 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, @@ -112,7 +118,12 @@ final class AvfCaptureVideoTrack extends _AvfCaptureTrack { 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 @@ -153,8 +164,9 @@ final class AvfCaptureVideoTrack extends _AvfCaptureTrack { final class AvfCaptureAudioTrack extends _AvfCaptureTrack { 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, @@ -169,7 +181,11 @@ final class AvfCaptureAudioTrack extends _AvfCaptureTrack { 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 diff --git a/dart/lib/media/media_stream_track.dart b/dart/lib/media/media_stream_track.dart index afce395..9891e7a 100644 --- a/dart/lib/media/media_stream_track.dart +++ b/dart/lib/media/media_stream_track.dart @@ -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; @@ -39,4 +71,50 @@ abstract class MediaStreamTrack { /// Stream of decoded audio samples (audio tracks only). /// Throws [UnsupportedError] on video tracks. Stream 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.broadcast(); + final _muteController = StreamController.broadcast(); + final _unmuteController = StreamController.broadcast(); + bool _muted = false; + + /// Fires once when the track ends (W3C `ended`). + Stream get onEnded => _endedController.stream; + + /// Fires when the source becomes temporarily unable to provide data + /// (W3C `mute`). + Stream get onMute => _muteController.stream; + + /// Fires when the source resumes providing data (W3C `unmute`). + Stream 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()); + } } diff --git a/dart/test/media/media_stream_track_test.dart b/dart/test/media/media_stream_track_test.dart new file mode 100644 index 0000000..721c4e0 --- /dev/null +++ b/dart/test/media/media_stream_track_test.dart @@ -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 get onVideoFrame => throw UnsupportedError('audio'); + @override + Stream get onAudioData => const Stream.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.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.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 = []; + 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.delayed(Duration.zero); + expect(events, ['mute', 'unmute']); + }); + + test('setMuted to the same value is a no-op', () async { + final track = _TestTrack(); + final events = []; + track.onMute.listen((_) => events.add('mute')); + + track.setMuted(true); + track.setMuted(true); // no second event + await Future.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 + }); + }); +}