Add TwitchAdSolutions-style ad blocking for native and WebView players.
Some checks failed
CI / Analyze, test, and build Android (push) Has been cancelled
CI / Build iOS (push) Has been cancelled

Request playback with non-site player types and swap to backup Usher streams on stitched ad breaks; inject vendored vaft into the legacy WebView and expose a Settings toggle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-09-30 10:55:50 +02:00
parent bb150ce42a
commit 1aae7438bb
16 changed files with 1697 additions and 12 deletions

View file

@ -0,0 +1,163 @@
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/playback_access_token.dart';
/// Dart-side port of the [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions)
/// strategy for Frosty's native HLS player.
///
/// Browser vaft hooks `Worker`/`fetch` to rewrite playlists. Here we instead:
/// 1. Request playback tokens with a non-`site` [forceAccessTokenPlayerType]
/// (vaft's `ForceAccessTokenPlayerType`).
/// 2. When stitched ads still appear, try [backupPlayerTypes] until a playlist
/// without the ad signifier is found, then hand that Usher URL to the player.
class TwitchAdSolutions {
TwitchAdSolutions._();
/// Matches vaft `AdSignifier` — Twitch marks ad pods with this in `#EXT-X-DATERANGE`.
static const adSignifier = 'stitched';
/// vaft `ForceAccessTokenPlayerType` — used for the primary stream when
/// ad blocking is enabled.
static const forceAccessTokenPlayerType = 'popout';
/// vaft `BackupPlayerTypes` order when the primary playlist still has ads.
static const backupPlayerTypes = <String>[
'embed',
'popout',
'autoplay',
];
/// vaft `FallbackPlayerType` when every backup still carries ads.
static const fallbackPlayerType = 'embed';
/// GQL `platform` for a given player type (vaft uses `android` for autoplay).
static String platformForPlayerType(String playerType) =>
playerType == 'autoplay' ? 'android' : 'web';
static bool playlistContainsAds(String playlistText) =>
playlistText.contains(adSignifier);
/// True when [text] looks like a midroll (vaft midroll banner path).
static bool playlistIsMidroll(String playlistText) =>
playlistText.contains('"MIDROLL"') ||
playlistText.contains('"midroll"') ||
playlistText.contains("MIDROLL");
}
/// Resolves Usher HLS URLs using TwitchAdSolutions player-type strategy.
class TwitchAdFreePlayback {
TwitchAdFreePlayback(this._gql);
final TwitchGqlApi _gql;
/// Primary URL when adblock is on: force [TwitchAdSolutions.forceAccessTokenPlayerType].
Future<({String url, PlaybackAccessToken token, String playerType})>
resolvePrimary({
required String login,
String? authToken,
}) {
return _resolveForPlayerType(
login: login,
authToken: authToken,
playerType: TwitchAdSolutions.forceAccessTokenPlayerType,
requireAdFree: false,
);
}
/// Walks [TwitchAdSolutions.backupPlayerTypes] for a playlist without ads.
/// Falls back to [TwitchAdSolutions.fallbackPlayerType] even if ads remain.
Future<({String url, PlaybackAccessToken token, String playerType})>
resolveBackup({
required String login,
String? authToken,
}) async {
for (final playerType in TwitchAdSolutions.backupPlayerTypes) {
try {
final resolved = await _resolveForPlayerType(
login: login,
authToken: authToken,
playerType: playerType,
requireAdFree: true,
);
debugPrint(
'TwitchAdFreePlayback: backup OK playerType=$playerType',
);
return resolved;
} catch (e) {
debugPrint(
'TwitchAdFreePlayback: backup playerType=$playerType failed: $e',
);
}
}
debugPrint(
'TwitchAdFreePlayback: all backups had ads; using fallback '
'${TwitchAdSolutions.fallbackPlayerType}',
);
return _resolveForPlayerType(
login: login,
authToken: authToken,
playerType: TwitchAdSolutions.fallbackPlayerType,
requireAdFree: false,
);
}
Future<({String url, PlaybackAccessToken token, String playerType})>
_resolveForPlayerType({
required String login,
String? authToken,
required String playerType,
required bool requireAdFree,
}) async {
final token = await _gql.getPlaybackAccessToken(
login: login,
authToken: authToken,
playerType: playerType,
platform: TwitchAdSolutions.platformForPlayerType(playerType),
);
final url = _gql.buildHlsUrl(login: login, token: token);
if (requireAdFree) {
final hasAds = await _multivariantHasAds(url);
if (hasAds) {
throw StateError('Playlist for playerType=$playerType still has ads');
}
}
return (url: url, token: token, playerType: playerType);
}
/// Fetches the multivariant playlist and samples the first media variant for
/// the ad signifier (same idea as vaft probing backup encodings).
Future<bool> _multivariantHasAds(String hlsUrl) async {
final master = await _gql.fetchPlaylistText(hlsUrl);
final mediaUrl = firstMediaPlaylistUrl(master, baseUrl: hlsUrl);
if (mediaUrl == null) {
// Can't verify — treat as ad-free so we don't reject a usable stream.
return false;
}
final media = await _gql.fetchPlaylistText(mediaUrl);
return TwitchAdSolutions.playlistContainsAds(media);
}
/// First `.m3u8` URI in a multivariant playlist, resolved against [baseUrl].
@visibleForTesting
static String? firstMediaPlaylistUrl(
String masterPlaylist, {
required String baseUrl,
}) {
final lines = masterPlaylist.replaceAll('\r', '').split('\n');
for (final raw in lines) {
final line = raw.trim();
if (line.isEmpty || line.startsWith('#')) continue;
if (!line.contains('.m3u8')) continue;
return Uri.parse(baseUrl).resolve(line).toString();
}
return null;
}
}
/// Tiny blank MP4 (same payload vaft serves for stripped ad segments). Exposed
/// for tests / future local playlist rewriting; not required for URL-swap mode.
const twitchAdSolutionsBlankSegmentDataUri =
'data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA';

