Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
+735
View File
@@ -0,0 +1,735 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'google_oauth_config_service.dart';
class GoogleSession {
const GoogleSession({
required this.accessToken,
required this.displayName,
this.email,
this.photoUrl,
});
final String accessToken;
final String displayName;
final String? email;
final String? photoUrl;
GoogleSession copyWith({
String? accessToken,
String? displayName,
String? email,
String? photoUrl,
}) {
return GoogleSession(
accessToken: accessToken ?? this.accessToken,
displayName: displayName ?? this.displayName,
email: email ?? this.email,
photoUrl: photoUrl ?? this.photoUrl,
);
}
}
class GoogleAuthConfigurationException implements Exception {
const GoogleAuthConfigurationException(this.message);
final String message;
@override
String toString() => message;
}
class GoogleAuthService {
GoogleAuthService._();
static final GoogleAuthService instance =
GoogleAuthService._();
static const String youtubeReadonlyScope =
'https://www.googleapis.com/auth/youtube.readonly';
static const String _envWebClientId =
String.fromEnvironment(
'GOOGLE_WEB_CLIENT_ID',
);
static const String _envDesktopClientId =
String.fromEnvironment(
'GOOGLE_DESKTOP_CLIENT_ID',
);
static const String _envDesktopClientSecret =
String.fromEnvironment(
'GOOGLE_DESKTOP_CLIENT_SECRET',
);
final ValueNotifier<GoogleSession?> session =
ValueNotifier<GoogleSession?>(null);
bool _googleSignInInitialized = false;
String? _initializedAndroidWebClientId;
GoogleSignInAccount? _androidAccount;
String? _windowsRefreshToken;
DateTime? _windowsAccessTokenExpiry;
GoogleOauthConfig get _savedConfig =>
GoogleOauthConfigService.instance.config.value;
String get _webClientId {
final environment = _envWebClientId.trim();
if (environment.isNotEmpty) {
return environment;
}
return _savedConfig.webClientId.trim();
}
String get _desktopClientId {
final environment = _envDesktopClientId.trim();
if (environment.isNotEmpty) {
return environment;
}
return _savedConfig.desktopClientId.trim();
}
String get _desktopClientSecret {
final environment = _envDesktopClientSecret.trim();
if (environment.isNotEmpty) {
return environment;
}
return _savedConfig.desktopClientSecret.trim();
}
bool get isConfigured {
if (Platform.isAndroid) {
return _webClientId.isNotEmpty;
}
if (Platform.isWindows) {
return _desktopClientId.isNotEmpty;
}
return false;
}
String get configurationHint {
if (Platform.isAndroid) {
return 'Client ID Web OAuth Google manquant';
}
if (Platform.isWindows) {
return 'Client ID OAuth "Desktop app" manquant';
}
return 'Plateforme non prise en charge';
}
Future<void> initialize() async {
await GoogleOauthConfigService.instance.initialize();
if (!Platform.isAndroid || !isConfigured) {
return;
}
await _initializeAndroidGoogleSignIn();
try {
final future =
GoogleSignIn.instance
.attemptLightweightAuthentication();
if (future == null) {
return;
}
final account = await future;
if (account == null) {
return;
}
_androidAccount = account;
final authorization =
await account.authorizationClient
.authorizationForScopes(
const [youtubeReadonlyScope],
);
if (authorization == null) {
return;
}
session.value = GoogleSession(
accessToken: authorization.accessToken,
displayName:
account.displayName ?? account.email,
email: account.email,
photoUrl: account.photoUrl,
);
} catch (error) {
debugPrint(
'[GOOGLE-AUTH] Restauration ignorée: $error',
);
}
}
Future<bool> saveAndroidConfiguration(
String webClientId,
) async {
final previous = _webClientId;
await GoogleOauthConfigService.instance.saveAndroid(
webClientId: webClientId,
);
if (!Platform.isAndroid) {
return true;
}
final next = _webClientId;
if (next.isEmpty) {
return true;
}
if (!_googleSignInInitialized) {
await _initializeAndroidGoogleSignIn();
return true;
}
return previous == next;
}
Future<void> saveWindowsConfiguration({
required String desktopClientId,
required String desktopClientSecret,
}) async {
await GoogleOauthConfigService.instance.saveWindows(
desktopClientId: desktopClientId,
desktopClientSecret: desktopClientSecret,
);
}
Future<GoogleSession> signIn() async {
if (!isConfigured) {
throw GoogleAuthConfigurationException(
configurationHint,
);
}
if (Platform.isAndroid) {
return _signInAndroid();
}
if (Platform.isWindows) {
return _signInWindows();
}
throw const GoogleAuthConfigurationException(
'Connexion Google non prise en charge '
'sur cette plateforme.',
);
}
Future<String> getAccessToken() async {
final current = session.value;
if (current == null) {
throw StateError(
'Aucune session Google active.',
);
}
if (Platform.isAndroid) {
final account = _androidAccount;
if (account != null) {
final authorization =
await account.authorizationClient
.authorizationForScopes(
const [youtubeReadonlyScope],
);
if (authorization != null) {
final nextAccessToken =
authorization.accessToken;
// Ne notifie les listeners que si le jeton a réellement
// changé. Avant, chaque simple lecture du jeton réémettait
// session.value et pouvait relancer le chargement des
// abonnements en boucle.
if (nextAccessToken != current.accessToken) {
session.value = current.copyWith(
accessToken: nextAccessToken,
);
}
return nextAccessToken;
}
}
return current.accessToken;
}
if (Platform.isWindows) {
final expiry = _windowsAccessTokenExpiry;
if (expiry != null &&
DateTime.now().isAfter(
expiry.subtract(
const Duration(minutes: 2),
),
) &&
_windowsRefreshToken != null) {
await _refreshWindowsAccessToken();
}
return session.value?.accessToken ??
current.accessToken;
}
return current.accessToken;
}
Future<void> signOut() async {
if (Platform.isAndroid &&
_googleSignInInitialized) {
try {
await GoogleSignIn.instance.signOut();
} catch (error) {
debugPrint(
'[GOOGLE-AUTH] signOut Android: $error',
);
}
}
_androidAccount = null;
_windowsRefreshToken = null;
_windowsAccessTokenExpiry = null;
session.value = null;
}
Future<void> _initializeAndroidGoogleSignIn() async {
if (_googleSignInInitialized) {
return;
}
final clientId = _webClientId;
if (clientId.isEmpty) {
throw const GoogleAuthConfigurationException(
'Client ID Web OAuth Google manquant.',
);
}
await GoogleSignIn.instance.initialize(
serverClientId: clientId,
);
_googleSignInInitialized = true;
_initializedAndroidWebClientId = clientId;
}
Future<GoogleSession> _signInAndroid() async {
await _initializeAndroidGoogleSignIn();
if (_initializedAndroidWebClientId !=
_webClientId) {
throw const GoogleAuthConfigurationException(
'Le Client ID Android a été modifié. '
'Redémarre l’application avant de te connecter.',
);
}
if (!GoogleSignIn.instance.supportsAuthenticate()) {
throw StateError(
'Google Sign-In interactif '
'n’est pas disponible.',
);
}
final account =
await GoogleSignIn.instance.authenticate(
scopeHint: const [youtubeReadonlyScope],
);
_androidAccount = account;
var authorization =
await account.authorizationClient
.authorizationForScopes(
const [youtubeReadonlyScope],
);
authorization ??=
await account.authorizationClient
.authorizeScopes(
const [youtubeReadonlyScope],
);
final result = GoogleSession(
accessToken: authorization.accessToken,
displayName:
account.displayName ?? account.email,
email: account.email,
photoUrl: account.photoUrl,
);
session.value = result;
return result;
}
Future<GoogleSession> _signInWindows() async {
final server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
0,
);
final redirectUri = Uri.parse(
'http://127.0.0.1:${server.port}/',
);
final verifier = _randomUrlSafeString(96);
final state = _randomUrlSafeString(32);
final challenge = base64UrlEncode(
sha256.convert(ascii.encode(verifier)).bytes,
).replaceAll('=', '');
const windowsScopes = <String>[
'openid',
'email',
'profile',
youtubeReadonlyScope,
];
final authorizationUri = Uri.https(
'accounts.google.com',
'/o/oauth2/v2/auth',
{
'client_id': _desktopClientId,
'redirect_uri': redirectUri.toString(),
'response_type': 'code',
'scope': windowsScopes.join(' '),
'code_challenge': challenge,
'code_challenge_method': 'S256',
'state': state,
'access_type': 'offline',
'prompt': 'consent',
},
);
final launched = await launchUrl(
authorizationUri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
await server.close(force: true);
throw StateError(
'Impossible d’ouvrir le navigateur '
'pour Google.',
);
}
HttpRequest request;
try {
request = await server.first.timeout(
const Duration(minutes: 3),
);
} on TimeoutException {
await server.close(force: true);
throw StateError(
'La connexion Google a expiré.',
);
}
final params = request.uri.queryParameters;
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.html
..write(
'<!doctype html><html><head>'
'<meta charset="utf-8">'
'<title>YouTube Personal</title></head>'
'<body style="font-family:Segoe UI,Arial;'
'padding:40px;background:#111;color:#fff">'
'<h2>Connexion terminée</h2>'
'<p>Tu peux fermer cet onglet et revenir '
'dans YouTube Personal.</p></body></html>',
);
await request.response.close();
await server.close(force: true);
if (params['state'] != state) {
throw StateError(
'Réponse OAuth Google invalide '
'(state incorrect).',
);
}
final error = params['error'];
if (error != null) {
throw StateError(
'Connexion Google refusée: $error',
);
}
final code = params['code'];
if (code == null || code.isEmpty) {
throw StateError(
'Google n’a pas renvoyé de '
'code d’autorisation.',
);
}
final tokenBody = <String, String>{
'client_id': _desktopClientId,
'code': code,
'code_verifier': verifier,
'grant_type': 'authorization_code',
'redirect_uri': redirectUri.toString(),
};
if (_desktopClientSecret.isNotEmpty) {
tokenBody['client_secret'] =
_desktopClientSecret;
}
final tokenResponse = await http.post(
Uri.parse(
'https://oauth2.googleapis.com/token',
),
headers: const {
'Content-Type':
'application/x-www-form-urlencoded',
},
body: tokenBody,
);
if (tokenResponse.statusCode < 200 ||
tokenResponse.statusCode >= 300) {
throw StateError(
'Échange OAuth Google impossible '
'(${tokenResponse.statusCode}): '
'${tokenResponse.body}',
);
}
final decoded = jsonDecode(
tokenResponse.body,
);
if (decoded is! Map) {
throw StateError(
'Réponse OAuth Google invalide.',
);
}
final accessToken = decoded['access_token'];
if (accessToken is! String ||
accessToken.isEmpty) {
throw StateError(
'Aucun jeton d’accès Google reçu.',
);
}
final refreshToken = decoded['refresh_token'];
if (refreshToken is String &&
refreshToken.isNotEmpty) {
_windowsRefreshToken = refreshToken;
}
final expiresIn = decoded['expires_in'];
if (expiresIn is num) {
_windowsAccessTokenExpiry =
DateTime.now().add(
Duration(
seconds: expiresIn.toInt(),
),
);
}
final profile = await _fetchGoogleProfile(
accessToken,
);
final result = GoogleSession(
accessToken: accessToken,
displayName:
profile.name ??
profile.email ??
'Compte Google',
email: profile.email,
photoUrl: profile.photoUrl,
);
session.value = result;
return result;
}
Future<void> _refreshWindowsAccessToken() async {
final refreshToken = _windowsRefreshToken;
if (refreshToken == null ||
refreshToken.isEmpty) {
return;
}
final body = <String, String>{
'client_id': _desktopClientId,
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
};
if (_desktopClientSecret.isNotEmpty) {
body['client_secret'] =
_desktopClientSecret;
}
final response = await http.post(
Uri.parse(
'https://oauth2.googleapis.com/token',
),
headers: const {
'Content-Type':
'application/x-www-form-urlencoded',
},
body: body,
);
if (response.statusCode < 200 ||
response.statusCode >= 300) {
throw StateError(
'Impossible de renouveler '
'la session Google.',
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map ||
decoded['access_token'] is! String) {
throw StateError(
'Réponse de renouvellement '
'Google invalide.',
);
}
final token =
decoded['access_token'] as String;
final expiresIn = decoded['expires_in'];
if (expiresIn is num) {
_windowsAccessTokenExpiry =
DateTime.now().add(
Duration(
seconds: expiresIn.toInt(),
),
);
}
final current = session.value;
if (current != null) {
session.value = current.copyWith(
accessToken: token,
);
}
}
Future<_GoogleProfile> _fetchGoogleProfile(
String accessToken,
) async {
try {
final response = await http.get(
Uri.parse(
'https://www.googleapis.com/oauth2/v3/userinfo',
),
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
},
);
if (response.statusCode < 200 ||
response.statusCode >= 300) {
return const _GoogleProfile();
}
final decoded = jsonDecode(response.body);
if (decoded is! Map) {
return const _GoogleProfile();
}
return _GoogleProfile(
name: decoded['name'] is String
? decoded['name'] as String
: null,
email: decoded['email'] is String
? decoded['email'] as String
: null,
photoUrl: decoded['picture'] is String
? decoded['picture'] as String
: null,
);
} catch (_) {
return const _GoogleProfile();
}
}
String _randomUrlSafeString(int length) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
'abcdefghijklmnopqrstuvwxyz'
'0123456789-._~';
final random = Random.secure();
return List.generate(
length,
(_) => chars[
random.nextInt(chars.length)],
).join();
}
}
class _GoogleProfile {
const _GoogleProfile({
this.name,
this.email,
this.photoUrl,
});
final String? name;
final String? email;
final String? photoUrl;
}