Something went wrong. Try again.
🎥 Command line media player
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278/* * This file is part of mpv. * * mpv is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * mpv is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with mpv. If not, see <http://www.gnu.org/licenses/>. */
import Cocoaimport MediaPlayerimport QuickLookThumbnailing
extension RemoteCommandCenter { typealias ConfigHandler = (MPRemoteCommandEvent) -> (MPRemoteCommandHandlerStatus)
enum KeyType { case normal case repeatable }
struct Config { let key: Int32 let type: KeyType var state: NSEvent.EventType = .applicationDefined let handler: ConfigHandler
init(key: Int32 = 0, type: KeyType = .normal, handler: @escaping ConfigHandler = { _ in return .commandFailed }) { self.key = key self.type = type self.handler = handler } }}
class RemoteCommandCenter: EventSubscriber { unowned let appHub: AppHub var event: EventHelper? { return appHub.event } var input: InputHelper { return appHub.input } var configs: [MPRemoteCommand: Config] = [:] var disabledCommands: [MPRemoteCommand] = [] var isPaused: Bool = false { didSet { updateInfoCenter() } } var duration: Double = 0 { didSet { updateInfoCenter() } } var position: Double = 0 { didSet { updateInfoCenter() } } var rate: Double = 1 { didSet { updateInfoCenter() } } var title: String = "" { didSet { updateInfoCenter() } } var chapter: String? { didSet { updateInfoCenter() } } var album: String? { didSet { updateInfoCenter() } } var artist: String? { didSet { updateInfoCenter() } } var path: String?
let coverLock = NSLock() var coverTime: UInt64 = mach_absolute_time() var coverPath: String? var cover: NSImage? { didSet { updateInfoCenter() } } var coverThumb: NSImage? { didSet { updateInfoCenter() } } var defaultCover: NSImage
let queue: DispatchQueue = DispatchQueue(label: "io.mpv.remote.queue") var infoCenter: MPNowPlayingInfoCenter { return MPNowPlayingInfoCenter.default() } var commandCenter: MPRemoteCommandCenter { return MPRemoteCommandCenter.shared() }
init(_ appHub: AppHub) { self.appHub = appHub defaultCover = appHub.getIcon()
configs = [ commandCenter.pauseCommand: Config(key: MP_KEY_PAUSEONLY, handler: keyHandler), commandCenter.playCommand: Config(key: MP_KEY_PLAYONLY, handler: keyHandler), commandCenter.stopCommand: Config(key: MP_KEY_STOP, handler: keyHandler), commandCenter.nextTrackCommand: Config(key: MP_KEY_NEXT, handler: keyHandler), commandCenter.previousTrackCommand: Config(key: MP_KEY_PREV, handler: keyHandler), commandCenter.togglePlayPauseCommand: Config(key: MP_KEY_PLAY, handler: keyHandler), commandCenter.seekForwardCommand: Config(key: MP_KEY_FORWARD, type: .repeatable, handler: keyHandler), commandCenter.seekBackwardCommand: Config(key: MP_KEY_REWIND, type: .repeatable, handler: keyHandler), commandCenter.changePlaybackPositionCommand: Config(handler: seekHandler) ]
disabledCommands = [ commandCenter.changePlaybackRateCommand, commandCenter.changeRepeatModeCommand, commandCenter.changeShuffleModeCommand, commandCenter.skipForwardCommand, commandCenter.skipBackwardCommand, commandCenter.enableLanguageOptionCommand, commandCenter.disableLanguageOptionCommand, commandCenter.ratingCommand, commandCenter.likeCommand, commandCenter.dislikeCommand, commandCenter.bookmarkCommand ]
for cmd in disabledCommands { cmd.isEnabled = false } }
func registerEvents() { event?.subscribe(self, event: .init(name: "duration", format: MPV_FORMAT_DOUBLE)) event?.subscribe(self, event: .init(name: "time-pos", format: MPV_FORMAT_DOUBLE)) event?.subscribe(self, event: .init(name: "speed", format: MPV_FORMAT_DOUBLE)) event?.subscribe(self, event: .init(name: "pause", format: MPV_FORMAT_FLAG)) event?.subscribe(self, event: .init(name: "media-title", format: MPV_FORMAT_STRING)) event?.subscribe(self, event: .init(name: "chapter-metadata/title", format: MPV_FORMAT_STRING)) event?.subscribe(self, event: .init(name: "metadata/by-key/album", format: MPV_FORMAT_STRING)) event?.subscribe(self, event: .init(name: "metadata/by-key/artist", format: MPV_FORMAT_STRING)) event?.subscribe(self, event: .init(name: "path", format: MPV_FORMAT_STRING)) event?.subscribe(self, event: .init(name: "track-list", format: MPV_FORMAT_NODE)) }
func start() { for (cmd, config) in configs { cmd.isEnabled = true cmd.addTarget(handler: config.handler) }
updateInfoCenter()
NotificationCenter.default.addObserver( forName: NSApplication.willBecomeActiveNotification, object: nil, queue: nil) { _ in self.makeCurrent() } }
func stop() { for (cmd, _) in configs { cmd.isEnabled = false cmd.removeTarget(nil) }
infoCenter.nowPlayingInfo = nil infoCenter.playbackState = .unknown
NotificationCenter.default.removeObserver( self, name: NSApplication.willBecomeActiveNotification, object: nil ) }
func makeCurrent() { infoCenter.playbackState = .paused infoCenter.playbackState = .playing updateInfoCenter() }
func updateInfoCenter() { let cover = cover ?? coverThumb ?? defaultCover infoCenter.playbackState = isPaused ? .paused : .playing infoCenter.nowPlayingInfo = (infoCenter.nowPlayingInfo ?? [:]).merging([ MPNowPlayingInfoPropertyMediaType: NSNumber(value: MPNowPlayingInfoMediaType.video.rawValue), MPNowPlayingInfoPropertyPlaybackProgress: NSNumber(value: 0.0), MPNowPlayingInfoPropertyPlaybackRate: NSNumber(value: isPaused ? 0 : rate), MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: position), MPMediaItemPropertyPlaybackDuration: NSNumber(value: duration), MPMediaItemPropertyTitle: title, MPMediaItemPropertyArtist: artist ?? chapter ?? "", MPMediaItemPropertyAlbumTitle: album ?? "", MPMediaItemPropertyArtwork: MPMediaItemArtwork(boundsSize: cover.size) { _ in return cover } ]) { (_, new) in new } }
func updateCover(tracks: [Any?]) { coverLock.withLock { coverTime = mach_absolute_time() coverPath = nil cover = nil coverThumb = nil
// read cover image on separate thread queue.async { self.generateCover(tracks: tracks, time: self.coverTime) } generateCoverThumb(time: self.coverTime) } }
func generateCover(tracks: [Any?], time: UInt64) { var imageCoverPath: String? var externalCoverPath: String?
for item in tracks { guard let track = item as? [String: Any?] else { continue } if (track["image"] as? Bool) == true { // opened file is an image if track["external"] as? Bool == false && track["albumart"] as? Bool == false && imageCoverPath == nil { imageCoverPath = path continue } // external cover if let filename = track["external-filename"] as? String, externalCoverPath == nil { externalCoverPath = filename continue } } }
guard let path = imageCoverPath ?? externalCoverPath, coverPath != path else { return }
var image = NSImage(contentsOf: URL(fileURLWithPath: path)) if let url = URL(string: path), image == nil { image = NSImage(contentsOf: url) }
coverLock.withLock { guard time == coverTime else { return } cover = image coverPath = path } }
func generateCoverThumb(time: UInt64) { guard let path = path else { return }
let request = QLThumbnailGenerator.Request(fileAt: URL(fileURLWithPath: path), size: CGSize(width: 2000, height: 2000), scale: 1, representationTypes: .all) QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { thumbnail, _ in self.coverLock.withLock { guard time == self.coverTime else { return } guard let image = thumbnail?.nsImage, thumbnail?.type != .icon else { return } self.coverThumb = image } } }
lazy var keyHandler: ConfigHandler = { event in guard let config = self.configs[event.command] else { return .commandFailed }
var state = config.state if config.type == .repeatable { state = config.state == .keyDown ? .keyUp : .keyDown self.configs[event.command]?.state = state } self.input.put(key: config.key, type: state)
return .success }
lazy var seekHandler: ConfigHandler = { event in guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
let cmd = String(format: "seek %.02f absolute", posEvent.positionTime) return self.input.command(cmd) ? .success : .commandFailed }
func handle(event: EventHelper.Event) { switch event.name { case "time-pos": let newPosition = max(event.double ?? 0, 0) if Int((floor(newPosition) - floor(position)) / rate) != 0 { position = newPosition } case "pause": isPaused = event.bool ?? false case "duration": duration = event.double ?? 0 case "speed": rate = event.double ?? 1 case "media-title": title = event.string ?? "" case "chapter-metadata/title": chapter = event.string case "metadata/by-key/album": album = event.string case "metadata/by-key/artist": artist = event.string case "path": path = event.string case "track-list": updateCover(tracks: event.array) default: break } }}