Files
youtube_personal/lib/services/background_playback_service.dart
T

756 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}