Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user