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>
163 lines
7.1 KiB
Dart
163 lines
7.1 KiB
Dart
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';
|