Skip to content

Instantly share code, notes, and snippets.

@elitan
Last active August 22, 2026 13:54
Show Gist options
  • Select an option

  • Save elitan/30e0f9e773cfa6a2834ce62a6e042d6e to your computer and use it in GitHub Desktop.

Select an option

Save elitan/30e0f9e773cfa6a2834ce62a6e042d6e to your computer and use it in GitHub Desktop.
Netflix-only Bluetooth lip-sync fix for Google TV Streamer

Fixing Netflix-only Bluetooth lip-sync delay on Google TV Streamer

Short version

On the first Google TV Streamer, Bluetooth headphones were roughly 200–250 ms late in Netflix while YouTube remained in sync. The usual sound settings did not fix it. The same failure and workaround were later reproduced on a second Streamer with different headphones and a different Bluetooth codec.

The evidence pointed to Netflix using Android's tunneled playback path over Bluetooth. On both devices, that path used hardware A/V sync, and the observed behavior was consistent with the headphones' presentation delay not being compensated correctly. Adding Netflix's own platform capability below forced non-tunneled playback over Bluetooth:

"tunnelModeWithBTSetting": "disable"

After restarting Netflix, the video decoder reported INPUT_TUNNEL_MODE OFF, Netflix moved off the DIRECT | HW_AV_SYNC audio route, and lip sync was correct.

This is an unsupported workaround, not an official Google or Netflix fix. Back up the existing setting before changing it.

First tested environment

  • Google TV Streamer (kirkwood)
  • Android TV 14 / API 34
  • Google TV build UTTK.260317.003
  • Netflix 13.1.2 build 26051
  • Srhythm NC25 Bluetooth headphones, offering AAC and SBC
  • Symptom was application-specific: YouTube was synchronized; Netflix was not

The device names, network addresses, Bluetooth addresses, and account details have been omitted.

What was happening

The two applications used different playback paths:

Application Observed path Result
YouTube Ordinary PCM/deep-buffer A2DP path Video compensated for Bluetooth delay
Netflix `DIRECT HW_AV_SYNC`, with tunneled secure video

Other useful observations:

  • The headphones exposed AAC and SBC, but no aptX Low Latency, aptX Adaptive, LDAC, or Opus.
  • Android reported that Bluetooth variable-latency mode was enabled at the framework level, but the audio HAL did not support it.
  • A2DP hardware offload was disabled.
  • Spatial audio, dialogue enhancement, and volume leveling were already off.
  • Changing surround output to “Never” did not change the bad Netflix route.
  • Switching AAC to SBC reduced the delay somewhat, but did not eliminate it by itself. The controlled AAC retest below showed that the codec change was not the actual fix.

Inspection of the installed Netflix build revealed an explicit platform capability named tunnelModeWithBTSetting and code paths logging:

  • Tunnel mode with BT is enabled due to platform config
  • Tunnel mode with BT is disabled due to platform config

Accepted values are enable, disable, and default.

Workaround

1. Enable ADB temporarily

Install Android SDK Platform Tools and jq on the computer. On the Google TV device, enable Developer options by opening Settings → System → About and selecting Android TV OS build seven times. Then open Settings → System → Developer options → Wireless debugging.

For the first connection, choose Pair device with pairing code on the TV and use the pairing address shown there:

adb pair GOOGLE_TV_IP:PAIRING_PORT

Enter the six-digit code from the TV when prompted. Then use the separate IP address and port shown on the main Wireless debugging screen:

adb connect GOOGLE_TV_IP:ADB_PORT
adb devices

Wireless-debugging ports can vary. Use the IP address and port shown by the TV.

2. Back up and update Netflix's platform capability

The commands below preserve every existing field rather than replacing the whole JSON object. They also stop if the existing value is not a valid JSON object:

set -e

ORIGINAL=$(adb shell settings get global nrdp_platform_capabilities | tr -d '\r')

if [ -z "$ORIGINAL" ] || [ "$ORIGINAL" = "null" ]; then
  ORIGINAL='{}'