View file

@ -34,12 +34,18 @@ class TwitchGqlApi extends BaseApiClient {
/// When [authToken] is provided, the request is authenticated which may
/// grant ad-free playback for subscribers and Turbo users.
///
/// [playerType] / [platform] mirror the web player's GQL variables. Ad
/// blocking (TwitchAdSolutions / vaft) uses non-`site` player types such as
/// `popout` / `embed` / `autoplay`.
///
/// Uses the web client's persisted GQL query (smaller payload, blends in
/// with regular web traffic); falls back to the inline query template if
/// Twitch ever rotates the persisted-query hash.
Future<PlaybackAccessToken> getPlaybackAccessToken({
required String login,
String? authToken,
String playerType = 'site',
String platform = 'web',
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlClientId,
@ -52,8 +58,8 @@ class TwitchGqlApi extends BaseApiClient {
'login': login,
'isVod': false,
'vodID': '',
'playerType': 'site',
'platform': 'web',
'playerType': playerType,
'platform': platform,
};
Future<JsonMap> query(Map<String, Object> body) =>
@ -92,6 +98,11 @@ class TwitchGqlApi extends BaseApiClient {
return PlaybackAccessToken.fromGqlResponse(response);
}
/// Fetches raw playlist text (multivariant or media) with playback headers.
Future<String> fetchPlaylistText(String url) async {
return get<String>(url, headers: playbackHttpHeaders);
}
/// Builds the HLS stream URL for the given channel [login] and [token].
String buildHlsUrl({
required String login,

View file

@ -6,6 +6,7 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_ad_solutions.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/constants.dart';
@ -113,9 +114,23 @@ abstract class NativeVideoStoreBase
/// suspended rather than burning refresh attempts on ad-induced noise.
var _isAdBreakActive = false;
/// True after we swapped to a TwitchAdSolutions backup player-type URL.
var _usingAdBackup = false;
/// Serializes ad-break URL swaps so overlapping adBreakStream events don't
/// race loadUrl calls.
var _adSwapInFlight = false;
late final TwitchAdFreePlayback _adFreePlayback =
TwitchAdFreePlayback(twitchGqlApi);
@readonly
var _loading = true;
/// Whether ad blocking is actively avoiding/replacing an ad pod.
@readonly
var _blockingAds = false;
@readonly
var _paused = true;
@ -276,6 +291,12 @@ abstract class NativeVideoStoreBase
debugPrint(
'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}',
);
if (!settingsStore.blockTwitchAds) {
runInAction(() => _blockingAds = false);
return;
}
// Fire-and-forget: swap stream URL per TwitchAdSolutions backup path.
unawaited(_onAdBreakChanged(isActive));
});
_pipSub = _controller!.isPipEnabledStream.listen((isPip) {
@ -378,12 +399,27 @@ abstract class NativeVideoStoreBase
Future<void> _loadStream(int generation) async {
// Use web cookie token (works with web Client-ID) for ad-free playback.
final authToken = authStore.gqlToken;
late final String hlsUrl;
late final PlaybackAccessToken token;
try {
token = await twitchGqlApi.getPlaybackAccessToken(
login: userLogin,
authToken: authToken,
);
if (settingsStore.blockTwitchAds) {
final resolved = await _adFreePlayback.resolvePrimary(
login: userLogin,
authToken: authToken,
);
hlsUrl = resolved.url;
token = resolved.token;
debugPrint(
'NativeVideoStore: adblock primary '
'playerType=${resolved.playerType}',
);
} else {
token = await twitchGqlApi.getPlaybackAccessToken(
login: userLogin,
authToken: authToken,
);
hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
}
} catch (e) {
if (authToken != null) {
debugPrint('NativeVideoStore: auth token failed, retrying without: $e');
@ -392,7 +428,16 @@ abstract class NativeVideoStoreBase
// reflects the true state and future loads skip this request.
authStore.invalidateGqlToken();
}
token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin);
if (settingsStore.blockTwitchAds) {
final resolved = await _adFreePlayback.resolvePrimary(
login: userLogin,
);
hlsUrl = resolved.url;
token = resolved.token;
} else {
token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin);
hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
}
} else {
rethrow;
}
@ -401,10 +446,11 @@ abstract class NativeVideoStoreBase
if (_disposed || generation != _initGeneration || !settingsStore.showVideo) {
return;
}
runInAction(
() => _restrictedStreamQualities = token.restrictedQualities,
);
final hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
runInAction(() {
_restrictedStreamQualities = token.restrictedQualities;
_usingAdBackup = false;
_blockingAds = false;
});
await _controller!.loadUrl(
url: hlsUrl,
@ -417,6 +463,66 @@ abstract class NativeVideoStoreBase
_startLatencyPolling();
}
/// TwitchAdSolutions backup swap when the native player reports a stitched
/// ad break; restores the primary (popout) URL when the break ends.
Future<void> _onAdBreakChanged(bool isActive) async {
if (_disposed || !settingsStore.blockTwitchAds || _controller == null) {
return;
}
if (_adSwapInFlight) return;
_adSwapInFlight = true;
final generation = _initGeneration;
try {
if (isActive) {
runInAction(() => _blockingAds = true);
final resolved = await _adFreePlayback.resolveBackup(
login: userLogin,
authToken: authStore.gqlToken,
);
if (_disposed || generation != _initGeneration) return;
debugPrint(
'NativeVideoStore: blocking ads via '
'playerType=${resolved.playerType}',
);
await _controller!.loadUrl(
url: resolved.url,
headers: TwitchGqlApi.playbackHttpHeaders,
);
if (_disposed || generation != _initGeneration) return;
await _controller!.configureForLivePlayback();
runInAction(() {
_usingAdBackup = true;
_restrictedStreamQualities = resolved.token.restrictedQualities;
});
} else if (_usingAdBackup) {
debugPrint('NativeVideoStore: ad break ended, restoring primary');
final resolved = await _adFreePlayback.resolvePrimary(
login: userLogin,
authToken: authStore.gqlToken,
);
if (_disposed || generation != _initGeneration) return;
await _controller!.loadUrl(
url: resolved.url,
headers: TwitchGqlApi.playbackHttpHeaders,
);
if (_disposed || generation != _initGeneration) return;
await _controller!.configureForLivePlayback();
runInAction(() {
_usingAdBackup = false;
_blockingAds = false;
_restrictedStreamQualities = resolved.token.restrictedQualities;
});
} else {
runInAction(() => _blockingAds = false);
}
} catch (e) {
debugPrint('NativeVideoStore: adblock swap failed: $e');
runInAction(() => _blockingAds = _isAdBreakActive || _usingAdBackup);
} finally {
_adSwapInFlight = false;
}
}
/// Shared error handler for both `_initPlayer` and `_reloadCurrentController`.
/// Checks stream liveness, auto-retries if CDN is still serving stale
/// manifests, or surfaces the error with the overlay visible.
@ -1278,6 +1384,9 @@ abstract class NativeVideoStoreBase
_paused = true;
_isStalled = false;
_isAdBreakActive = false;
_usingAdBackup = false;
_blockingAds = false;
_adSwapInFlight = false;
_restrictedStreamQualities = [];
_isQualitySwitching = false;
_isAudioOnlyMode = false;

View file

@ -57,6 +57,29 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
});
}
late final _$_blockingAdsAtom = Atom(
name: 'NativeVideoStoreBase._blockingAds',
context: context,
);
bool get blockingAds {
_$_blockingAdsAtom.reportRead();
return super._blockingAds;
}
@override
bool get isBlockingAds => blockingAds;
@override
bool get _blockingAds => blockingAds;
@override
set _blockingAds(bool value) {
_$_blockingAdsAtom.reportWrite(value, super._blockingAds, () {
super._blockingAds = value;
});
}
late final _$_pausedAtom = Atom(
name: 'NativeVideoStoreBase._paused',
context: context,

View file

@ -322,6 +322,25 @@ class VideoOverlay extends StatelessWidget {
height: 80, // Covers bottom area around controls
child: Container(decoration: bottomGradient),
),
if (videoStore.isBlockingAds)
Positioned(
top: 8,
left: 8,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Text(
'Blocking ads',
style: TextStyle(color: Colors.white, fontSize: 12),
),
),
),
),
),
// Content
Stack(
children: [

View file

@ -29,6 +29,9 @@ abstract class VideoPlayerInterface {
String get streamQuality;
String? get latency;
/// True while TwitchAdSolutions is actively avoiding an ad break.
bool get isBlockingAds;
void handleVideoTap();
void handlePausePlay();
void handleToggleOverlay();

View file

@ -183,9 +183,20 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
// Every real navigation invalidates the injected JS context, so
// every one must re-arm init. Unconditional is safe: the
// onPageFinished handler below still filters on videoUrl.
onPageStarted: (url) => _needsInit = true,
onPageStarted: (url) {
_needsInit = true;
// Best-effort: inject vaft as early as the WebView allows.
// True document-start injection isn't exposed by webview_flutter
// on Android; native player remains the reliable adblock path.
if (settingsStore.blockTwitchAds) {
unawaited(_injectVaftUserScript());
}
},
onPageFinished: (url) async {
if (url != videoUrl) return;
if (settingsStore.blockTwitchAds) {
await _injectVaftUserScript();
}
if (!_needsInit) return;
_needsInit = false;
await initVideo();
@ -263,6 +274,11 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
@readonly
var _isInPipMode = false;
/// WebView path relies on the injected vaft userscript's own overlay; the
/// Dart overlay banner is driven by the native player only.
@override
bool get isBlockingAds => false;
/// The video URL to use for the webview.
String get videoUrl =>
'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty';
@ -636,6 +652,22 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
}
}
/// Injects [assets/js/vaft.user.js] (TwitchAdSolutions) into the player page.
Future<void> _injectVaftUserScript() async {
try {
final raw = await rootBundle.loadString('assets/js/vaft.user.js');
// Strip UserScript metadata; keep the IIFE body.
final script = raw.replaceFirst(
RegExp(r'^[\s\S]*?==/UserScript==\s*', multiLine: true),
'',
);
await videoWebViewController.runJavaScript(script);
debugPrint('VideoStore: injected TwitchAdSolutions vaft');
} catch (e) {
debugPrint('VideoStore: vaft inject failed: $e');
}
}
/// Called whenever the video/overlay is tapped.
@override
@action

View file

@ -69,6 +69,7 @@ abstract class _SettingsStoreBase with Store {
static const defaultDefaultToHighestQuality = false;
static const defaultUseTextureRendering = true;
static const defaultUseNativePlayer = true;
static const defaultBlockTwitchAds = true;
static const defaultShowOverlay = true;
static const defaultToggleableOverlay = false;
@ -91,6 +92,12 @@ abstract class _SettingsStoreBase with Store {
@observable
var useNativePlayer = defaultUseNativePlayer;
/// TwitchAdSolutions (vaft)-style ad blocking for the native player, and
/// userscript injection for the legacy WebView player.
@JsonKey(defaultValue: defaultBlockTwitchAds)
@observable
var blockTwitchAds = defaultBlockTwitchAds;
/// 5.2.0 made native the default. 5.1.0 already wrote
/// `useNativePlayer: false` into saved settings, so a Dart default
/// change alone would leave existing installs on WebView.
@ -123,6 +130,7 @@ abstract class _SettingsStoreBase with Store {
defaultToHighestQuality = defaultDefaultToHighestQuality;
useTextureRendering = defaultUseTextureRendering;
useNativePlayer = defaultUseNativePlayer;
blockTwitchAds = defaultBlockTwitchAds;
showOverlay = defaultShowOverlay;
toggleableOverlay = defaultToggleableOverlay;

View file

@ -24,6 +24,7 @@ SettingsStore _$SettingsStoreFromJson(
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
..useNativePlayer = json['useNativePlayer'] as bool? ?? true
..blockTwitchAds = json['blockTwitchAds'] as bool? ?? true
..nativePlayerV52Migrated = json['nativePlayerV52Migrated'] as bool? ?? false
..showOverlay = json['showOverlay'] as bool? ?? true
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
@ -107,6 +108,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
'defaultToHighestQuality': instance.defaultToHighestQuality,
'useTextureRendering': instance.useTextureRendering,
'useNativePlayer': instance.useNativePlayer,
'blockTwitchAds': instance.blockTwitchAds,
'nativePlayerV52Migrated': instance.nativePlayerV52Migrated,
'showOverlay': instance.showOverlay,
'toggleableOverlay': instance.toggleableOverlay,
@ -352,6 +354,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$blockTwitchAdsAtom = Atom(
name: '_SettingsStoreBase.blockTwitchAds',
context: context,
);
@override
bool get blockTwitchAds {
_$blockTwitchAdsAtom.reportRead();
return super.blockTwitchAds;
}
@override
set blockTwitchAds(bool value) {
_$blockTwitchAdsAtom.reportWrite(value, super.blockTwitchAds, () {
super.blockTwitchAds = value;
});
}
late final _$showOverlayAtom = Atom(
name: '_SettingsStoreBase.showOverlay',
context: context,
@ -1245,6 +1265,7 @@ showVideo: ${showVideo},
defaultToHighestQuality: ${defaultToHighestQuality},
useTextureRendering: ${useTextureRendering},
useNativePlayer: ${useNativePlayer},
blockTwitchAds: ${blockTwitchAds},
showOverlay: ${showOverlay},
toggleableOverlay: ${toggleableOverlay},
showLatency: ${showLatency},

View file

@ -34,6 +34,21 @@ class VideoSettings extends StatelessWidget {
onChanged: (newValue) =>
settingsStore.useNativePlayer = newValue,
),
if (settingsStore.showVideo)
SettingsListSwitch(
title: 'Block Twitch ads',
subtitle: Text(
settingsStore.useNativePlayer
? 'Uses TwitchAdSolutions (vaft): popout/embed/autoplay player types and backup streams during ad breaks.'
: 'Injects the vaft userscript into the WebView. Native player is more reliable — enable it above if ads still play.',
),
value: settingsStore.blockTwitchAds,
onChanged: (newValue) {
settingsStore.blockTwitchAds = newValue;
// Ad blocking is most effective on the native path.
if (newValue) settingsStore.useNativePlayer = true;
},
),
if (!Platform.isIOS || isIPad())
SettingsListSwitch(
title: 'Default to highest quality',