Implementing Lock Screen and Control Center Media Controls
Media controls on lock screen — not optional feature but expected standard for any media player. Without them, user forced to unlock phone, find app and press pause instead of doing this with one tap right on Lock Screen.
iOS: MPNowPlayingInfoCenter and MPRemoteCommandCenter
Two independent objects: first handles metadata (what to show), second — commands (what to do on button click).
// Metadata
var nowPlayingInfo: [String: Any] = [:]
nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
nowPlayingInfo[MPMediaItemPropertyArtist] = track.artist
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = track.duration
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate // 0.0 pause, 1.0 play
// Artwork — load image asynchronously
let artwork = MPMediaItemArtwork(boundsSize: CGSize(width: 300, height: 300)) { size in
return self.trackArtworkImage ?? UIImage(named: "placeholder")!
}
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
MPNowPlayingInfoPropertyElapsedPlaybackTime — current position in track. If not update on seek, Lock Screen progress bar diverges from reality. Update after each seek and every 5–10 seconds via timer.
// Commands
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { [weak self] _ in
self?.player.play()
return .success
}
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.player.pause()
return .success
}
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
self?.playNext()
return .success
}
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
self?.player.seek(to: CMTime(seconds: e.positionTime, preferredTimescale: 600))
return .success
}
changePlaybackPositionCommand — slider on Lock Screen. Without it, user can't seek without opening app.
Commands need enable/disable by context: if playlist has one track — commandCenter.nextTrackCommand.isEnabled = false.
Android: MediaSession and MediaNotification
val mediaSession = MediaSession.Builder(context, player)
.setCallback(object : MediaSession.Callback {
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo) =
MediaSession.ConnectionResult.accept(
SessionCommands.EMPTY,
Player.Commands.Builder().addAllCommands().build()
)
})
.build()
media3 automatically creates notification with media controls when using MediaSessionService. Customize buttons via DefaultMediaNotificationProvider:
class CustomNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) {
override fun getMediaButtons(
session: MediaSession, playerCommands: Player.Commands,
customLayout: ImmutableList<CommandButton>, showPauseButton: Boolean
): ImmutableList<CommandButton> {
// Add "Favorite" button next to play/pause
return super.getMediaButtons(session, playerCommands, customLayout, showPauseButton)
.toMutableList().apply { add(favoriteButton) }.toImmutableList()
}
}
Artwork in notification: MediaMetadata.Builder().setArtworkUri(uri).build() — system loads image by URI. To avoid ANR on network artwork load — pre-load via Coil or Glide in CoroutineScope(Dispatchers.IO), pass ready Bitmap via setArtworkData.
Flutter
audio_service (pub.dev) — standard package. Create AudioHandler, register in AudioService.init(). Command handlers: onPlay, onPause, onSkipToNext, onSeekTo. Metadata — mediaItem in AudioHandler.
Timeline
Media controls on iOS — 1 day (entire scope — setup two objects and metadata update). Android with custom notification — 1.5 days. Flutter — 1–2 days.