fi

printf '%s\n' "$ORIGINAL" > nrdp_platform_capabilities.backup.json

UPDATED=$(printf '%s' "$ORIGINAL" \
  | jq -e -c '
      if type == "object" then
        . + {"tunnelModeWithBTSetting":"disable"}
      else
        error("existing capability value is not a JSON object")
      end
    ')

PAYLOAD=$(printf '%s' "$UPDATED" | base64 | tr -d '\n')

adb shell "settings put global nrdp_platform_capabilities \
  \"\$(echo '$PAYLOAD' | base64 -d)\""

Verify the result:

adb shell settings get global nrdp_platform_capabilities

The output should retain the original fields and include:

"tunnelModeWithBTSetting":"disable"

3. Restart Netflix without clearing its data

adb shell am force-stop com.netflix.ninja
adb shell monkey -p com.netflix.ninja \
  -c android.intent.category.LEANBACK_LAUNCHER 1 >/dev/null

This force-stops and relaunches Netflix. It does not clear the application data or sign the user out.

4. Verify

Play a dialogue-heavy Netflix scene. On the tested MediaTek-based Streamer, the following command confirmed the changed video path:

adb shell logcat -d -v time \
  | grep 'set INPUT_TUNNEL_MODE' \
  | tail -1

Expected result:

set INPUT_TUNNEL_MODE OFF

The exact log tag is platform-specific, so its absence on another device does not prove the workaround failed. The practical test is whether dialogue remains synchronized.

AAC versus SBC result

SBC was tested first and made the original tunneled-path delay somewhat smaller, but Netflix still looked out of sync.

After disabling tunnel mode fixed the problem, the headphones were deliberately switched back to AAC while keeping every other relevant setting unchanged. Netflix was restarted and the same kind of dialogue-heavy content was tested again. The device reported all of the following:

  • AAC at 44.1 kHz, 16-bit stereo
  • INPUT_TUNNEL_MODE OFF
  • an active Netflix PCM/A2DP audio track with flags 0x000, rather than the previous direct/HW-A/V-sync route

Lip sync remained correct. This isolates tunnelModeWithBTSetting=disable as the effective workaround on this setup; SBC was not required. AAC was therefore left enabled.

Independent reproduction on a second Streamer

On 22 August 2026, the same failure was reproduced on a second, separately configured Google TV Streamer while Netflix was playing through JBL Live 780NC headphones. This setup differed from the first in useful ways:

  • Netflix was again 13.1.2 build 26051.
  • The headphones were using LDAC at 96 kHz, 32-bit stereo rather than AAC.
  • nrdp_platform_capabilities again lacked tunnelModeWithBTSetting.
  • Netflix had an active hw_avsync_bt output with DIRECT | HW_AV_SYNC.
  • The active Netflix audio track repeatedly logged retrograde timestamp corrections.

After merging "tunnelModeWithBTSetting":"disable" and restarting Netflix, the decoder logged INPUT_TUNNEL_MODE OFF. The active Netflix audio track moved to the ordinary ms12_input_deep_buffer A2DP/PCM path, and the user reported that lip sync was correct.

This does not establish how widespread the bug is, but reproducing the same failure and fix on two Streamers with two headphone models and two codecs makes a headphone-specific cause unlikely. The shared Netflix/Streamer tunneled path is the common factor observed in both cases.

If the delay returns

On the tested device, the delay returned three days later and then returned again five days after it was reapplied. The TV had not rebooted, the system build was unchanged, and Netflix had not updated. In both cases, this command showed that tunnelModeWithBTSetting had disappeared:

adb shell settings get global nrdp_platform_capabilities

The second recurrence exposed the exact writer. Google TV Streamer includes a persistent, privileged MediaTek service named com.mediatek.nfxservice. Inspection of its installed code showed that it listens for display changes and HDMI-audio hotplug events. Its HDR-capability check writes a complete, hard-coded nrdp_platform_capabilities object rather than preserving unknown fields, so every such write removes tunnelModeWithBTSetting.

The device log captured the reset trigger directly:

NFXService: onDisplayChanged
NFXService: HDR_NOT_APPLICABLE
NFXService: HPD Connect

This can happen during an HDMI/display transition such as a TV wake, reconnect, or input-mode change; it does not require a reboot or software update.

If the delay returns, inspect the value above first:

  • If tunnelModeWithBTSetting is missing, repeat steps 2 and 3. Reapplying the field and restarting Netflix restored correct sync immediately on the tested device.
  • If the field is still set to disable, do not assume the same cause. Capture the current playback route and investigate it as a separate regression.

Do not disable com.mediatek.nfxservice as a workaround. It also manages Netflix audio/HDR capabilities, HDMI CEC state, and Netflix remote-button behavior. A narrowly scoped observer that merges only the missing field back would be safer, but that requires a separately installed helper with privileged settings permission and is not part of this procedure.

Wireless-debugging ports can also change. If a previously working address no longer connects, this command can reveal the currently advertised connection endpoint:

adb mdns services

Look for the Google TV device's _adb-tls-connect._tcp entry, then connect to the IP address and port shown there.

Optional automatic guard for repeated resets

The manual workaround is safer and simpler for a one-off occurrence. On the tested Streamer, however, MediaTek's service removed the field repeatedly after normal display changes. A small open-source helper was therefore tested as an optional self-healing guard.

The guard included in this Gist:

  • Watches only the Settings Provider URI for nrdp_platform_capabilities.
  • Parses the current JSON and adds only "tunnelModeWithBTSetting":"disable" when it is missing or different.
  • Leaves missing or invalid JSON untouched rather than replacing it.
  • Uses JobScheduler, so it does not poll or keep a process running.
  • Re-arms the observer after each change and schedules it again after boot.
  • Requests no Internet, storage, Bluetooth, account, microphone, notification, or accessibility permission.

It does require Android's broad WRITE_SECURE_SETTINGS permission. The source uses that permission for one global key, but anyone considering this workaround should inspect and build the source rather than installing an unknown APK.

Android initially places an unlaunched sideload in the NEVER standby bucket and can later defer jobs for rarely used apps. The tested installation therefore also uses a battery-optimization exemption. This keeps the event-driven job eligible; it does not make the helper poll or run continuously.

See the companion netflix-bt-guard-source.md file in this Gist. Recreate its layout and build it using build.sh, then install and initialize it:

adb install -r netflix-bt-guard.apk
adb shell pm grant io.github.elitan.netflixbtguard \
  android.permission.WRITE_SECURE_SETTINGS
adb shell dumpsys deviceidle whitelist \
  +io.github.elitan.netflixbtguard
adb shell am start \
  -n io.github.elitan.netflixbtguard/.StartActivity

On the first tested device, a simulated overwrite was repaired on the first check. A real sleep/wake cycle then caused com.mediatek.nfxservice to regenerate the capability object three times; the guard restored the field after every write, generally within about 0.5 seconds.

The same guard was then installed on the second Streamer. A controlled removal of the field was repaired on the third 200 ms check, about 0.4 seconds later. Netflix continued playing through the corrected non-tunneled audio path.

The helper does not restart Netflix. If Netflix was already running and cached the bad configuration before the guard repaired it, close and reopen Netflix once.

To remove the helper completely:

adb shell dumpsys deviceidle whitelist \
  -io.github.elitan.netflixbtguard
adb uninstall io.github.elitan.netflixbtguard

Uninstalling does not restore the original capability JSON. Use the separate revert procedure below if that is also desired.

Revert

To restore the original Netflix capability object:

ORIGINAL=$(tr -d '\r\n' < nrdp_platform_capabilities.backup.json)
PAYLOAD=$(printf '%s' "$ORIGINAL" | base64 | tr -d '\n')

adb shell "settings put global nrdp_platform_capabilities \
  \"\$(echo '$PAYLOAD' | base64 -d)\""

adb shell am force-stop com.netflix.ninja
adb shell monkey -p com.netflix.ninja \
  -c android.intent.category.LEANBACK_LAUNCHER 1 >/dev/null

Notes and caveats

  • A Netflix or Google TV update may overwrite or stop honoring this setting.
  • The same failure and one-field workaround were reproduced on two separate Google TV Streamers using Srhythm NC25/AAC and JBL Live 780NC/LDAC respectively. This reduces the likelihood of a headphone-specific cause but does not prove that every Streamer is affected.
  • MediaTek's privileged Netflix support service removes the value when it regenerates the capability object after HDMI/display changes. On the first device, this removal was reproduced twice without a reboot or application update.
  • Do not copy another person's complete nrdp_platform_capabilities JSON; merge only the one field so device-specific capabilities remain intact.
  • Clearing Netflix data and factory-resetting the Streamer were not required.
  • Turn Wireless debugging off after testing.

References

Optional Netflix Bluetooth Guard source

This is the complete source for the optional helper described in the companion Google TV / Netflix lip-sync report. It was built and tested on the exact device from that report. It is an unsupported workaround, not an official Google or Netflix application.

Review the source before granting WRITE_SECURE_SETTINGS. Android exposes that as a broad privileged permission even though this code reads and writes only nrdp_platform_capabilities.

Layout

AndroidManifest.xml
build.sh
src/io/github/elitan/netflixbtguard/BootReceiver.java
src/io/github/elitan/netflixbtguard/CapabilityGuard.java
src/io/github/elitan/netflixbtguard/GuardJobService.java
src/io/github/elitan/netflixbtguard/GuardScheduler.java
src/io/github/elitan/netflixbtguard/StartActivity.java

The manifest requests only RECEIVE_BOOT_COMPLETED and WRITE_SECURE_SETTINGS. There is no Internet, storage, Bluetooth, microphone, account, notification, or accessibility permission.

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="io.github.elitan.netflixbtguard">

    <uses-sdk
        android:minSdkVersion="24"
        android:targetSdkVersion="34" />

    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />

    <application
        android:allowBackup="false"
        android:directBootAware="true"
        android:hasCode="true"
        android:label="Netflix Bluetooth Guard"
        android:supportsRtl="true"
        android:usesCleartextTraffic="false">

        <receiver
            android:name=".BootReceiver"
            android:directBootAware="true"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
                <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
                <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
            </intent-filter>
        </receiver>

        <activity
            android:name=".StartActivity"
            android:enabled="true"
            android:excludeFromRecents="true"
            android:exported="true"
            android:noHistory="true"
            android:theme="@android:style/Theme.NoDisplay" />

        <service
            android:name=".GuardJobService"
            android:directBootAware="true"
            android:enabled="true"
            android:exported="true"
            android:permission="android.permission.BIND_JOB_SERVICE" />
    </application>

</manifest>

BootReceiver.java

package io.github.elitan.netflixbtguard;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;

public final class BootReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        GuardScheduler.start(context);
        String action = intent == null ? "unknown" : intent.getAction();
        CapabilityGuard.repair(context, "broadcast:" + action);
    }
}

