mirror of
				https://github.com/yattee/yattee.git
				synced 2025-11-04 06:32:03 +00:00 
			
		
		
		
	Playback modes
This commit is contained in:
		@@ -492,26 +492,7 @@ final class AVPlayerBackend: PlayerBackend {
 | 
			
		||||
            model.prepareCurrentItemForHistory(finished: true)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if model.queue.isEmpty {
 | 
			
		||||
            #if !os(macOS)
 | 
			
		||||
                try? AVAudioSession.sharedInstance().setActive(false)
 | 
			
		||||
            #endif
 | 
			
		||||
            if Defaults[.closeLastItemOnPlaybackEnd] {
 | 
			
		||||
                model.resetQueue()
 | 
			
		||||
                #if os(tvOS)
 | 
			
		||||
                    controller?.playerView.dismiss(animated: false) { [weak self] in
 | 
			
		||||
                        self?.controller?.dismiss(animated: true)
 | 
			
		||||
                    }
 | 
			
		||||
                #else
 | 
			
		||||
                    model.hide()
 | 
			
		||||
                #endif
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            if model.playingInPictureInPicture {
 | 
			
		||||
                startPictureInPictureOnPlay = true
 | 
			
		||||
            }
 | 
			
		||||
            model.advanceToNextItem()
 | 
			
		||||
        }
 | 
			
		||||
        eofPlaybackModeAction()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func addFrequentTimeObserver() {
 | 
			
		||||
 
 | 
			
		||||
@@ -444,22 +444,7 @@ final class MPVBackend: PlayerBackend {
 | 
			
		||||
 | 
			
		||||
        getClientUpdates()
 | 
			
		||||
 | 
			
		||||
        if Defaults[.closeLastItemOnPlaybackEnd] {
 | 
			
		||||
            model.prepareCurrentItemForHistory(finished: true)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if model.queue.isEmpty {
 | 
			
		||||
            #if !os(macOS)
 | 
			
		||||
                try? AVAudioSession.sharedInstance().setActive(false)
 | 
			
		||||
            #endif
 | 
			
		||||
 | 
			
		||||
            if Defaults[.closeLastItemOnPlaybackEnd] {
 | 
			
		||||
                model.resetQueue()
 | 
			
		||||
                model.hide()
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            model.advanceToNextItem()
 | 
			
		||||
        }
 | 
			
		||||
        eofPlaybackModeAction()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func setNeedsDrawing(_ needsDrawing: Bool) {
 | 
			
		||||
 
 | 
			
		||||
@@ -72,4 +72,30 @@ extension PlayerBackend {
 | 
			
		||||
    func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
 | 
			
		||||
        seek(relative: time, completionHandler: completionHandler)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func eofPlaybackModeAction() {
 | 
			
		||||
        switch model.playbackMode {
 | 
			
		||||
        case .queue, .shuffle:
 | 
			
		||||
            if Defaults[.closeLastItemOnPlaybackEnd] {
 | 
			
		||||
                model.prepareCurrentItemForHistory(finished: true)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if model.queue.isEmpty {
 | 
			
		||||
                if Defaults[.closeLastItemOnPlaybackEnd] {
 | 
			
		||||
                    model.resetQueue()
 | 
			
		||||
                    model.hide()
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                model.advanceToNextItem()
 | 
			
		||||
            }
 | 
			
		||||
        case .loopOne:
 | 
			
		||||
            model.backend.seek(to: .zero) { _ in
 | 
			
		||||
                self.model.play()
 | 
			
		||||
            }
 | 
			
		||||
        case .related:
 | 
			
		||||
            guard let item = model.autoplayItem else { return }
 | 
			
		||||
            model.resetAutoplay()
 | 
			
		||||
            model.advanceToItem(item)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,23 @@ import SwiftyJSON
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
final class PlayerModel: ObservableObject {
 | 
			
		||||
    enum PlaybackMode: String, CaseIterable, Defaults.Serializable {
 | 
			
		||||
        case queue, shuffle, loopOne, related
 | 
			
		||||
 | 
			
		||||
        var systemImage: String {
 | 
			
		||||
            switch self {
 | 
			
		||||
            case .queue:
 | 
			
		||||
                return "list.number"
 | 
			
		||||
            case .shuffle:
 | 
			
		||||
                return "shuffle"
 | 
			
		||||
            case .loopOne:
 | 
			
		||||
                return "repeat.1"
 | 
			
		||||
            case .related:
 | 
			
		||||
                return "infinity"
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
 | 
			
		||||
    let logger = Logger(label: "stream.yattee.app")
 | 
			
		||||
 | 
			
		||||
@@ -65,6 +82,11 @@ final class PlayerModel: ObservableObject {
 | 
			
		||||
    @Published var restoredSegments = [Segment]()
 | 
			
		||||
 | 
			
		||||
    @Published var musicMode = false
 | 
			
		||||
    @Published var playbackMode = PlaybackMode.queue { didSet { handlePlaybackModeChange() }}
 | 
			
		||||
    @Published var autoplayItem: PlayerQueueItem?
 | 
			
		||||
    @Published var autoplayItemSource: Video?
 | 
			
		||||
    @Published var advancing = false
 | 
			
		||||
 | 
			
		||||
    @Published var returnYouTubeDislike = ReturnYouTubeDislikeAPI()
 | 
			
		||||
 | 
			
		||||
    @Published var isSeeking = false { didSet {
 | 
			
		||||
@@ -160,6 +182,7 @@ final class PlayerModel: ObservableObject {
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        Defaults[.activeBackend] = .mpv
 | 
			
		||||
        playbackMode = Defaults[.playbackMode]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func show() {
 | 
			
		||||
@@ -489,6 +512,7 @@ final class PlayerModel: ObservableObject {
 | 
			
		||||
 | 
			
		||||
        backend.closeItem()
 | 
			
		||||
        aspectRatio = VideoPlayerView.defaultAspectRatio
 | 
			
		||||
        resetAutoplay()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func closePiP() {
 | 
			
		||||
@@ -518,10 +542,50 @@ final class PlayerModel: ObservableObject {
 | 
			
		||||
        #endif
 | 
			
		||||
 | 
			
		||||
        DispatchQueue.main.async(qos: .background) { [weak self] in
 | 
			
		||||
            Defaults[.lastPlayed] = self?.currentItem
 | 
			
		||||
            guard let self = self else { return }
 | 
			
		||||
            Defaults[.lastPlayed] = self.currentItem
 | 
			
		||||
 | 
			
		||||
            if self.playbackMode == .related,
 | 
			
		||||
               let video = self.currentVideo,
 | 
			
		||||
               self.autoplayItemSource.isNil || self.autoplayItemSource?.videoID != video.videoID
 | 
			
		||||
            {
 | 
			
		||||
                self.setRelatedAutoplayItem()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func handlePlaybackModeChange() {
 | 
			
		||||
        Defaults[.playbackMode] = playbackMode
 | 
			
		||||
 | 
			
		||||
        guard playbackMode == .related else {
 | 
			
		||||
            autoplayItem = nil
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        setRelatedAutoplayItem()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func setRelatedAutoplayItem() {
 | 
			
		||||
        guard let video = currentVideo?.related.randomElement() else { return }
 | 
			
		||||
 | 
			
		||||
        let item = PlayerQueueItem(video)
 | 
			
		||||
        autoplayItem = item
 | 
			
		||||
        autoplayItemSource = video
 | 
			
		||||
 | 
			
		||||
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
 | 
			
		||||
            guard let self = self else { return }
 | 
			
		||||
            self.accounts.api.loadDetails(item, completionHandler: { newItem in
 | 
			
		||||
                guard newItem.videoID == self.autoplayItem?.videoID else { return }
 | 
			
		||||
                self.autoplayItem = newItem
 | 
			
		||||
                self.controls.objectWillChange.send()
 | 
			
		||||
            })
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func resetAutoplay() {
 | 
			
		||||
        autoplayItem = nil
 | 
			
		||||
        autoplayItemSource = nil
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #if os(macOS)
 | 
			
		||||
        var windowTitle: String {
 | 
			
		||||
            currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)"
 | 
			
		||||
 
 | 
			
		||||
@@ -8,20 +8,16 @@ extension PlayerModel {
 | 
			
		||||
        currentItem?.video
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func play(_ videos: [Video], shuffling: Bool = false) {
 | 
			
		||||
        let videosToPlay = shuffling ? videos.shuffled() : videos
 | 
			
		||||
 | 
			
		||||
        guard let first = videosToPlay.first else {
 | 
			
		||||
            return
 | 
			
		||||
    func play(_ videos: [Video]) {
 | 
			
		||||
        videos.forEach { video in
 | 
			
		||||
            enqueueVideo(video, loadDetails: false)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        enqueueVideo(first, prepending: true) { _, item in
 | 
			
		||||
            self.advanceToItem(item)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        videosToPlay.dropFirst().reversed().forEach { video in
 | 
			
		||||
            enqueueVideo(video, prepending: true, loadDetails: false)
 | 
			
		||||
        }
 | 
			
		||||
        #if os(iOS)
 | 
			
		||||
            onPresentPlayer = { [weak self] in self?.advanceToNextItem() }
 | 
			
		||||
        #else
 | 
			
		||||
            advanceToNextItem()
 | 
			
		||||
        #endif
 | 
			
		||||
 | 
			
		||||
        show()
 | 
			
		||||
    }
 | 
			
		||||
@@ -43,6 +39,8 @@ extension PlayerModel {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func playItem(_ item: PlayerQueueItem, at time: CMTime? = nil) {
 | 
			
		||||
        advancing = false
 | 
			
		||||
 | 
			
		||||
        if !playingInPictureInPicture {
 | 
			
		||||
            backend.closeItem()
 | 
			
		||||
        }
 | 
			
		||||
@@ -79,13 +77,42 @@ extension PlayerModel {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func advanceToNextItem() {
 | 
			
		||||
        guard !advancing else {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        advancing = true
 | 
			
		||||
        prepareCurrentItemForHistory()
 | 
			
		||||
 | 
			
		||||
        if let nextItem = queue.first {
 | 
			
		||||
        var nextItem: PlayerQueueItem?
 | 
			
		||||
        switch playbackMode {
 | 
			
		||||
        case .queue:
 | 
			
		||||
            nextItem = queue.first
 | 
			
		||||
        case .shuffle:
 | 
			
		||||
            nextItem = queue.randomElement()
 | 
			
		||||
        case .related:
 | 
			
		||||
            nextItem = autoplayItem
 | 
			
		||||
        case .loopOne:
 | 
			
		||||
            nextItem = nil
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        resetAutoplay()
 | 
			
		||||
 | 
			
		||||
        if let nextItem = nextItem {
 | 
			
		||||
            advanceToItem(nextItem)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var isAdvanceToNextItemAvailable: Bool {
 | 
			
		||||
        switch playbackMode {
 | 
			
		||||
        case .loopOne:
 | 
			
		||||
            return false
 | 
			
		||||
        case .queue, .shuffle:
 | 
			
		||||
            return !queue.isEmpty
 | 
			
		||||
        case .related:
 | 
			
		||||
            return !autoplayItem.isNil
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func advanceToItem(_ newItem: PlayerQueueItem, at time: CMTime? = nil) {
 | 
			
		||||
        prepareCurrentItemForHistory()
 | 
			
		||||
 | 
			
		||||
@@ -206,6 +233,7 @@ extension PlayerModel {
 | 
			
		||||
 | 
			
		||||
    private func videoLoadFailureHandler(_ error: RequestError) {
 | 
			
		||||
        navigation.presentAlert(title: "Could not load video", message: error.userMessage)
 | 
			
		||||
        advancing = false
 | 
			
		||||
        videoBeingOpened = nil
 | 
			
		||||
        currentItem = nil
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -43,22 +43,7 @@ extension PlayerModel {
 | 
			
		||||
 | 
			
		||||
                self.pause()
 | 
			
		||||
 | 
			
		||||
                if Defaults[.closeLastItemOnPlaybackEnd] {
 | 
			
		||||
                    self.prepareCurrentItemForHistory(finished: true)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if self.queue.isEmpty {
 | 
			
		||||
                    #if !os(macOS)
 | 
			
		||||
                        try? AVAudioSession.sharedInstance().setActive(false)
 | 
			
		||||
                    #endif
 | 
			
		||||
 | 
			
		||||
                    if Defaults[.closeLastItemOnPlaybackEnd] {
 | 
			
		||||
                        self.resetQueue()
 | 
			
		||||
                        self.hide()
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    self.advanceToNextItem()
 | 
			
		||||
                }
 | 
			
		||||
                self.backend.eofPlaybackModeAction()
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return
 | 
			
		||||
 
 | 
			
		||||
@@ -74,6 +74,7 @@ extension Defaults.Keys {
 | 
			
		||||
 | 
			
		||||
    static let queue = Key<[PlayerQueueItem]>("queue", default: [])
 | 
			
		||||
    static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
 | 
			
		||||
    static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
 | 
			
		||||
 | 
			
		||||
    static let saveHistory = Key<Bool>("saveHistory", default: true)
 | 
			
		||||
    static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
 | 
			
		||||
 
 | 
			
		||||
@@ -18,9 +18,6 @@ struct AppSidebarPlaylists: View {
 | 
			
		||||
                    Button("Play All") {
 | 
			
		||||
                        player.play(playlists.find(id: playlist.id)?.videos ?? [])
 | 
			
		||||
                    }
 | 
			
		||||
                    Button("Shuffle All") {
 | 
			
		||||
                        player.play(playlists.find(id: playlist.id)?.videos ?? [], shuffling: true)
 | 
			
		||||
                    }
 | 
			
		||||
                    Button("Edit") {
 | 
			
		||||
                        navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))
 | 
			
		||||
                    }
 | 
			
		||||
 
 | 
			
		||||
@@ -275,6 +275,7 @@ struct PlayerControls: View {
 | 
			
		||||
            Spacer()
 | 
			
		||||
 | 
			
		||||
            HStack(spacing: 20) {
 | 
			
		||||
                playbackModeButton
 | 
			
		||||
                restartVideoButton
 | 
			
		||||
                advanceToNextItemButton
 | 
			
		||||
                #if !os(tvOS)
 | 
			
		||||
@@ -286,6 +287,12 @@ struct PlayerControls: View {
 | 
			
		||||
        .font(.system(size: 20))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var playbackModeButton: some View {
 | 
			
		||||
        button("Playback Mode", systemImage: player.playbackMode.systemImage, background: false) {
 | 
			
		||||
            player.playbackMode = player.playbackMode.next()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var seekBackwardButton: some View {
 | 
			
		||||
        button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) {
 | 
			
		||||
            player.backend.seek(relative: .secondsInDefaultTimescale(-10))
 | 
			
		||||
@@ -337,7 +344,7 @@ struct PlayerControls: View {
 | 
			
		||||
        button("Next", systemImage: "forward.fill", size: 25, cornerRadius: 5, background: false) {
 | 
			
		||||
            player.advanceToNextItem()
 | 
			
		||||
        }
 | 
			
		||||
        .disabled(player.queue.isEmpty)
 | 
			
		||||
        .disabled(!player.isAdvanceToNextItemAvailable)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func button(
 | 
			
		||||
 
 | 
			
		||||
@@ -80,12 +80,8 @@ struct PlaylistsView: View {
 | 
			
		||||
                Spacer()
 | 
			
		||||
 | 
			
		||||
                if currentPlaylist != nil {
 | 
			
		||||
                    HStack(spacing: 0) {
 | 
			
		||||
                        playButton
 | 
			
		||||
 | 
			
		||||
                        shuffleButton
 | 
			
		||||
                    }
 | 
			
		||||
                    .offset(x: 10)
 | 
			
		||||
                    playButton
 | 
			
		||||
                        .offset(x: 10)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .padding(.horizontal)
 | 
			
		||||
@@ -180,7 +176,6 @@ struct PlaylistsView: View {
 | 
			
		||||
                        .labelStyle(.iconOnly)
 | 
			
		||||
 | 
			
		||||
                    playButton
 | 
			
		||||
                    shuffleButton
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                Spacer()
 | 
			
		||||
@@ -293,6 +288,7 @@ struct PlaylistsView: View {
 | 
			
		||||
 | 
			
		||||
    private var playButton: some View {
 | 
			
		||||
        Button {
 | 
			
		||||
            player.playbackMode = .queue
 | 
			
		||||
            player.play(items.compactMap(\.video))
 | 
			
		||||
        } label: {
 | 
			
		||||
            Image(systemName: "play")
 | 
			
		||||
@@ -301,16 +297,6 @@ struct PlaylistsView: View {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var shuffleButton: some View {
 | 
			
		||||
        Button {
 | 
			
		||||
            player.play(items.compactMap(\.video), shuffling: true)
 | 
			
		||||
        } label: {
 | 
			
		||||
            Image(systemName: "shuffle")
 | 
			
		||||
                .padding(8)
 | 
			
		||||
                .contentShape(Rectangle())
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var currentPlaylist: Playlist? {
 | 
			
		||||
        model.find(id: selectedPlaylistID) ?? model.all.first
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -86,8 +86,6 @@ struct ChannelPlaylistView: View {
 | 
			
		||||
 | 
			
		||||
                    playButton
 | 
			
		||||
                        .labelStyle(.iconOnly)
 | 
			
		||||
                    shuffleButton
 | 
			
		||||
                        .labelStyle(.iconOnly)
 | 
			
		||||
                }
 | 
			
		||||
            #endif
 | 
			
		||||
            VerticalCells(items: items)
 | 
			
		||||
@@ -119,7 +117,6 @@ struct ChannelPlaylistView: View {
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    playButton
 | 
			
		||||
                    shuffleButton
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
@@ -137,20 +134,13 @@ struct ChannelPlaylistView: View {
 | 
			
		||||
 | 
			
		||||
    private var playButton: some View {
 | 
			
		||||
        Button {
 | 
			
		||||
            player.playbackMode = .queue
 | 
			
		||||
            player.play(videos)
 | 
			
		||||
        } label: {
 | 
			
		||||
            Label("Play All", systemImage: "play")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var shuffleButton: some View {
 | 
			
		||||
        Button {
 | 
			
		||||
            player.play(videos, shuffling: true)
 | 
			
		||||
        } label: {
 | 
			
		||||
            Label("Shuffle", systemImage: "shuffle")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var videos: [Video] {
 | 
			
		||||
        items.compactMap(\.video)
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -109,7 +109,7 @@ struct ControlsBar: View {
 | 
			
		||||
                    .frame(maxWidth: .infinity)
 | 
			
		||||
                    .contentShape(Rectangle())
 | 
			
		||||
            }
 | 
			
		||||
            .disabled(model.queue.isEmpty)
 | 
			
		||||
            .disabled(!model.isAdvanceToNextItemAvailable)
 | 
			
		||||
 | 
			
		||||
            Button {
 | 
			
		||||
                model.closeCurrentItem()
 | 
			
		||||
 
 | 
			
		||||
@@ -65,16 +65,11 @@ struct PlaylistVideosView: View {
 | 
			
		||||
                    FavoriteButton(item: FavoriteItem(section: .channelPlaylist(playlist.id, playlist.title)))
 | 
			
		||||
 | 
			
		||||
                    Button {
 | 
			
		||||
                        player.playbackMode = .queue
 | 
			
		||||
                        player.play(videos)
 | 
			
		||||
                    } label: {
 | 
			
		||||
                        Label("Play All", systemImage: "play")
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    Button {
 | 
			
		||||
                        player.play(videos, shuffling: true)
 | 
			
		||||
                    } label: {
 | 
			
		||||
                        Label("Shuffle", systemImage: "shuffle")
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user