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
@@ -0,0 +1,755 @@
import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import '../models/video_item.dart';
import 'library_service.dart';
import 'sponsor_block_service.dart';
class BackgroundPlaybackSnapshot {
const BackgroundPlaybackSnapshot({
required this.position,
required this.playing,
required this.rate,
});
final Duration position;
final bool playing;
final double rate;
}
class BackgroundPlaybackService {
BackgroundPlaybackService._();
static final BackgroundPlaybackService instance =
BackgroundPlaybackService._();
MediaKitAudioHandler? _handler;
bool get isAvailable => Platform.isAndroid && _handler != null;
Future<void> initialize() async {
if (!Platform.isAndroid || _handler != null) {
return;
}
MediaKitAudioHandler? created;
await AudioService.init(
builder: () {
created = MediaKitAudioHandler();
return created!;
},
config: const AudioServiceConfig(
androidNotificationChannelId:
'com.studioweb.youtube_personal.playback',
androidNotificationChannelName:
'Lecture YouTube Personal',
androidNotificationChannelDescription:
'Contrôles de lecture en arrière-plan',
androidNotificationOngoing: false,
androidStopForegroundOnPause: false,
rewindInterval: Duration(seconds: 10),
fastForwardInterval: Duration(seconds: 10),
),
);
_handler = created;
}
Future<void> attachPlayer(
Player player,
VideoItem video,
) async {
final handler = _handler;
if (handler == null) {
return;
}
await handler.attachForegroundPlayer(
player,
video,
);
}
Future<void> enterBackgroundAudio({
required String url,
required VideoItem video,
required Duration position,
required bool shouldPlay,
required double rate,
}) async {
await _handler?.enterBackgroundAudio(
url: url,
video: video,
position: position,
shouldPlay: shouldPlay,
rate: rate,
);
}
Future<BackgroundPlaybackSnapshot?> exitBackgroundAudio({
required Player foregroundPlayer,
required VideoItem video,
}) async {
return _handler?.exitBackgroundAudio(
foregroundPlayer: foregroundPlayer,
video: video,
);
}
Future<void> detachPlayer() async {
await _handler?.detachPlayer();
}
}
class MediaKitAudioHandler extends BaseAudioHandler
with SeekHandler {
Player? _foregroundPlayer;
Player? _backgroundPlayer;
Player? _activePlayer;
VideoItem? _video;
final List<StreamSubscription<dynamic>> _subscriptions =
<StreamSubscription<dynamic>>[];
bool _backgroundMode = false;
bool _playing = false;
bool _buffering = false;
bool _completed = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
double _speed = 1.0;
Duration _lastPersistedPosition = Duration.zero;
List<SponsorSegment> _backgroundSponsorSegments =
const <SponsorSegment>[];
bool _backgroundSponsorSeekInProgress = false;
String? _backgroundSponsorActiveId;
Future<void> attachForegroundPlayer(
Player player,
VideoItem video,
) async {
_foregroundPlayer = player;
_video = video;
if (_backgroundMode) {
return;
}
await _bindPlayer(
player,
video,
);
}
Future<void> enterBackgroundAudio({
required String url,
required VideoItem video,
required Duration position,
required bool shouldPlay,
required double rate,
}) async {
if (_backgroundMode) {
return;
}
debugPrint(
'[BACKGROUND-AUDIO] switching to dedicated player '
'at ${position.inMilliseconds} ms '
'(shouldPlay=$shouldPlay rate=$rate)',
);
_backgroundMode = true;
_video = video;
unawaited(_prepareBackgroundSponsors(video.id));
await _cancelSubscriptions();
// Garde la MediaSession/foreground service en état actif pendant
// l'ouverture du nouveau Player afin d'éviter qu'Android arrête
// le service entre les deux moteurs.
_playing = shouldPlay;
_buffering = true;
_completed = false;
_position = position;
_duration = video.duration ?? Duration.zero;
_speed = rate;
_setMediaItem(video);
_broadcastState();
final backgroundPlayer = Player(
configuration: const PlayerConfiguration(
vo: 'null',
title: 'YouTube Personal Background',
),
);
_backgroundPlayer = backgroundPlayer;
_activePlayer = backgroundPlayer;
try {
// Ne pas lancer immédiatement : on désactive d'abord explicitement
// la piste vidéo pour que ce Player ne dépende d'aucune Surface Android.
await backgroundPlayer.open(
Media(url),
play: false,
);
await backgroundPlayer.setVideoTrack(
VideoTrack.no(),
);
// Sur certains appareils, open() retourne avant que la timeline du
// média soit réellement prête. Un seek envoyé immédiatement peut alors
// être ignoré et laisser position=0. On attend une durée exploitable.
await _waitUntilMediaReady(
backgroundPlayer,
wantedPosition: position,
fallbackDuration: video.duration,
);
if (position > Duration.zero) {
await _seekAndConfirm(
backgroundPlayer,
position,
);
}
await backgroundPlayer.setRate(rate);
if (shouldPlay) {
await backgroundPlayer.play();
// Confirme aussi que le nouveau Player est réellement passé en
// lecture avant de rendre la main au code écran-verrouillé.
await _waitUntilPlaying(backgroundPlayer);
}
await _bindPlayer(
backgroundPlayer,
video,
);
debugPrint(
'[BACKGROUND-AUDIO] dedicated player ready '
'at ${backgroundPlayer.state.position.inMilliseconds} ms '
'playing=${backgroundPlayer.state.playing}',
);
} catch (error) {
debugPrint(
'[BACKGROUND-AUDIO] dedicated player failed: $error',
);
_backgroundMode = false;
_backgroundPlayer = null;
await backgroundPlayer.dispose();
final foreground = _foregroundPlayer;
if (foreground != null) {
await _bindPlayer(
foreground,
video,
);
} else {
_activePlayer = null;
}
rethrow;
}
}
Future<BackgroundPlaybackSnapshot?> exitBackgroundAudio({
required Player foregroundPlayer,
required VideoItem video,
}) async {
if (!_backgroundMode) {
_foregroundPlayer = foregroundPlayer;
return BackgroundPlaybackSnapshot(
position: foregroundPlayer.state.position,
playing: foregroundPlayer.state.playing,
rate: foregroundPlayer.state.rate,
);
}
final background = _backgroundPlayer;
if (background == null) {
_backgroundMode = false;
_foregroundPlayer = foregroundPlayer;
await _bindPlayer(
foregroundPlayer,
video,
);
return null;
}
final snapshot = BackgroundPlaybackSnapshot(
position: background.state.position,
playing: background.state.playing,
rate: background.state.rate,
);
await LibraryService.instance.savePlaybackPosition(
video.id,
snapshot.position,
duration: video.duration,
);
debugPrint(
'[BACKGROUND-AUDIO] returning to video player '
'at ${snapshot.position.inMilliseconds} ms '
'playing=${snapshot.playing}',
);
// Annule les listeners avant de couper le Player de fond, sinon
// le MediaSession verrait artificiellement une pause.
await _cancelSubscriptions();
_backgroundMode = false;
_backgroundPlayer = null;
_foregroundPlayer = foregroundPlayer;
_activePlayer = foregroundPlayer;
await background.pause();
await background.dispose();
await _bindPlayer(
foregroundPlayer,
video,
);
return snapshot;
}
Future<void> detachPlayer() async {
await _cancelSubscriptions();
final background = _backgroundPlayer;
_backgroundPlayer = null;
if (background != null) {
await background.dispose();
}
_foregroundPlayer = null;
_activePlayer = null;
_video = null;
_backgroundMode = false;
_playing = false;
_buffering = false;
_completed = false;
_position = Duration.zero;
_duration = Duration.zero;
_speed = 1.0;
_lastPersistedPosition = Duration.zero;
_backgroundSponsorSegments = const <SponsorSegment>[];
_backgroundSponsorSeekInProgress = false;
_backgroundSponsorActiveId = null;
mediaItem.add(null);
playbackState.add(
PlaybackState(
processingState: AudioProcessingState.idle,
playing: false,
),
);
}
Future<void> _waitUntilMediaReady(
Player player, {
required Duration wantedPosition,
required Duration? fallbackDuration,
}) async {
final currentDuration = player.state.duration;
if (currentDuration > Duration.zero &&
(wantedPosition <= Duration.zero ||
currentDuration >= wantedPosition)) {
return;
}
try {
await player.stream.duration
.firstWhere(
(duration) =>
duration > Duration.zero &&
(wantedPosition <= Duration.zero ||
duration >= wantedPosition),
)
.timeout(const Duration(seconds: 10));
} on TimeoutException {
// Si youtube_explode nous a déjà fourni une durée cohérente, on peut
// malgré tout tenter le seek. Le contrôle suivant confirmera ou non.
if (fallbackDuration == null ||
fallbackDuration <= Duration.zero ||
(wantedPosition > Duration.zero &&
fallbackDuration < wantedPosition)) {
rethrow;
}
}
}
Future<void> _seekAndConfirm(
Player player,
Duration target,
) async {
const tolerance = Duration(seconds: 2);
for (var attempt = 1; attempt <= 3; attempt++) {
debugPrint(
'[BACKGROUND-AUDIO] seek attempt $attempt -> '
'${target.inMilliseconds} ms',
);
await player.seek(target);
final immediate = player.state.position;
if (_closeEnough(
immediate,
target,
tolerance,
)) {
debugPrint(
'[BACKGROUND-AUDIO] seek confirmed immediately '
'at ${immediate.inMilliseconds} ms',
);
return;
}
try {
final confirmed = await player.stream.position
.firstWhere(
(position) => _closeEnough(
position,
target,
tolerance,
),
)
.timeout(const Duration(seconds: 3));
debugPrint(
'[BACKGROUND-AUDIO] seek confirmed '
'at ${confirmed.inMilliseconds} ms',
);
return;
} on TimeoutException {
debugPrint(
'[BACKGROUND-AUDIO] seek attempt $attempt not confirmed '
'(current=${player.state.position.inMilliseconds} ms)',
);
await Future<void>.delayed(
const Duration(milliseconds: 300),
);
}
}
throw TimeoutException(
'Le Player audio de fond n’a pas confirmé le seek.',
);
}
Future<void> _waitUntilPlaying(Player player) async {
if (player.state.playing) {
return;
}
await player.stream.playing
.firstWhere((playing) => playing)
.timeout(const Duration(seconds: 5));
}
bool _closeEnough(
Duration value,
Duration target,
Duration tolerance,
) {
final delta =
(value.inMilliseconds - target.inMilliseconds).abs();
return delta <= tolerance.inMilliseconds;
}
Future<void> _prepareBackgroundSponsors(String videoId) async {
final segments = await SponsorBlockService.instance
.getSponsorSegments(videoId);
if (_video?.id != videoId) {
return;
}
_backgroundSponsorSegments = segments;
_backgroundSponsorActiveId = null;
}
Future<void> _maybeSkipBackgroundSponsor(
Player player,
Duration position,
) async {
if (!_backgroundMode ||
_backgroundSponsorSeekInProgress ||
_backgroundSponsorSegments.isEmpty) {
return;
}
SponsorSegment? match;
for (final segment in _backgroundSponsorSegments) {
if (segment.contains(position)) {
match = segment;
break;
}
}
if (match == null) {
_backgroundSponsorActiveId = null;
return;
}
if (_backgroundSponsorActiveId == match.id) {
return;
}
_backgroundSponsorActiveId = match.id;
_backgroundSponsorSeekInProgress = true;
try {
final target = match.end + const Duration(milliseconds: 80);
final wasPlaying = player.state.playing;
debugPrint(
'[BACKGROUND-SPONSORBLOCK] skip '
'${match.start.inMilliseconds} -> ${match.end.inMilliseconds} ms',
);
await player.seek(target);
if (wasPlaying) {
await player.play();
}
_position = target;
_lastPersistedPosition = target;
} catch (error) {
debugPrint('[BACKGROUND-SPONSORBLOCK] skip impossible: $error');
} finally {
_backgroundSponsorSeekInProgress = false;
}
}
Future<void> _bindPlayer(
Player player,
VideoItem video,
) async {
await _cancelSubscriptions();
_activePlayer = player;
_video = video;
_playing = player.state.playing;
_buffering = player.state.buffering;
_position = player.state.position;
_duration = video.duration ?? player.state.duration;
_speed = player.state.rate;
_completed = false;
_lastPersistedPosition = _position;
_setMediaItem(video);
_subscriptions.addAll([
player.stream.playing.listen((value) {
_playing = value;
_broadcastState();
}),
player.stream.buffering.listen((value) {
_buffering = value;
_broadcastState();
}),
player.stream.position.listen((value) {
_position = value;
if (_backgroundMode) {
unawaited(_maybeSkipBackgroundSponsor(player, value));
}
if (_backgroundMode &&
(_position - _lastPersistedPosition).abs() >=
const Duration(seconds: 5)) {
_lastPersistedPosition = _position;
unawaited(
LibraryService.instance.savePlaybackPosition(
video.id,
_position,
duration: video.duration,
),
);
}
_broadcastState();
}),
player.stream.duration.listen((value) {
if (value > Duration.zero) {
_duration = value;
}
_broadcastState();
}),
player.stream.rate.listen((value) {
_speed = value;
_broadcastState();
}),
player.stream.completed.listen((value) {
_completed = value;
_broadcastState();
}),
]);
_broadcastState();
}
void _setMediaItem(VideoItem video) {
mediaItem.add(
MediaItem(
id: video.id,
title: video.title,
artist: video.author,
duration: video.duration,
artUri: Uri.tryParse(video.thumbnailUrl),
),
);
}
Future<void> _cancelSubscriptions() async {
for (final subscription in _subscriptions) {
await subscription.cancel();
}
_subscriptions.clear();
}
void _broadcastState() {
final processingState = _completed
? AudioProcessingState.completed
: _buffering
? AudioProcessingState.buffering
: AudioProcessingState.ready;
playbackState.add(
PlaybackState(
controls: [
MediaControl.rewind,
_playing ? MediaControl.pause : MediaControl.play,
MediaControl.fastForward,
],
androidCompactActionIndices: const [0, 1, 2],
systemActions: const {
MediaAction.seek,
MediaAction.setSpeed,
},
processingState: processingState,
playing: _playing,
updatePosition: _position,
bufferedPosition: Duration.zero,
speed: _speed,
),
);
}
@override
Future<void> play() async {
await _activePlayer?.play();
}
@override
Future<void> pause() async {
await _activePlayer?.pause();
}
@override
Future<void> seek(Duration position) async {
final target = _clampPosition(position);
await _activePlayer?.seek(target);
_position = target;
final video = _video;
if (_backgroundMode && video != null) {
_lastPersistedPosition = target;
await LibraryService.instance.savePlaybackPosition(
video.id,
target,
duration: video.duration,
);
}
_broadcastState();
}
@override
Future<void> rewind() async {
await seek(
_position - const Duration(seconds: 10),
);
}
@override
Future<void> fastForward() async {
await seek(
_position + const Duration(seconds: 10),
);
}
@override
Future<void> setSpeed(double speed) async {
await _activePlayer?.setRate(speed);
}
@override
Future<void> stop() async {
await _activePlayer?.stop();
_playing = false;
_completed = false;
_position = Duration.zero;
playbackState.add(
PlaybackState(
processingState: AudioProcessingState.idle,
playing: false,
),
);
await super.stop();
}
Duration _clampPosition(Duration value) {
if (value < Duration.zero) {
return Duration.zero;
}
if (_duration > Duration.zero && value > _duration) {
return _duration;
}
return value;
}
}