CapabilityGuard.java

package io.github.elitan.netflixbtguard;

import android.content.ContentResolver;
import android.content.Context;
import android.provider.Settings;
import android.util.Log;

import org.json.JSONException;
import org.json.JSONObject;

final class CapabilityGuard {
    static final String SETTING_NAME = "nrdp_platform_capabilities";

    private static final String TAG = "NetflixBtGuard";
    private static final String FIELD_NAME = "tunnelModeWithBTSetting";
    private static final String REQUIRED_VALUE = "disable";

    private CapabilityGuard() {}

    static synchronized boolean repair(Context context, String reason) {
        ContentResolver resolver = context.getContentResolver();

        try {
            String current = Settings.Global.getString(resolver, SETTING_NAME);
            if (current == null || current.trim().isEmpty() || "null".equals(current.trim())) {
                Log.w(TAG, "Capability object is absent; leaving it untouched (" + reason + ")");
                return false;
            }

            JSONObject capabilities = new JSONObject(current);
            if (REQUIRED_VALUE.equals(capabilities.optString(FIELD_NAME, null))) {
                Log.i(TAG, "Capability is already correct (" + reason + ")");
                return false;
            }

            capabilities.put(FIELD_NAME, REQUIRED_VALUE);
            boolean written = Settings.Global.putString(
                    resolver,
                    SETTING_NAME,
                    capabilities.toString());

            if (written) {
                Log.i(TAG, "Restored Netflix Bluetooth tunnel override (" + reason + ")");
            } else {
                Log.e(TAG, "Settings provider rejected the repair (" + reason + ")");
            }
            return written;
        } catch (JSONException error) {
            Log.e(TAG, "Capability object is invalid JSON; leaving it untouched", error);
            return false;
        } catch (SecurityException error) {
            Log.e(TAG, "WRITE_SECURE_SETTINGS has not been granted", error);
            return false;
        }
    }
}

