mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
Improve audio playback rate controls
This commit is contained in:
parent
c53d7a1401
commit
88dd028371
@ -8910,3 +8910,11 @@ Sorry for the inconvenience.";
|
||||
"Appearance.VoiceOver.Theme" = "%@ Theme";
|
||||
|
||||
"ChatList.EmptyChatListWithArchive" = "All of your chats are archived.";
|
||||
|
||||
"VoiceOver.Media.PlaybackRate05X" = "0.5X";
|
||||
"VoiceOver.Media.PlaybackRate125X" = "1.25X";
|
||||
"VoiceOver.Media.PlaybackRate15X" = "1.5X";
|
||||
"VoiceOver.Media.PlaybackRate175X" = "1.75X";
|
||||
"VoiceOver.Media.PlaybackRate2X" = "2X";
|
||||
|
||||
"Conversation.AudioRateTooltip15X" = "Audio will play at 1.5X speed.";
|
||||
|
@ -2713,7 +2713,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
|
||||
strongSelf.context.sharedContext.mediaManager.setPlaylist(nil, type: type, control: SharedMediaPlayerControlAction.playback(.pause))
|
||||
}
|
||||
}
|
||||
mediaAccessoryPanel.setRate = { [weak self] rate in
|
||||
mediaAccessoryPanel.setRate = { [weak self] rate, fromMenu in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
@ -2742,21 +2742,28 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
|
||||
})
|
||||
|
||||
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
|
||||
let slowdown: Bool?
|
||||
let text: String?
|
||||
let rate: CGFloat?
|
||||
if baseRate == .x1 {
|
||||
slowdown = true
|
||||
text = presentationData.strings.Conversation_AudioRateTooltipNormal
|
||||
rate = 1.0
|
||||
} else if baseRate == .x1_5 {
|
||||
text = presentationData.strings.Conversation_AudioRateTooltip15X
|
||||
rate = 1.5
|
||||
} else if baseRate == .x2 {
|
||||
slowdown = false
|
||||
text = presentationData.strings.Conversation_AudioRateTooltipSpeedUp
|
||||
rate = 2.0
|
||||
} else {
|
||||
slowdown = nil
|
||||
text = nil
|
||||
rate = nil
|
||||
}
|
||||
if let slowdown = slowdown {
|
||||
if let rate, let text, !fromMenu {
|
||||
controller.present(
|
||||
UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .audioRate(
|
||||
slowdown: slowdown,
|
||||
text: slowdown ? presentationData.strings.Conversation_AudioRateTooltipNormal : presentationData.strings.Conversation_AudioRateTooltipSpeedUp
|
||||
rate: rate,
|
||||
text: text
|
||||
),
|
||||
elevatedLayout: false,
|
||||
animateInAsReplacement: hasTooltip,
|
||||
|
@ -171,7 +171,7 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi
|
||||
|
||||
public var tapAction: (() -> Void)?
|
||||
public var close: (() -> Void)?
|
||||
public var setRate: ((AudioPlaybackRate) -> Void)?
|
||||
public var setRate: ((AudioPlaybackRate, Bool) -> Void)?
|
||||
public var togglePlayPause: (() -> Void)?
|
||||
public var playPrevious: (() -> Void)?
|
||||
public var playNext: (() -> Void)?
|
||||
@ -184,21 +184,27 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi
|
||||
guard self.playbackBaseRate != oldValue, let playbackBaseRate = self.playbackBaseRate else {
|
||||
return
|
||||
}
|
||||
self.rateButton.accessibilityLabel = self.strings.VoiceOver_Media_PlaybackRate
|
||||
self.rateButton.accessibilityHint = self.strings.VoiceOver_Media_PlaybackRateChange
|
||||
switch playbackBaseRate {
|
||||
case .x0_5:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "0.5X", color: self.theme.rootController.navigationBar.accentTextColor)))
|
||||
self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate05X
|
||||
case .x1:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "1X", color: self.theme.rootController.navigationBar.controlColor)))
|
||||
self.rateButton.accessibilityLabel = self.strings.VoiceOver_Media_PlaybackRate
|
||||
self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRateNormal
|
||||
self.rateButton.accessibilityHint = self.strings.VoiceOver_Media_PlaybackRateChange
|
||||
case .x1_25:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "1.25X", color: self.theme.rootController.navigationBar.accentTextColor)))
|
||||
self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate125X
|
||||
case .x1_5:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "1.5X", color: self.theme.rootController.navigationBar.accentTextColor)))
|
||||
self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate15X
|
||||
case .x1_75:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "1.75X", color: self.theme.rootController.navigationBar.accentTextColor)))
|
||||
self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate175X
|
||||
case .x2:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "2X", color: self.theme.rootController.navigationBar.accentTextColor)))
|
||||
self.rateButton.accessibilityLabel = self.strings.VoiceOver_Media_PlaybackRate
|
||||
self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRateFast
|
||||
self.rateButton.accessibilityHint = self.strings.VoiceOver_Media_PlaybackRateChange
|
||||
self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRate2X
|
||||
default:
|
||||
break
|
||||
}
|
||||
@ -379,8 +385,12 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "0.5X", color: self.theme.rootController.navigationBar.accentTextColor)))
|
||||
case .x1:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "1X", color: self.theme.rootController.navigationBar.controlColor)))
|
||||
case .x1_25:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "1.25X", color: self.theme.rootController.navigationBar.controlColor)))
|
||||
case .x1_5:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "1.5X", color: self.theme.rootController.navigationBar.accentTextColor)))
|
||||
case .x1_75:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "1.75X", color: self.theme.rootController.navigationBar.controlColor)))
|
||||
case .x2:
|
||||
self.rateButton.setContent(.image(optionsRateImage(rate: "2X", color: self.theme.rootController.navigationBar.accentTextColor)))
|
||||
default:
|
||||
@ -493,6 +503,7 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi
|
||||
transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: bounds.size.width - 44.0 - rightInset, y: 0.0), size: CGSize(width: 44.0, height: minHeight)))
|
||||
let rateButtonSize = CGSize(width: 30.0, height: minHeight)
|
||||
transition.updateFrame(node: self.rateButton, frame: CGRect(origin: CGPoint(x: bounds.size.width - 33.0 - closeButtonSize.width - rateButtonSize.width - rightInset, y: -4.0), size: rateButtonSize))
|
||||
|
||||
transition.updateFrame(node: self.playPauseIconNode, frame: CGRect(origin: CGPoint(x: 6.0, y: 4.0 + UIScreenPixel), size: CGSize(width: 28.0, height: 28.0)))
|
||||
transition.updateFrame(node: self.actionButton, frame: CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: CGSize(width: 40.0, height: 37.0)))
|
||||
transition.updateFrame(node: self.scrubbingNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 37.0 - 2.0), size: CGSize(width: size.width, height: 2.0)))
|
||||
@ -520,14 +531,16 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi
|
||||
} else {
|
||||
nextRate = .x2
|
||||
}
|
||||
self.setRate?(nextRate)
|
||||
self.setRate?(nextRate, false)
|
||||
}
|
||||
|
||||
private func speedList(strings: PresentationStrings) -> [(String, String, AudioPlaybackRate)] {
|
||||
let speedList: [(String, String, AudioPlaybackRate)] = [
|
||||
("0.5x", "0.5x", .x0_5),
|
||||
(strings.PlaybackSpeed_Normal, "1x", .x1),
|
||||
("1.25x", "1.25x", .x1_25),
|
||||
("1.5x", "1.5x", .x1_5),
|
||||
("1.75x", "1.75x", .x1_75),
|
||||
("2x", "2x", .x2)
|
||||
]
|
||||
return speedList
|
||||
@ -547,7 +560,7 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi
|
||||
}, action: { [weak self] _, f in
|
||||
f(.default)
|
||||
|
||||
self?.setRate?(rate)
|
||||
self?.setRate?(rate, true)
|
||||
})))
|
||||
}
|
||||
|
||||
@ -626,7 +639,7 @@ private final class PlayPauseIconNode: ManagedAnimationNode {
|
||||
}
|
||||
|
||||
private func optionsRateImage(rate: String, color: UIColor = .white) -> UIImage? {
|
||||
return generateImage(CGSize(width: 30.0, height: 16.0), rotatedContext: { size, context in
|
||||
return generateImage(CGSize(width: 36.0, height: 16.0), rotatedContext: { size, context in
|
||||
UIGraphicsPushContext(context)
|
||||
|
||||
context.clear(CGRect(origin: CGPoint(), size: size))
|
||||
@ -640,7 +653,11 @@ private func optionsRateImage(rate: String, color: UIColor = .white) -> UIImage?
|
||||
|
||||
var offset = CGPoint(x: 1.0, y: 0.0)
|
||||
var width: CGFloat
|
||||
if rate.count >= 3 {
|
||||
if rate.count >= 5 {
|
||||
string.addAttribute(.kern, value: -0.8 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string))
|
||||
offset.x += -0.5
|
||||
width = 34.0
|
||||
} else if rate.count >= 3 {
|
||||
if rate == "0.5X" {
|
||||
string.addAttribute(.kern, value: -0.8 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string))
|
||||
offset.x += -0.5
|
||||
|
@ -11,7 +11,7 @@ public final class MediaNavigationAccessoryPanel: ASDisplayNode {
|
||||
public let containerNode: MediaNavigationAccessoryContainerNode
|
||||
|
||||
public var close: (() -> Void)?
|
||||
public var setRate: ((AudioPlaybackRate) -> Void)?
|
||||
public var setRate: ((AudioPlaybackRate, Bool) -> Void)?
|
||||
public var togglePlayPause: (() -> Void)?
|
||||
public var tapAction: (() -> Void)?
|
||||
public var playPrevious: (() -> Void)?
|
||||
@ -32,8 +32,8 @@ public final class MediaNavigationAccessoryPanel: ASDisplayNode {
|
||||
close()
|
||||
}
|
||||
}
|
||||
self.containerNode.headerNode.setRate = { [weak self] rate in
|
||||
self?.setRate?(rate)
|
||||
self.containerNode.headerNode.setRate = { [weak self] rate, fromMenu in
|
||||
self?.setRate?(rate, fromMenu)
|
||||
}
|
||||
self.containerNode.headerNode.togglePlayPause = { [weak self] in
|
||||
if let strongSelf = self, let togglePlayPause = strongSelf.togglePlayPause {
|
||||
|
@ -669,7 +669,7 @@ open class TelegramBaseController: ViewController, KeyShortcutResponder {
|
||||
strongSelf.context.sharedContext.mediaManager.setPlaylist(nil, type: type, control: SharedMediaPlayerControlAction.playback(.pause))
|
||||
}
|
||||
}
|
||||
mediaAccessoryPanel.setRate = { [weak self] rate in
|
||||
mediaAccessoryPanel.setRate = { [weak self] rate, fromMenu in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
@ -687,41 +687,48 @@ open class TelegramBaseController: ViewController, KeyShortcutResponder {
|
||||
}
|
||||
strongSelf.context.sharedContext.mediaManager.playlistControl(.setBaseRate(baseRate), type: type)
|
||||
|
||||
// var hasTooltip = false
|
||||
// strongSelf.forEachController({ controller in
|
||||
// if let controller = controller as? UndoOverlayController {
|
||||
// hasTooltip = true
|
||||
// controller.dismissWithCommitAction()
|
||||
// }
|
||||
// return true
|
||||
// })
|
||||
//
|
||||
// let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
|
||||
// let slowdown: Bool?
|
||||
// if baseRate == .x1 {
|
||||
// slowdown = true
|
||||
// } else if baseRate == .x2 {
|
||||
// slowdown = false
|
||||
// } else {
|
||||
// slowdown = nil
|
||||
// }
|
||||
// if let slowdown = slowdown {
|
||||
// strongSelf.present(
|
||||
// UndoOverlayController(
|
||||
// presentationData: presentationData,
|
||||
// content: .audioRate(
|
||||
// slowdown: slowdown,
|
||||
// text: slowdown ? presentationData.strings.Conversation_AudioRateTooltipNormal : presentationData.strings.Conversation_AudioRateTooltipSpeedUp
|
||||
// ),
|
||||
// elevatedLayout: false,
|
||||
// animateInAsReplacement: hasTooltip,
|
||||
// action: { action in
|
||||
// return true
|
||||
// }
|
||||
// ),
|
||||
// in: .current
|
||||
// )
|
||||
// }
|
||||
var hasTooltip = false
|
||||
strongSelf.forEachController({ controller in
|
||||
if let controller = controller as? UndoOverlayController {
|
||||
hasTooltip = true
|
||||
controller.dismissWithCommitAction()
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
|
||||
let text: String?
|
||||
let rate: CGFloat?
|
||||
if baseRate == .x1 {
|
||||
text = presentationData.strings.Conversation_AudioRateTooltipNormal
|
||||
rate = 1.0
|
||||
} else if baseRate == .x1_5 {
|
||||
text = presentationData.strings.Conversation_AudioRateTooltip15X
|
||||
rate = 1.5
|
||||
} else if baseRate == .x2 {
|
||||
text = presentationData.strings.Conversation_AudioRateTooltipSpeedUp
|
||||
rate = 2.0
|
||||
} else {
|
||||
text = nil
|
||||
rate = nil
|
||||
}
|
||||
if let rate, let text, !fromMenu {
|
||||
strongSelf.present(
|
||||
UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .audioRate(
|
||||
rate: rate,
|
||||
text: text
|
||||
),
|
||||
elevatedLayout: false,
|
||||
animateInAsReplacement: hasTooltip,
|
||||
action: { action in
|
||||
return true
|
||||
}
|
||||
),
|
||||
in: .current
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
mediaAccessoryPanel.togglePlayPause = { [weak self] in
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -36,7 +36,7 @@ private func generateCollapseIcon(theme: PresentationTheme) -> UIImage? {
|
||||
}
|
||||
|
||||
private func optionsRateImage(rate: String, color: UIColor = .white) -> UIImage? {
|
||||
return generateImage(CGSize(width: 30.0, height: 16.0), rotatedContext: { size, context in
|
||||
return generateImage(CGSize(width: 36.0, height: 16.0), rotatedContext: { size, context in
|
||||
UIGraphicsPushContext(context)
|
||||
|
||||
context.clear(CGRect(origin: CGPoint(), size: size))
|
||||
@ -50,7 +50,11 @@ private func optionsRateImage(rate: String, color: UIColor = .white) -> UIImage?
|
||||
|
||||
var offset = CGPoint(x: 1.0, y: 0.0)
|
||||
var width: CGFloat
|
||||
if rate.count >= 3 {
|
||||
if rate.count >= 5 {
|
||||
string.addAttribute(.kern, value: -0.8 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string))
|
||||
offset.x += -0.5
|
||||
width = 33.0
|
||||
} else if rate.count >= 3 {
|
||||
if rate == "0.5X" {
|
||||
string.addAttribute(.kern, value: -0.8 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string))
|
||||
offset.x += -0.5
|
||||
@ -418,8 +422,14 @@ final class OverlayPlayerControlsNode: ASDisplayNode {
|
||||
let baseRate: AudioPlaybackRate
|
||||
if value.status.baseRate.isEqual(to: 2.0) {
|
||||
baseRate = .x2
|
||||
} else if value.status.baseRate.isEqual(to: 1.75) {
|
||||
baseRate = .x1_75
|
||||
} else if value.status.baseRate.isEqual(to: 1.5) {
|
||||
baseRate = .x1_25
|
||||
} else if value.status.baseRate.isEqual(to: 1.25) {
|
||||
baseRate = .x1_5
|
||||
} else if value.status.baseRate.isEqual(to: 0.5) {
|
||||
baseRate = .x0_5
|
||||
} else {
|
||||
baseRate = .x1
|
||||
}
|
||||
@ -784,8 +794,14 @@ final class OverlayPlayerControlsNode: ASDisplayNode {
|
||||
switch baseRate {
|
||||
case .x2:
|
||||
self.rateButton.setImage(optionsRateImage(rate: "2X", color: self.presentationData.theme.list.itemAccentColor), for: [])
|
||||
case .x1_75:
|
||||
self.rateButton.setImage(optionsRateImage(rate: "1.75X", color: self.presentationData.theme.list.itemAccentColor), for: [])
|
||||
case .x1_5:
|
||||
self.rateButton.setImage(optionsRateImage(rate: "1.5X", color: self.presentationData.theme.list.itemAccentColor), for: [])
|
||||
case .x1_25:
|
||||
self.rateButton.setImage(optionsRateImage(rate: "1.25X", color: self.presentationData.theme.list.itemAccentColor), for: [])
|
||||
case .x0_5:
|
||||
self.rateButton.setImage(optionsRateImage(rate: "0.5X", color: self.presentationData.theme.list.itemAccentColor), for: [])
|
||||
default:
|
||||
self.rateButton.setImage(optionsRateImage(rate: "1X", color: self.presentationData.theme.list.itemSecondaryTextColor), for: [])
|
||||
}
|
||||
|
@ -262,7 +262,7 @@ final class PeerInfoListPaneNode: ASDisplayNode, PeerInfoPaneNode {
|
||||
strongSelf.context.sharedContext.mediaManager.setPlaylist(nil, type: type, control: SharedMediaPlayerControlAction.playback(.pause))
|
||||
}
|
||||
}
|
||||
mediaAccessoryPanel.setRate = { [weak self] rate in
|
||||
mediaAccessoryPanel.setRate = { [weak self] rate, fromMenu in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
@ -291,21 +291,28 @@ final class PeerInfoListPaneNode: ASDisplayNode, PeerInfoPaneNode {
|
||||
})
|
||||
|
||||
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
|
||||
let slowdown: Bool?
|
||||
let text: String?
|
||||
let rate: CGFloat?
|
||||
if baseRate == .x1 {
|
||||
slowdown = true
|
||||
text = presentationData.strings.Conversation_AudioRateTooltipNormal
|
||||
rate = 1.0
|
||||
} else if baseRate == .x1_5 {
|
||||
text = presentationData.strings.Conversation_AudioRateTooltip15X
|
||||
rate = 1.5
|
||||
} else if baseRate == .x2 {
|
||||
slowdown = false
|
||||
text = presentationData.strings.Conversation_AudioRateTooltipSpeedUp
|
||||
rate = 2.0
|
||||
} else {
|
||||
slowdown = nil
|
||||
text = nil
|
||||
rate = nil
|
||||
}
|
||||
if let slowdown = slowdown {
|
||||
if let rate, let text, !fromMenu {
|
||||
controller.present(
|
||||
UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .audioRate(
|
||||
slowdown: slowdown,
|
||||
text: slowdown ? presentationData.strings.Conversation_AudioRateTooltipNormal : presentationData.strings.Conversation_AudioRateTooltipSpeedUp
|
||||
rate: rate,
|
||||
text: text
|
||||
),
|
||||
elevatedLayout: false,
|
||||
animateInAsReplacement: hasTooltip,
|
||||
|
@ -18,7 +18,9 @@ public enum MusicPlaybackSettingsLooping: Int32 {
|
||||
public enum AudioPlaybackRate: Int32 {
|
||||
case x0_5 = 500
|
||||
case x1 = 1000
|
||||
case x1_25 = 1250
|
||||
case x1_5 = 1500
|
||||
case x1_75 = 1750
|
||||
case x2 = 2000
|
||||
case x4 = 4000
|
||||
case x8 = 8000
|
||||
|
@ -26,7 +26,7 @@ public enum UndoOverlayContent {
|
||||
case linkCopied(text: String)
|
||||
case banned(text: String)
|
||||
case importedMessage(text: String)
|
||||
case audioRate(slowdown: Bool, text: String)
|
||||
case audioRate(rate: CGFloat, text: String)
|
||||
case forward(savedMessages: Bool, text: String)
|
||||
case autoDelete(isOn: Bool, title: String?, text: String)
|
||||
case gigagroupConversion(text: String)
|
||||
|
@ -540,11 +540,21 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode {
|
||||
displayUndo = false
|
||||
}
|
||||
self.originalRemainingSeconds = duration
|
||||
case let .audioRate(slowdown, text):
|
||||
case let .audioRate(rate, text):
|
||||
self.avatarNode = nil
|
||||
self.iconNode = nil
|
||||
self.iconCheckNode = nil
|
||||
self.animationNode = AnimationNode(animation: slowdown ? "anim_voicespeedstop" : "anim_voicespeed", colors: [:], scale: 0.066)
|
||||
|
||||
let animationName: String
|
||||
if rate == 1.5 {
|
||||
animationName = "anim_voice1_5x"
|
||||
} else if rate == 2.0 {
|
||||
animationName = "anim_voice2x"
|
||||
} else {
|
||||
animationName = "anim_voice1x"
|
||||
}
|
||||
|
||||
self.animationNode = AnimationNode(animation: animationName, colors: [:], scale: 0.066)
|
||||
self.animatedStickerNode = nil
|
||||
|
||||
let body = MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white)
|
||||
|
Loading…
x
Reference in New Issue
Block a user