130 Widgets

Building tools. Learning to build tools. Learning to build learning tools.

6. Video Feeds, Calling Screens & Native Theme Control

Debugging the video feed bug, discovering multiple FluentProviders, and the exhaustive search for a way to switch Teams’ native theme programmatically.

CDP Arch

The Bug: Theme Blocks Video Feeds

Theme injection worked perfectly in chat, channels, search — every view in Teams except one. Join a call, and the video feeds disappear. Other participants’ cameras show a solid dark rectangle. Your own self-view is gone. The theme CSS was painting opaque backgrounds over the video elements.

This was the most user-visible bug in v2’s early development. A theme that breaks video calls is worse than no theme at all. Understanding why it happened required digging into how Teams structures its calling UI — and discovering that the calling screen is fundamentally different from the chat screen.

Discovery: Multiple FluentProvider Instances

In the chat view, Teams has a single .fui-FluentProvider wrapping most of the UI. During calls, the DOM gains additional FluentProvider instances. The observe-calling.js tool revealed at least five during an active call:

// From observe-calling.js — finding all FluentProviders during a call
const providersResult = await Runtime.evaluate({
  expression: `
    (() => {
      const providers = [...document.querySelectorAll('.fui-FluentProvider')];
      return JSON.stringify(providers.map(p => ({
        dataTid: p.getAttribute('data-tid') || '',
        classes: p.className.substring(0, 100),
        tokenCount: [...getComputedStyle(p)]
          .filter(prop => prop.startsWith('--')).length,
        bounds: (() => {
          const r = p.getBoundingClientRect();
          return { w: Math.round(r.width), h: Math.round(r.height) };
        })(),
      })));
    })()
  `,
  returnByValue: true,
});

The key discovery: one FluentProvider carried data-tid="calling-screen-background". This element was positioned at z-index: -1 with position: fixed, covering the entire viewport. It served as the backdrop behind the video grid. By setting its background to a dark color, the theme CSS was painting an opaque layer that hid the video elements underneath.

Another provider with a meeting-branding identifier appeared for meetings with custom branding enabled — separate from both the main chat provider and the calling background provider.

The Initial Wrong Fix

The first attempt was to exclude calling-related FluentProviders from the token overrides:

/* ❌ Initial attempt — exclude calling providers */
.fui-FluentProvider:not([data-tid*="calling"]):not([data-tid*="meeting"]) {
  --colorNeutralBackground1: #21252b !important;
  /* ... 400 more tokens */
}

This stopped blocking the video feeds, but introduced a new problem: the calling screen UI (buttons, participant names, controls) remained in the default Teams light or dark theme. The call screen looked like a completely different app from the rest of Teams. Half-themed was arguably worse than the original bug.

The html Class Discovery

Inspecting the <html> element during calls revealed something interesting. In normal chat, the class might be theme-darkV2. During calls, it changes to theme-defaultV2 — regardless of whether Teams is in light or dark mode. The “V2” suffix indicates a Fluent UI v9 rendering context.