GuardJobService.java

package io.github.elitan.netflixbtguard;

import android.app.job.JobParameters;
import android.app.job.JobService;

public final class GuardJobService extends JobService {
    @Override
    public boolean onStartJob(JobParameters params) {
        GuardScheduler.continueWatching(this, params.getJobId());
        CapabilityGuard.repair(this, "settings-change");
        return false;
    }

    @Override
    public boolean onStopJob(JobParameters params) {
        GuardScheduler.continueWatching(this, params.getJobId());
        return false;
    }
}

GuardScheduler.java

package io.github.elitan.netflixbtguard;

import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.content.ComponentName;
import android.content.Context;
import android.provider.Settings;
import android.util.Log;

final class GuardScheduler {
    private static final String TAG = "NetflixBtGuard";
    private static final int JOB_A = 74101;
    private static final int JOB_B = 74102;

    private GuardScheduler() {}

    static synchronized void start(Context context) {
        JobScheduler scheduler = context.getSystemService(JobScheduler.class);
        if (scheduler == null) {
            Log.e(TAG, "JobScheduler is unavailable");
            return;
        }

        scheduler.cancel(JOB_A);
        scheduler.cancel(JOB_B);
        schedule(context, scheduler, JOB_A);
    }

    static synchronized void continueWatching(Context context, int completedJobId) {
        JobScheduler scheduler = context.getSystemService(JobScheduler.class);
        if (scheduler == null) {
            Log.e(TAG, "JobScheduler is unavailable");
            return;
        }

        int nextJobId = completedJobId == JOB_A ? JOB_B : JOB_A;
        scheduler.cancel(nextJobId);
        schedule(context, scheduler, nextJobId);
    }

