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 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 attachPlayer( Player player, VideoItem video, ) async { final handler = _handler; if (handler == null) { return; } await handler.attachForegroundPlayer( player, video, ); } Future 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 exitBackgroundAudio({ required Player foregroundPlayer, required VideoItem video, }) async { return _handler?.exitBackgroundAudio( foregroundPlayer: foregroundPlayer, video: video, ); } Future detachPlayer() async { await _handler?.detachPlayer(); } } class MediaKitAudioHandler extends BaseAudioHandler with SeekHandler { Player? _foregroundPlayer; Player? _backgroundPlayer; Player? _activePlayer; VideoItem? _video; final List> _subscriptions = >[]; 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 _backgroundSponsorSegments = const []; bool _backgroundSponsorSeekInProgress = false; String? _backgroundSponsorActiveId; Future attachForegroundPlayer( Player player, VideoItem video, ) async { _foregroundPlayer = player; _video = video; if (_backgroundMode) { return; } await _bindPlayer( player, video, ); } Future 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 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 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 []; _backgroundSponsorSeekInProgress = false; _backgroundSponsorActiveId = null; mediaItem.add(null); playbackState.add( PlaybackState( processingState: AudioProcessingState.idle, playing: false, ), ); } Future _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 _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.delayed( const Duration(milliseconds: 300), ); } } throw TimeoutException( 'Le Player audio de fond n’a pas confirmé le seek.', ); } Future _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 _prepareBackgroundSponsors(String videoId) async { final segments = await SponsorBlockService.instance .getSponsorSegments(videoId); if (_video?.id != videoId) { return; } _backgroundSponsorSegments = segments; _backgroundSponsorActiveId = null; } Future _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 _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 _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 play() async { await _activePlayer?.play(); } @override Future pause() async { await _activePlayer?.pause(); } @override Future 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 rewind() async { await seek( _position - const Duration(seconds: 10), ); } @override Future fastForward() async { await seek( _position + const Duration(seconds: 10), ); } @override Future setSpeed(double speed) async { await _activePlayer?.setRate(speed); } @override Future 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; } }