This is significant because the html/body background rule from Section 5 (html { background-color: #21252b; }) was forcing an opaque background even during calls. The solution: make the rule conditional on the html class:

/* Normal views — set opaque background */
html:not([class*="V2"]) {
  background-color: #21252b !important;
}
html:not([class*="V2"]) body {
  background-color: #21252b !important;
}

/* During calls — transparent for video feeds */
html[class*="V2"], html[class*="V2"] body {
  background-color: transparent !important;
}

The [class*="V2"] selector catches all calling-related class names (theme-darkV2, theme-defaultV2, etc.) and forces transparency. In normal chat views where the html class doesn’t contain “V2”, the opaque background prevents the WebView2 window color from bleeding through.

388 Tokens Differ — Same Names, Different Values

The observe-calling.js script compared tokens from the main FluentProvider against the calling screen FluentProvider. The result: 388 tokens had different values. But the token names were identical — --colorNeutralBackground1 existed in both providers, just with different hex values.

The calling provider’s tokens were light-mode values. Even when Teams is in dark mode, the calling screen provider uses the light palette. This explains why naively excluding it from overrides produced a light-themed call screen.

Why Light Mode for Calls?

Teams uses light-mode tokens for the calling screen because the UI overlays on top of video content (which can be any brightness). Light-colored controls with shadows provide better visibility against arbitrary video backgrounds than dark controls. This is a deliberate design decision, not a bug in Teams — but it causes problems for third-party theming because now we have to override the same token names in two different contexts.

The Correct Fix: Override All Providers

The solution that emerged from this analysis:

  1. Apply token overrides to all .fui-FluentProvider instances — including the calling screen provider. No exclusions.
  2. Keep html/body transparent during calls so video feeds show through the gaps between UI elements.
  3. Set the [data-tid="calling-screen-background"] element’s background explicitly to the theme’s deepest color.
/* Apply tokens to ALL FluentProviders — including calling */
.fui-FluentProvider {
  --colorNeutralBackground1: #21252b !important;
  /* ... all 400+ tokens */
}

/* Calling screen backdrop — explicit bg */
[data-tid="calling-screen-background"] {
  background-color: #1b1f23 !important;
}

/* html/body: opaque normally, transparent during calls */
html:not([class*="V2"]) {
  background-color: #21252b !important;
}
html[class*="V2"], html[class*="V2"] body {
  background-color: transparent !important;
}

Video feeds work because <video> and <canvas> elements render their content independent of CSS background tokens. They don’t use --colorNeutralBackground1 — they draw pixels directly. The issue was never the tokens blocking video; it was the opaque html/body background painting over the transparent regions where video feeds are visible.

New Target Discovery: setTimeout Scanning

Teams opens new windows for pop-out chats, calls, and meeting stages. Each window is a new WebView2 instance with its own CDP target. If the injector only connects to the initial target, new windows won’t be themed.

The injector solves this with a polling loop that scans for new CDP targets every 3 seconds:

// From injector.js — continuous target scanning
const TARGET_SCAN_INTERVAL_MS = 3000;

const scanForNewTargets = async () => {
  const targets = await findTargets("Teams");
  for (const target of targets) {
    if (connectedTargetIds.has(target.id)) continue;

    const client = await CDP({ port: target._port, target });
    const { Runtime, Page } = client;
    await Page.enable();

    // Inject theme immediately into new target
    await Runtime.evaluate({ expression });
    console.log(`  New target injected: ${target.title}`);

    connectedTargetIds.add(target.id);
    clients.push({ client, Runtime, Page, target });

    // Re-inject on navigation
    Page.on("frameNavigated", async (params) => {
      if (!params.frame.parentId) {
        await Runtime.evaluate({ expression });
      }
    });
  }
};

setInterval(scanForNewTargets, TARGET_SCAN_INTERVAL_MS);

The scan uses a Set of connected target IDs to avoid duplicate connections. When a new target appears, it immediately injects the current theme CSS, registers a navigation handler for re-injection after page transitions, and adds a disconnect listener to clean up if the window closes.

Native Theme Detection: The Reliability Problem

Some themes require Teams to be in a specific native mode. Dimmed themes (Section 3) need light mode as input. Ported VS Code themes (Section 4) work best when Teams is in dark mode, to avoid a white flash on new windows before the theme injects.

The obvious detection approach — reading localStorage.getItem(’tmp.desktopTheme’) — is unreliable. The value can be stale, missing, or not reflect the actual visual state after a theme change. The reliable method: temporarily remove the injected style and read the actual computed background color:

// From injector.js — reliable mode detection
async function checkMode(Runtime) {
  const result = await Runtime.evaluate({
    expression: `
      (() => {
        // Temporarily remove injected theme
        const injected = document.getElementById('custom-teams-theme');
        if (injected) injected.remove();

        // Force style recalc
        document.body.offsetHeight;

        // Read the actual background
        const p = document.querySelector('.fui-FluentProvider');
        const bg = getComputedStyle(p)
          .getPropertyValue('--colorNeutralBackground1').trim();

        // Parse and check brightness
        const d = document.createElement('div');
        d.style.color = bg;
        document.body.appendChild(d);
        const rgb = getComputedStyle(d).color.match(/\\d+/g);
        document.body.removeChild(d);

        const brightness = (0.299*rgb[0] + 0.587*rgb[1] + 0.114*rgb[2]) / 255;
        return brightness > 0.5 ? 'light' : 'dark';
      })()
    `,
    returnByValue: true,
  });
  return result.result.value;
}

This is destructive (it removes the theme briefly) but accurate. The theme is re-injected immediately afterward, so the visual flicker is imperceptible in practice.

The Theme Switching Saga

Detecting the native mode is useful, but the real goal is to switch it programmatically. If a user selects a dimmed theme but Teams is in dark mode, the injector should switch Teams to light mode automatically. This turned out to be far harder than expected.

Here’s every approach that was tried and why each failed:

Approach Result Why It Failed
localStorage.setItem(’tmp.desktopTheme’, ’dark’) No effect Teams reads but doesn’t watch this key at runtime
Emulation.setEmulatedMedia Changes matchMedia Teams doesn’t listen to prefers-color-scheme changes
Emulation.setAutoDarkModeOverride No visible effect Teams doesn’t use the browser’s auto-dark feature
Edit app_settings.json Reverted on restart Cloud sync overwrites local settings on startup
Edit cloud_settings.json Reverted on restart File is downloaded fresh from Microsoft’s servers
chrome.webview.postMessage interception Zero messages Theme changes don’t use the WebView2 message channel
The WebView2/Native Boundary

Teams’ theme setting lives in the native (Electron/WebView2) layer, not the web layer. CDP gives us full control of the web content, but the native shell is largely opaque. We can read the DOM, execute JavaScript, intercept network requests — but we can’t call native APIs that the Electron/WebView2 host exposes only to its own privileged code.

The Solution: Automate the Settings UI

Since no API exists, the solution is to automate what a human would do: open Settings, navigate to Appearance, change the theme dropdown, and navigate back. CDP can click elements, wait for DOM mutations, and read values — everything needed to drive the UI programmatically.

// From injector.js — automating the Settings UI
async function switchTeamsMode(Runtime, targetMode) {
  const label = { dark: "Dark", light: "Light" }[targetMode];

  // Helper: wait for element to appear, then click
  async function waitAndClick(selector, timeout = 3000) {
    const result = await Runtime.evaluate({
      expression: `new Promise((resolve) => {
        const el = document.querySelector('${selector}');
        if (el && el.getBoundingClientRect().width > 0) {
          el.click(); resolve('ok'); return;
        }
        const obs = new MutationObserver(() => {
          const el = document.querySelector('${selector}');
          if (el && el.getBoundingClientRect().width > 0) {
            obs.disconnect(); el.click(); resolve('ok');
          }
        });
        obs.observe(document.body, { childList: true, subtree: true });
        setTimeout(() => { obs.disconnect(); resolve('timeout'); }, ${timeout});
      })`,
      returnByValue: true,
      awaitPromise: true,
    });
    return result.result.value === "ok";
  }

  // Step 1: Open Settings menu
  await waitAndClick('[data-tid="more-options-header"]');
  await waitAndClick('[data-tid="settings-button-menu"]');

  // Step 2: Click Appearance tab
  await waitAndClick('[data-tid="appearance"]');

  // Step 3: Open theme dropdown and select target
  await waitAndClick('[data-tid="appearance-settings-theme-selector-dropdown"]');

  // Step 4: Click the target option
  await Runtime.evaluate({
    expression: `new Promise((resolve) => {
      const check = () => {
        const opts = [...document.querySelectorAll('[role="option"]')];
        const target = opts.find(o => o.textContent?.trim() === '${label}');
        if (target) { target.click(); resolve('ok'); return true; }
        return false;
      };
      if (check()) return;
      const obs = new MutationObserver(() => { if (check()) obs.disconnect(); });
      obs.observe(document.body, { childList: true, subtree: true });
      setTimeout(() => { obs.disconnect(); resolve('timeout'); }, 3000);
    })`,
    returnByValue: true,
    awaitPromise: true,
  });

  await new Promise(r => setTimeout(r, 500));

  // Step 5: Navigate back twice (Appearance → General → main view)
  for (let i = 0; i < 2; i++) {
    await Runtime.evaluate({
      expression: `(() => {
        const btn = document.querySelector('[data-tid="nav-back"]');
        if (btn) { btn.click(); return 'back'; }
        history.back(); return 'history-back';
      })()`,
      returnByValue: true,
    });
    await new Promise(r => setTimeout(r, 300));
  }
}

MutationObserver-Based Waiting

The waitAndClick() helper uses a MutationObserver instead of polling with setTimeout. When a Teams UI panel opens asynchronously, the observer fires as soon as the target element appears in the DOM, then clicks it and disconnects. This minimizes the delay between each step — the entire Settings → Appearance → Dropdown → Select → Back → Back sequence takes about 2–3 seconds.

The MutationObserver approach also handles the case where the element already exists: it checks first, and only sets up the observer if the element isn’t found. A timeout (3 seconds) prevents hanging indefinitely if the UI structure changes.

Navigate Back Twice

Settings in Teams is a hierarchical view: General → Appearance is one level deep. After changing the theme, you need to go back twice to return to the original view (Appearance → General → chat/channel). The injector clicks [data-tid="nav-back"] twice with a 300ms delay between clicks to let the transition animation complete.

If the back button isn’t found (possible if the Settings UI structure changes), the fallback calls history.back() — the browser navigation stack may or may not work depending on how Teams manages its single-page app routing.

Summary

The injector now handles chat, channels, and calls correctly — with automatic mode detection and switching. The next section looks ahead: what’s still fragile, how to maintain themes across Teams updates, and where this project could go.