756 lines
18 KiB
Dart
756 lines
18 KiB
Dart
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;
|
||
}
|
||
}
|