feat(video): adopt streamlink playback hardening and ad-break awareness

- use Twitch's persisted PlaybackAccessToken query with web client
  headers (UA/Referer/Origin pinned per streamlink), falling back to the
  inline template query on GQL errors
- switch playlist requests to the usher v2 endpoint and forward the same
  playback headers to the native player
- parse hide_ads and chansub.restricted_bitrates from the token payload;
  surface subscriber-only qualities as disabled tiles in the quality picker
- subscribe to the native player's adBreakChanged events: pause latency
  polling, defer stall recovery, and skip bounce counting during ad pods
- bump better_native_video_player to 8556673 (stitched-ad detection)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-06-11 23:28:25 -07:00
parent 2258f9832b
commit b305deec42
9 changed files with 322 additions and 59 deletions

View file

@ -1,6 +1,7 @@
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/playback_access_token.dart';
@ -12,38 +13,89 @@ import 'package:frosty/models/playback_access_token.dart';
class TwitchGqlApi extends BaseApiClient {
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
/// Browser-consistent User-Agent for playback-related requests. Twitch has
/// rejected access-token requests with non-browser UAs before
/// (streamlink#6574); sending one consistent with the web client ID and
/// web session token avoids the obvious fingerprint mismatch.
static const playbackUserAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
/// Headers for the HLS playlist/segment requests issued by the native
/// player, mirroring what the web player sends to Usher/weaver.
static const playbackHttpHeaders = <String, String>{
'User-Agent': playbackUserAgent,
'Referer': 'https://player.twitch.tv',
'Origin': 'https://player.twitch.tv',
};
/// Fetches a playback access token for the given channel [login].
///
/// When [authToken] is provided, the request is authenticated which may
/// grant ad-free playback for subscribers and Turbo users.
///
/// 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,
}) async {
final headers = <String, String>{
'Client-ID': twitchGqlClientId,
'User-Agent': playbackUserAgent,
if (authToken != null) 'Authorization': 'OAuth $authToken',
};
final body = {
'operationName': 'PlaybackAccessToken_Template',
'query':
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
'variables': {
'isLive': true,
'login': login,
'isVod': false,
'vodID': '',
'playerType': 'site',
final variables = {
'isLive': true,
'login': login,
'isVod': false,
'vodID': '',
'playerType': 'site',
'platform': 'web',
};
final persistedBody = {
'operationName': 'PlaybackAccessToken',
'extensions': {
'persistedQuery': {
'version': 1,
// Stable for years; used by the web player and streamlink.
'sha256Hash':
'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9',
},
},
'variables': variables,
};
final response = await post<JsonMap>(
'/gql',
data: body,
data: persistedBody,
headers: headers,
);
// PersistedQueryNotFound (or similar) comes back as a 200 with an
// errors array — retry once with the inline template before failing.
if (response['errors'] != null && response['data'] == null) {
debugPrint(
'TwitchGqlApi: persisted query failed (${response['errors']}), '
'falling back to inline template',
);
final templateBody = {
'operationName': 'PlaybackAccessToken_Template',
'query':
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
'variables': variables,
};
final fallback = await post<JsonMap>(
'/gql',
data: templateBody,
headers: headers,
);
return PlaybackAccessToken.fromGqlResponse(fallback);
}
return PlaybackAccessToken.fromGqlResponse(response);
}
@ -55,7 +107,10 @@ class TwitchGqlApi extends BaseApiClient {
final random = Random().nextInt(999999);
final encodedToken = Uri.encodeComponent(token.value);
return 'https://usher.ttvnw.net/api/channel/hls/$login.m3u8'
// Usher v2 — what the web player and streamlink use today. Same query
// params and token; the multivariant playlist is standards-compliant
// (EXT-X-SESSION-DATA + STREAM-INF only, no EXT-X-MEDIA/TWITCH-INFO).
return 'https://usher.ttvnw.net/api/v2/channel/hls/$login.m3u8'
'?sig=${token.signature}'
'&token=$encodedToken'
'&allow_source=true'

View file

@ -1,24 +1,65 @@
class PlaybackAccessToken {
final String value;
final String signature;
const PlaybackAccessToken({
required this.value,
required this.signature,
});
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
final data =
(json['data'] as Map<String, dynamic>?)?['streamPlaybackAccessToken']
as Map<String, dynamic>?;
if (data == null) {
throw const FormatException(
'streamPlaybackAccessToken missing from GQL response',
);
}
return PlaybackAccessToken(
value: data['value'] as String,
signature: data['signature'] as String,
);
}
}
import 'dart:convert';
import 'package:flutter/foundation.dart';
class PlaybackAccessToken {
final String value;
final String signature;
/// Whether Twitch will suppress ads for this session (Turbo, sub, etc.).
/// Defaults to false when the token payload can't be parsed.
final bool hideAds;
/// Quality group names (e.g. "1080p60") that exist for this stream but are
/// restricted to subscribers, so they won't appear in the HLS playlist.
final List<String> restrictedQualities;
const PlaybackAccessToken({
required this.value,
required this.signature,
this.hideAds = false,
this.restrictedQualities = const [],
});
/// Names in restricted_bitrates that aren't real video qualities
/// (matches streamlink's filter).
static final _nonQualityNameRe = RegExp(r'^((.+_)?archives|live|chunked)');
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
final data =
(json['data'] as Map<String, dynamic>?)?['streamPlaybackAccessToken']
as Map<String, dynamic>?;
if (data == null) {
throw const FormatException(
'streamPlaybackAccessToken missing from GQL response',
);
}
final value = data['value'] as String;
var hideAds = false;
var restricted = const <String>[];
try {
final payload = jsonDecode(value) as Map<String, dynamic>;
hideAds = payload['hide_ads'] as bool? ?? false;
final bitrates =
(payload['chansub'] as Map<String, dynamic>?)?['restricted_bitrates']
as List<dynamic>?;
if (bitrates != null) {
restricted = bitrates
.whereType<String>()
.where((name) => !_nonQualityNameRe.hasMatch(name))
.toList();
}
} catch (e) {
// Token payload format is Twitch-internal — never fail playback over it.
debugPrint('PlaybackAccessToken: failed to parse token payload: $e');
}
return PlaybackAccessToken(
value: value,
signature: data['signature'] as String,
hideAds: hideAds,
restrictedQualities: restricted,
);
}
}

View file

@ -103,6 +103,13 @@ abstract class NativeVideoStoreBase
StreamSubscription<bool>? _pipSub;
StreamSubscription<List<NativeVideoPlayerQuality>>? _qualitiesSub;
StreamSubscription<bool>? _adBreakSub;
/// True while a server-stitched ad break is playing (native player detects
/// twitch-stitched-ad dateranges). Latency readings and play/buffer
/// transitions are unreliable during ads, so recovery and chat sync are
/// suspended rather than burning refresh attempts on ad-induced noise.
var _isAdBreakActive = false;
@readonly
var _loading = true;
@ -125,6 +132,11 @@ abstract class NativeVideoStoreBase
@readonly
var _availableStreamQualities = <String>[];
/// Qualities that exist for this stream but require a subscription
/// (parsed from the playback token's restricted_bitrates).
@readonly
var _restrictedStreamQualities = <String>[];
@readonly
var _streamQualityIndex = 0;
@ -257,6 +269,13 @@ abstract class NativeVideoStoreBase
return;
}
_adBreakSub = _controller!.adBreakStream.listen((isActive) {
_isAdBreakActive = isActive;
debugPrint(
'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}',
);
});
_pipSub = _controller!.isPipEnabledStream.listen((isPip) {
runInAction(() {
// iOS-only bookkeeping: track whether PiP was triggered by gesture
@ -380,9 +399,15 @@ abstract class NativeVideoStoreBase
if (_disposed || generation != _initGeneration || !settingsStore.showVideo) {
return;
}
runInAction(
() => _restrictedStreamQualities = token.restrictedQualities,
);
final hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
await _controller!.loadUrl(url: hlsUrl);
await _controller!.loadUrl(
url: hlsUrl,
headers: TwitchGqlApi.playbackHttpHeaders,
);
if (_disposed || generation != _initGeneration || !settingsStore.showVideo) {
return;
}
@ -503,7 +528,9 @@ abstract class NativeVideoStoreBase
// to engage recovery on those stalls.
if (_hasPlayedOnce && !_isQualitySwitching) {
_isStalled = true;
_recordShortPlayBounceIfNeeded();
// Ad-pod entry/exit produces short play/buffer bounces that would
// otherwise count toward the CDN-trouble error heuristic.
if (!_isAdBreakActive) _recordShortPlayBounceIfNeeded();
}
// Clear _initializing on the first buffering event so the stall
// recovery timer isn't permanently blocked during init stalls.
@ -669,6 +696,15 @@ abstract class NativeVideoStoreBase
return;
}
// Ad-pod transitions cause brief discontinuities that look like stalls.
// Defer (not skip) recovery so a genuine stall surviving the ad break
// still gets handled once the break ends.
if (_isAdBreakActive) {
debugPrint('NativeVideoStore: stall during ad break, deferring recovery');
_startStallRecoveryTimer();
return;
}
// When a stream ends the HLS server stops serving segments, causing
// the player to stall indefinitely. Skip recovery if the stream is
// offline — the overlay handles that state.
@ -802,6 +838,11 @@ abstract class NativeVideoStoreBase
}
Future<void> _updateLatency() async {
// Program-date-time readings jump around during stitched ad breaks —
// skip the poll entirely (display, recovery, and chat sync all resume
// on the next tick after the break ends).
if (_isAdBreakActive) return;
final seconds = await _controller?.getLatencyToLive();
if (_disposed || seconds == null) return;
final rounded = seconds.round();
@ -1202,6 +1243,8 @@ abstract class NativeVideoStoreBase
_pipSub = null;
_qualitiesSub?.cancel();
_qualitiesSub = null;
_adBreakSub?.cancel();
_adBreakSub = null;
_controller?.removeActivityListener(_onPlayerActivity);
_controller?.dispose();
_controller = null;
@ -1222,6 +1265,8 @@ abstract class NativeVideoStoreBase
_pendingRefreshOnResume = false;
_paused = true;
_isStalled = false;
_isAdBreakActive = false;
_restrictedStreamQualities = [];
_isQualitySwitching = false;
_isAudioOnlyMode = false;
_stallRecoveryAttempt = 0;

View file

@ -181,6 +181,30 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
);
}
late final _$_restrictedStreamQualitiesAtom = Atom(
name: 'NativeVideoStoreBase._restrictedStreamQualities',
context: context,
);
List<String> get restrictedStreamQualities {
_$_restrictedStreamQualitiesAtom.reportRead();
return super._restrictedStreamQualities;
}
@override
List<String> get _restrictedStreamQualities => restrictedStreamQualities;
@override
set _restrictedStreamQualities(List<String> value) {
_$_restrictedStreamQualitiesAtom.reportWrite(
value,
super._restrictedStreamQualities,
() {
super._restrictedStreamQualities = value;
},
);
}
late final _$_streamQualityIndexAtom = Atom(
name: 'NativeVideoStoreBase._streamQualityIndex',
context: context,

View file

@ -99,26 +99,35 @@ class VideoOverlay extends StatelessWidget {
builder: (context) => ListView(
shrinkWrap: true,
primary: false,
children: videoStore.availableStreamQualities
.map(
(quality) => ListTile(
trailing: videoStore.streamQuality == quality
? const Icon(Icons.check_rounded)
: null,
title: Text(quality),
onTap: () {
videoStore.setStreamQuality(quality);
SharedPreferences.getInstance().then(
(prefs) => prefs.setString(
lastStreamQualityKey(videoStore.userLogin),
quality,
),
);
Navigator.pop(context);
},
),
)
.toList(),
children: [
...videoStore.availableStreamQualities.map(
(quality) => ListTile(
trailing: videoStore.streamQuality == quality
? const Icon(Icons.check_rounded)
: null,
title: Text(quality),
onTap: () {
videoStore.setStreamQuality(quality);
SharedPreferences.getInstance().then(
(prefs) => prefs.setString(
lastStreamQualityKey(videoStore.userLogin),
quality,
),
);
Navigator.pop(context);
},
),
),
// Qualities the broadcaster offers only to subscribers
// (from the playback token's restricted_bitrates).
...videoStore.restrictedStreamQualities.map(
(quality) => ListTile(
enabled: false,
title: Text(quality),
subtitle: const Text('Subscribers only'),
),
),
],
),
),
),

View file

@ -20,6 +20,12 @@ abstract class VideoPlayerInterface {
StreamTwitch? get streamInfo;
Channel? get offlineChannelInfo;
List<String> get availableStreamQualities;
/// Qualities that exist for this stream but require a subscription.
/// Only the native player can know this (parsed from the playback token);
/// the WebView player always reports none.
List<String> get restrictedStreamQualities;
String get streamQuality;
String? get latency;

View file

@ -237,6 +237,11 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
@readonly
List<String> _availableStreamQualities = [];
// The WebView player has no access to the playback token, so sub-only
// qualities can't be detected here.
@override
List<String> get restrictedStreamQualities => const [];
// The current stream quality index
@readonly
int _streamQualityIndex = 0;

View file

@ -102,7 +102,7 @@ packages:
description:
path: "."
ref: frosty-patches
resolved-ref: f088474cec180921e4d22214835d4e2f1b8f4ac0
resolved-ref: "8556673c3939a16061433061106e2a2a1f9af602"
url: "https://github.com/tommyxchow/flutter-native-video-player.git"
source: git
version: "0.4.11"

View file

@ -0,0 +1,78 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/playback_access_token.dart';
Map<String, dynamic> gqlResponse({required String value, String sig = 'sig'}) =>
{
'data': {
'streamPlaybackAccessToken': {'value': value, 'signature': sig},
},
};
void main() {
group('PlaybackAccessToken.fromGqlResponse', () {
test('parses hide_ads and restricted bitrates from token payload', () {
final value = jsonEncode({
'hide_ads': true,
'chansub': {
'restricted_bitrates': [
'1080p60',
'720p60',
'chunked',
'archives',
'sub_archives',
'live',
'audio_only',
],
},
});
final token = PlaybackAccessToken.fromGqlResponse(
gqlResponse(value: value),
);
expect(token.value, value);
expect(token.signature, 'sig');
expect(token.hideAds, isTrue);
// chunked/archives/sub_archives/live are playlist-internal names, not
// real qualities; names merely containing those words must survive.
expect(token.restrictedQualities, [
'1080p60',
'720p60',
'audio_only',
]);
});
test('does not filter qualities that merely contain reserved words', () {
final value = jsonEncode({
'chansub': {
'restricted_bitrates': ['1080p60_live', 'olive', 'rearchives'],
},
});
final token = PlaybackAccessToken.fromGqlResponse(
gqlResponse(value: value),
);
expect(token.restrictedQualities, ['1080p60_live', 'olive', 'rearchives']);
});
test('survives a non-JSON token payload', () {
final token = PlaybackAccessToken.fromGqlResponse(
gqlResponse(value: 'not-json'),
);
expect(token.value, 'not-json');
expect(token.hideAds, isFalse);
expect(token.restrictedQualities, isEmpty);
});
test('throws FormatException when token is missing', () {
expect(
() => PlaybackAccessToken.fromGqlResponse({'data': null}),
throwsFormatException,
);
});
});
}