    private static void schedule(Context context, JobScheduler scheduler, int jobId) {
        JobInfo.TriggerContentUri trigger = new JobInfo.TriggerContentUri(
                Settings.Global.getUriFor(CapabilityGuard.SETTING_NAME),
                0);

        JobInfo job = new JobInfo.Builder(
                jobId,
                new ComponentName(context, GuardJobService.class))
                .addTriggerContentUri(trigger)
                .setTriggerContentUpdateDelay(100)
                .setTriggerContentMaxDelay(1_000)
                .build();

        int result = scheduler.schedule(job);
        if (result == JobScheduler.RESULT_SUCCESS) {
            Log.i(TAG, "Watching Netflix capability changes with job " + jobId);
        } else {
            Log.e(TAG, "Could not schedule capability watcher " + jobId);
        }
    }
}

StartActivity.java

package io.github.elitan.netflixbtguard;

import android.app.Activity;
import android.os.Bundle;

public final class StartActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        GuardScheduler.start(this);
        CapabilityGuard.repair(this, "initialization");
        finish();
    }
}

build.sh

This dependency-free build uses Android SDK platform 34, build-tools 36.1.0, the JDK bundled with Android Studio when available, and the standard local Android debug key.

#!/bin/sh
set -eu

PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
BUILD_TOOLS_VERSION=${BUILD_TOOLS_VERSION:-36.1.0}
ANDROID_PLATFORM=${ANDROID_PLATFORM:-android-34}

