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
5 changes: 3 additions & 2 deletions Sources/Panels/BrowserMediaPlaybackMessageHandler.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation
import WebKit

/// Receives `{ frameID, playing }` from the injected media-playback hook and
/// Receives `{ frameID, playing, audible }` from the injected media-playback hook and
/// forwards it to the owning ``BrowserPanel`` on the main actor.
///
/// Mirrors ``ReactGrabMessageHandler``: a thin `NSObject` adapter so the panel
Expand All @@ -20,7 +20,8 @@ final class BrowserMediaPlaybackMessageHandler: NSObject, WKScriptMessageHandler
guard let body = message.body as? [String: Any],
let frameID = body["frameID"] as? String,
let playing = body["playing"] as? Bool else { return }
let report = BrowserMediaPlaybackReport(frameID: frameID, isPlaying: playing)
let audible = body["audible"] as? Bool ?? false
let report = BrowserMediaPlaybackReport(frameID: frameID, isPlaying: playing, isAudible: audible)
// WebKit delivers script messages on the main thread. Apply the report
// synchronously instead of hopping through a `Task` so it lands in
// WebKit's delivery order relative to navigation callbacks: a report
Expand Down
2 changes: 2 additions & 0 deletions Sources/Panels/BrowserMediaPlaybackReport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ struct BrowserMediaPlaybackReport: Sendable {
let frameID: String
/// Whether that frame currently has any actively-playing media.
let isPlaying: Bool
/// Whether that frame currently has an unmuted, non-zero-volume audio source.
let isAudible: Bool
}
111 changes: 89 additions & 22 deletions Sources/Panels/BrowserPanel+MediaPlayback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ extension BrowserPanel {
static let mediaPlaybackContentWorld = WKContentWorld.world(name: mediaPlaybackMessageHandlerName)

/// Injected document-start hook that reports whether the current frame has
/// any actively-playing `<video>`/`<audio>` element.
/// actively-playing and audible `<video>`/`<audio>` elements.
///
/// Runs in every frame (main frame and cross-origin iframes) so an embedded
/// player (a news site embedding a YouTube/Vimeo/Twitch iframe, etc.) keeps
Expand All @@ -26,9 +26,10 @@ extension BrowserPanel {
/// (https://github.com/manaflow-ai/cmux/issues/5409).
///
/// Reports only on change (debounced via `lastReported`) and on `pagehide`.
/// Uses `paused`/`ended`, so playback that the user has muted still counts as
/// "playing media" and can render the muted activity glyph. Uses only public
/// DOM APIs, so it is stable across WebKit/macOS versions.
/// The broad `playing` state uses `paused`/`ended`, so muted playback still
/// keeps a hidden pane alive. The narrower `audible` state additionally
/// requires an unmuted element with non-zero volume and a detectable audio
/// source, so the speaker glyph is not shown for muted or video-only media.
///
/// The script is purely passive (capture-phase listeners only; no console,
/// prototype, or enumerable-global tampering) so it does not trip the
Expand All @@ -50,7 +51,8 @@ extension BrowserPanel {
return Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
})();

let lastReported = null;
let lastReported = { playing: null, audible: null };
let lastElementState = new WeakMap();
let mediaObserver = null;

const isElementPlaying = (el) => {
Expand All @@ -61,21 +63,62 @@ extension BrowserPanel {
}
};

const anyPlaying = () => {
const hasAudioSource = (el) => {
try {
const tagName = (el.tagName || "").toLowerCase();
if (tagName === "audio") return true;
const tracks = el.audioTracks;
if (tracks && typeof tracks.length === "number") {
let sawEnabledState = false;
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (!track || typeof track.enabled !== "boolean") continue;
sawEnabledState = true;
if (track.enabled) return true;
}
if (sawEnabledState) return false;
}
if (typeof el.webkitAudioDecodedByteCount === "number" && el.webkitAudioDecodedByteCount > 0) {
return true;
}
} catch (_) {}
return false;
};

const isElementAudible = (el) => {
try {
return isElementPlaying(el)
&& !el.muted
&& el.volume > 0
&& hasAudioSource(el);
} catch (_) {
return false;
}
};

const currentPlaybackState = () => {
const state = { playing: false, audible: false };
try {
const media = document.querySelectorAll("video, audio");
for (let i = 0; i < media.length; i++) {
if (isElementPlaying(media[i])) return true;
const el = media[i];
if (!isElementPlaying(el)) continue;
state.playing = true;
if (isElementAudible(el)) {
state.audible = true;
break;
}
}
} catch (_) {}
return false;
return state;
};

const post = (playing) => {
const post = (playing, audible) => {
try {
window.webkit.messageHandlers["\(mediaPlaybackMessageHandlerName)"].postMessage({
frameID: frameID,
playing: playing
playing: playing,
audible: audible
});
} catch (_) {}
};
Expand Down Expand Up @@ -132,28 +175,51 @@ extension BrowserPanel {
}

function report() {
const playing = anyPlaying();
syncObserver(playing);
if (playing === lastReported) return;
lastReported = playing;
post(playing);
const state = currentPlaybackState();
syncObserver(state.playing);
if (state.playing === lastReported.playing && state.audible === lastReported.audible) return;
lastReported.playing = state.playing;
lastReported.audible = state.audible;
post(state.playing, state.audible);
}

function reportIfTargetStateChanged(event) {
try {
const el = event && event.target;
if (!el || !el.matches || !el.matches("video, audio")) {
report();
return;
}
const next = {
playing: isElementPlaying(el),
audible: isElementAudible(el)
};
const previous = lastElementState.get(el);
if (previous && previous.playing === next.playing && previous.audible === next.audible) return;
lastElementState.set(el, next);
report();
} catch (_) {
report();
}
}

// Media events do not bubble, but capture-phase listeners on `document`
// still observe them as the event travels down to the target element.
const events = [
"play", "playing", "pause", "ended", "emptied",
"waiting", "stalled", "suspend", "abort", "loadeddata"
"waiting", "stalled", "suspend", "abort", "loadeddata",
"volumechange", "timeupdate"
];
for (let i = 0; i < events.length; i++) {
document.addEventListener(events[i], report, true);
document.addEventListener(events[i], reportIfTargetStateChanged, true);
}

window.addEventListener("pagehide", () => {
disconnectObserver();
if (lastReported === false) return;
lastReported = false;
post(false);
if (lastReported.playing === false && lastReported.audible === false) return;
lastReported.playing = false;
lastReported.audible = false;
post(false, false);
}, true);

document.addEventListener("DOMContentLoaded", report, true);
Expand Down Expand Up @@ -194,12 +260,13 @@ extension BrowserPanel {
fromWebViewInstanceID instanceID: UUID
) {
guard instanceID == webViewInstanceID else { return }
applyMediaPlaybackReport(frameID: report.frameID, isPlaying: report.isPlaying)
applyMediaPlaybackReport(frameID: report.frameID, isPlaying: report.isPlaying, isAudible: report.isAudible)
#if DEBUG
cmuxDebugLog(
"browser.media.playback panel=\(id.uuidString.prefix(5)) " +
"frame=\(report.frameID.prefix(5)) playing=\(report.isPlaying ? 1 : 0) " +
"anyPlaying=\(isPlayingMedia ? 1 : 0)"
"audible=\(report.isAudible ? 1 : 0) anyPlaying=\(isPlayingMedia ? 1 : 0) " +
"anyAudible=\(isPlayingAudio ? 1 : 0)"
)
#endif
}
Expand Down
28 changes: 14 additions & 14 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3067,7 +3067,7 @@ final class BrowserPanel: Panel, ObservableObject {
private(set) var isPlayingMedia: Bool = false {
didSet {
guard oldValue != isPlayingMedia else { return }
setMediaActivity(isPlayingAudio: isPlayingMedia, reason: "media_playback_changed")
reevaluateHiddenWebViewDiscardScheduling(reason: "media_playback_changed")
}
}
/// Live media activity. ``Workspace`` publishes it to tab/sidebar surfaces.
Expand All @@ -3076,9 +3076,9 @@ final class BrowserPanel: Panel, ObservableObject {
var isUsingMicrophone: Bool { mediaActivity.isUsingMicrophone }
var isUsingCamera: Bool { mediaActivity.isUsingCamera }
var onMediaActivityChanged: ((BrowserMediaActivity) -> Void)?
/// Document ids of the frames currently reporting playing media. The pane is
/// kept alive while this is non-empty.
/// Frame ids reporting playing media; keeps hidden panes alive while non-empty.
private var playingMediaFrameIDs: Set<String> = []
private var audibleMediaFrameIDs: Set<String> = []
var mediaPlaybackMessageHandler: BrowserMediaPlaybackMessageHandler?

private func setMediaActivity(
Expand All @@ -3097,23 +3097,22 @@ final class BrowserPanel: Panel, ObservableObject {
reevaluateHiddenWebViewDiscardScheduling(reason: reason)
}

/// Folds a per-frame playback report into ``isPlayingMedia``. Lives here so
/// the `private(set)` setter stays confined to this file.
func applyMediaPlaybackReport(frameID: String, isPlaying: Bool) {
if isPlaying {
playingMediaFrameIDs.insert(frameID)
} else {
playingMediaFrameIDs.remove(frameID)
}
/// Folds a per-frame playback report into retention and audio-glyph state.
func applyMediaPlaybackReport(frameID: String, isPlaying: Bool, isAudible: Bool) {
if isPlaying { playingMediaFrameIDs.insert(frameID) } else { playingMediaFrameIDs.remove(frameID) }
if isPlaying && isAudible { audibleMediaFrameIDs.insert(frameID) } else { audibleMediaFrameIDs.remove(frameID) }
isPlayingMedia = !playingMediaFrameIDs.isEmpty
refreshAudioMediaActivity(reason: "media_audibility_changed")
}

/// Clears all tracked playing frames (new webview bind or main-frame
/// navigation, where the prior frame hooks are gone).
/// Clears tracked frames after a webview bind or main-frame navigation.
func resetMediaPlaybackTracking() {
playingMediaFrameIDs.removeAll()
(playingMediaFrameIDs, audibleMediaFrameIDs) = ([], [])
isPlayingMedia = false
refreshAudioMediaActivity(reason: "media_playback_reset")
}

private func refreshAudioMediaActivity(reason: String) { setMediaActivity(isPlayingAudio: !audibleMediaFrameIDs.isEmpty && !isMuted, reason: reason) }
var pendingReactGrabReturnTargetPanelId: UUID?
var pendingReactGrabRoundTripToken: String?
let reactGrabBridgeSessionUpdaterName = "__cmuxReactGrabBridgeSync_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))"
Expand Down Expand Up @@ -6081,6 +6080,7 @@ extension BrowserPanel {
let applied = applyMuteState(muted, to: webView, reason: "setMuted")
if applied, isMuted != muted {
isMuted = muted
refreshAudioMediaActivity(reason: "audio_mute_changed")
}
return applied
}
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; };
BCBC0A0E0000000000000F01 /* BrowserMediaActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */; };
BCBC0A0E0000000000000E11 /* BrowserMediaActivityAggregationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000E12 /* BrowserMediaActivityAggregationTests.swift */; };
BCBC0A0E0000000000000E21 /* BrowserMediaPlaybackAudioActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000E22 /* BrowserMediaPlaybackAudioActivityTests.swift */; };
A500MH01 /* BrowserMediaPlaybackMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500MH00 /* BrowserMediaPlaybackMessageHandler.swift */; };
A500MR01 /* BrowserMediaPlaybackReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500MR00 /* BrowserMediaPlaybackReport.swift */; };
B0A501000000000000000001 /* BrowserOmnibarAppKitBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A501000000000000000002 /* BrowserOmnibarAppKitBridge.swift */; };
Expand Down Expand Up @@ -1214,6 +1215,7 @@
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; };
BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserMediaActivity.swift; sourceTree = "<group>"; };
BCBC0A0E0000000000000E12 /* BrowserMediaActivityAggregationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserMediaActivityAggregationTests.swift; sourceTree = "<group>"; };
BCBC0A0E0000000000000E22 /* BrowserMediaPlaybackAudioActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserMediaPlaybackAudioActivityTests.swift; sourceTree = "<group>"; };
A500MH00 /* BrowserMediaPlaybackMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserMediaPlaybackMessageHandler.swift; sourceTree = "<group>"; };
A500MR00 /* BrowserMediaPlaybackReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserMediaPlaybackReport.swift; sourceTree = "<group>"; };
B0A501000000000000000002 /* BrowserOmnibarAppKitBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserOmnibarAppKitBridge.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2975,6 +2977,7 @@
604100010000000000000002 /* GhosttyDrawableSizeRetryTests.swift */,
BCBC0A0E0000000000000D02 /* BrowserChromeMetricsTests.swift */,
BCBC0A0E0000000000000E12 /* BrowserMediaActivityAggregationTests.swift */,
BCBC0A0E0000000000000E22 /* BrowserMediaPlaybackAudioActivityTests.swift */,
A11EB0000000000000000001 /* GhosttyConfigPathResolverTests.swift */,
C13519000000000000000004 /* GhosttyTerminalStartupEnvironmentTests.swift */,
C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */,
Expand Down Expand Up @@ -4393,6 +4396,7 @@
4E1F28554F1B908F18559390 /* BrowserHistorySuggestionCacheTests.swift in Sources */,
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
BCBC0A0E0000000000000E11 /* BrowserMediaActivityAggregationTests.swift in Sources */,
BCBC0A0E0000000000000E21 /* BrowserMediaPlaybackAudioActivityTests.swift in Sources */,
C2B6A97D1F2E4C71A8B9D001 /* BrowserOmnibarPerformanceSupportTests.swift in Sources */,
D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */,
1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */,
Expand Down
37 changes: 37 additions & 0 deletions cmuxTests/BrowserMediaPlaybackAudioActivityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import Testing

#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif

@MainActor
@Suite(.serialized)
struct BrowserMediaPlaybackAudioActivityTests {
@Test func activeSilentMediaPlaybackBlocksDiscardWithoutAudioGlyph() {
let panel = BrowserPanel(workspaceId: UUID(), renderInitialNavigation: false)
defer { panel.close() }

panel.applyMediaPlaybackReport(frameID: "main", isPlaying: true, isAudible: false)

#expect(panel.isPlayingMedia)
#expect(panel.isPlayingAudio == false)
}

@Test func audibleMediaPlaybackDrivesAudioGlyphIndependentlyOfDiscardBlocker() {
let panel = BrowserPanel(workspaceId: UUID(), renderInitialNavigation: false)
defer { panel.close() }

panel.applyMediaPlaybackReport(frameID: "main", isPlaying: true, isAudible: true)

#expect(panel.isPlayingMedia)
#expect(panel.isPlayingAudio)

panel.applyMediaPlaybackReport(frameID: "main", isPlaying: true, isAudible: false)

#expect(panel.isPlayingMedia)
#expect(panel.isPlayingAudio == false)
}
}
Loading