if [ -z "${ANDROID_SDK_ROOT:-}" ]; then
    if [ -n "${ANDROID_HOME:-}" ]; then
        ANDROID_SDK_ROOT=$ANDROID_HOME
    else
        ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
    fi
fi

if [ -z "${JAVA_HOME:-}" ]; then
    if [ -x "/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java" ]; then
        JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home
    else
        JAVAC_PATH=$(command -v javac)
        JAVA_HOME=$(CDPATH= cd -- "$(dirname -- "$JAVAC_PATH")/.." && pwd)
    fi
fi

export JAVA_HOME
PATH="$JAVA_HOME/bin:$PATH"
export PATH

BUILD_DIR="$PROJECT_DIR/build"
CLASSES_DIR="$BUILD_DIR/classes"
DEX_DIR="$BUILD_DIR/dex"
ANDROID_JAR="$ANDROID_SDK_ROOT/platforms/$ANDROID_PLATFORM/android.jar"
BUILD_TOOLS="$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION"
UNSIGNED_APK="$BUILD_DIR/netflix-bt-guard-unsigned.apk"
ALIGNED_APK="$BUILD_DIR/netflix-bt-guard-aligned.apk"
SIGNED_APK="$BUILD_DIR/netflix-bt-guard.apk"
KEYSTORE=${KEYSTORE:-"$HOME/.android/debug.keystore"}

rm -rf "$BUILD_DIR"
mkdir -p "$CLASSES_DIR" "$DEX_DIR"

find "$PROJECT_DIR/src" -name '*.java' -print > "$BUILD_DIR/sources.list"

"$JAVA_HOME/bin/javac" \
    -source 8 \
    -target 8 \
    -encoding UTF-8 \
    -bootclasspath "$ANDROID_JAR" \
    -d "$CLASSES_DIR" \
    @"$BUILD_DIR/sources.list"

"$BUILD_TOOLS/aapt2" link \
    -I "$ANDROID_JAR" \
    --manifest "$PROJECT_DIR/AndroidManifest.xml" \
    --min-sdk-version 24 \
    --target-sdk-version 34 \
    --version-code 3 \
    --version-name 1.0.2 \
    -o "$UNSIGNED_APK"

find "$CLASSES_DIR" -name '*.class' -print > "$BUILD_DIR/classes.list"

"$BUILD_TOOLS/d8" \
    --lib "$ANDROID_JAR" \
    --min-api 24 \
    --output "$DEX_DIR" \
    @"$BUILD_DIR/classes.list"

zip -q -j "$UNSIGNED_APK" "$DEX_DIR/classes.dex"
"$BUILD_TOOLS/zipalign" -f 4 "$UNSIGNED_APK" "$ALIGNED_APK"
"$BUILD_TOOLS/apksigner" sign \
    --ks "$KEYSTORE" \
    --ks-pass pass:android \
    --key-pass pass:android \
    --out "$SIGNED_APK" \
    "$ALIGNED_APK"

"$BUILD_TOOLS/apksigner" verify --verbose --print-certs "$SIGNED_APK"
printf '\nBuilt %s\n' "$SIGNED_APK"

Install and initialize

chmod +x build.sh
./build.sh

adb install -r build/netflix-bt-guard.apk
adb shell pm grant io.github.elitan.netflixbtguard \
  android.permission.WRITE_SECURE_SETTINGS
adb shell dumpsys deviceidle whitelist \
  +io.github.elitan.netflixbtguard
adb shell am start \
  -n io.github.elitan.netflixbtguard/.StartActivity

The battery-optimization exemption prevents Android's app-standby buckets from indefinitely deferring the event-driven job. The helper still runs only when the watched setting changes.

Remove

adb shell dumpsys deviceidle whitelist \
  -io.github.elitan.netflixbtguard
adb uninstall io.github.elitan.netflixbtguard
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment