mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Merge remote-tracking branch 'origin/master' into ab/remote-discover-upd
This commit is contained in:
@@ -1285,6 +1285,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.removeChat(connection.id)
|
||||
}
|
||||
}
|
||||
case let .contactDeletedByContact(user, contact):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
m.updateContact(contact)
|
||||
}
|
||||
}
|
||||
case let .contactConnected(user, contact, _):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
|
||||
@@ -164,7 +164,7 @@ struct ChatInfoView: View {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
@@ -181,7 +181,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready {
|
||||
if contact.ready && contact.active {
|
||||
Section("Servers") {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
@@ -192,8 +192,7 @@ struct ChatInfoView: View {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
!contact.ready
|
||||
|| connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
|
||||
@@ -79,6 +79,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
|
||||
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case .rcvDirectEvent: eventItemView()
|
||||
case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText)
|
||||
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
|
||||
case .rcvGroupEvent: eventItemView()
|
||||
|
||||
@@ -150,7 +150,7 @@ struct ChatView: View {
|
||||
HStack {
|
||||
if contact.allowsFeature(.calls) {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
Menu {
|
||||
if contact.allowsFeature(.calls) {
|
||||
@@ -159,11 +159,11 @@ struct ChatView: View {
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
.disabled(!contact.ready)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
searchButton()
|
||||
toggleNtfsButton(chat)
|
||||
.disabled(!contact.ready)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
@@ -321,6 +321,7 @@ struct ChatView: View {
|
||||
@ViewBuilder private func connectingText() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
!contact.ready,
|
||||
contact.active,
|
||||
!contact.nextSendGrpInv {
|
||||
Text("connecting…")
|
||||
.font(.caption)
|
||||
|
||||
@@ -65,7 +65,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
Button {
|
||||
AlertManager.shared.showAlert(
|
||||
contact.ready
|
||||
contact.ready || !contact.active
|
||||
? deleteContactAlert(chat.chatInfo)
|
||||
: deletePendingContactAlert(chat, contact)
|
||||
)
|
||||
|
||||
@@ -57,19 +57,26 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
if !contact.active {
|
||||
inactiveIcon()
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memLeft: groupInactiveIcon()
|
||||
case .memRemoved: groupInactiveIcon()
|
||||
case .memGroupDeleted: groupInactiveIcon()
|
||||
case .memLeft: inactiveIcon()
|
||||
case .memRemoved: inactiveIcon()
|
||||
case .memGroupDeleted: inactiveIcon()
|
||||
default: EmptyView()
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func groupInactiveIcon() -> some View {
|
||||
@ViewBuilder private func inactiveIcon() -> some View {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.secondary.opacity(0.65))
|
||||
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
|
||||
@@ -80,7 +87,6 @@ struct ChatPreviewView: View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
previewTitle(contact.verified == true ? verifiedIcon + t : t)
|
||||
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
|
||||
case let .group(groupInfo):
|
||||
let v = previewTitle(t)
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
@@ -183,7 +189,7 @@ struct ChatPreviewView: View {
|
||||
if !contact.ready {
|
||||
if contact.nextSendGrpInv {
|
||||
chatPreviewInfoText("send direct message")
|
||||
} else {
|
||||
} else if contact.active {
|
||||
chatPreviewInfoText("connecting…")
|
||||
}
|
||||
}
|
||||
@@ -228,16 +234,20 @@ struct ChatPreviewView: View {
|
||||
@ViewBuilder private func chatStatusImage() -> some View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 17, height: 17)
|
||||
.foregroundColor(.secondary)
|
||||
default:
|
||||
ProgressView()
|
||||
if contact.active {
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 17, height: 17)
|
||||
.foregroundColor(.secondary)
|
||||
default:
|
||||
ProgressView()
|
||||
}
|
||||
} else {
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
}
|
||||
default:
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
|
||||
@@ -462,6 +462,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case contactAlreadyExists(user: UserRef, contact: Contact)
|
||||
case contactRequestAlreadyAccepted(user: UserRef, contact: Contact)
|
||||
case contactDeleted(user: UserRef, contact: Contact)
|
||||
case contactDeletedByContact(user: UserRef, contact: Contact)
|
||||
case chatCleared(user: UserRef, chatInfo: ChatInfo)
|
||||
case userProfileNoChange(user: User)
|
||||
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
|
||||
@@ -599,6 +600,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .contactAlreadyExists: return "contactAlreadyExists"
|
||||
case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted"
|
||||
case .contactDeleted: return "contactDeleted"
|
||||
case .contactDeletedByContact: return "contactDeletedByContact"
|
||||
case .chatCleared: return "chatCleared"
|
||||
case .userProfileNoChange: return "userProfileNoChange"
|
||||
case .userProfileUpdated: return "userProfileUpdated"
|
||||
@@ -735,6 +737,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .contactDeleted(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
|
||||
case .userProfileNoChange: return noDetails
|
||||
case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
|
||||
@@ -1420,6 +1423,7 @@ public enum ChatErrorType: Decodable {
|
||||
case invalidConnReq
|
||||
case invalidChatMessage(connection: Connection, message: String)
|
||||
case contactNotReady(contact: Contact)
|
||||
case contactNotActive(contact: Contact)
|
||||
case contactDisabled(contact: Contact)
|
||||
case connectionDisabled(connection: Connection)
|
||||
case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole)
|
||||
|
||||
@@ -1373,6 +1373,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var activeConn: Connection
|
||||
public var viaGroup: Int64?
|
||||
public var contactUsed: Bool
|
||||
public var contactStatus: ContactStatus
|
||||
public var chatSettings: ChatSettings
|
||||
public var userPreferences: Preferences
|
||||
public var mergedPreferences: ContactUserPreferences
|
||||
@@ -1384,8 +1385,9 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var id: ChatId { get { "@\(contactId)" } }
|
||||
public var apiId: Int64 { get { contactId } }
|
||||
public var ready: Bool { get { activeConn.connStatus == .ready } }
|
||||
public var active: Bool { get { contactStatus == .active } }
|
||||
public var sendMsgEnabled: Bool { get {
|
||||
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false))
|
||||
(ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false))
|
||||
|| nextSendGrpInv
|
||||
} }
|
||||
public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } }
|
||||
@@ -1430,6 +1432,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
profile: LocalProfile.sampleData,
|
||||
activeConn: Connection.sampleData,
|
||||
contactUsed: true,
|
||||
contactStatus: .active,
|
||||
chatSettings: ChatSettings.defaults,
|
||||
userPreferences: Preferences.sampleData,
|
||||
mergedPreferences: ContactUserPreferences.sampleData,
|
||||
@@ -1439,6 +1442,11 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public enum ContactStatus: String, Decodable {
|
||||
case active = "active"
|
||||
case deleted = "deleted"
|
||||
}
|
||||
|
||||
public struct ContactRef: Decodable, Equatable {
|
||||
var contactId: Int64
|
||||
public var agentConnId: String
|
||||
@@ -2091,6 +2099,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
case .rcvDecryptionError: return showNtfDir
|
||||
case .rcvGroupInvitation: return showNtfDir
|
||||
case .sndGroupInvitation: return showNtfDir
|
||||
case .rcvDirectEvent: return false
|
||||
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
|
||||
switch rcvGroupEvent {
|
||||
case .groupUpdated: return false
|
||||
@@ -2513,6 +2522,7 @@ public enum CIContent: Decodable, ItemContent {
|
||||
case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32)
|
||||
case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
|
||||
case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
|
||||
case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent)
|
||||
case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent)
|
||||
case sndGroupEvent(sndGroupEvent: SndGroupEvent)
|
||||
case rcvConnEvent(rcvConnEvent: RcvConnEvent)
|
||||
@@ -2542,6 +2552,7 @@ public enum CIContent: Decodable, ItemContent {
|
||||
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
|
||||
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
|
||||
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
|
||||
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
|
||||
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text
|
||||
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
|
||||
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
|
||||
@@ -3195,6 +3206,16 @@ public enum CIGroupInvitationStatus: String, Decodable {
|
||||
case expired
|
||||
}
|
||||
|
||||
public enum RcvDirectEvent: Decodable {
|
||||
case contactDeleted
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .contactDeleted: return NSLocalizedString("deleted contact", comment: "rcv direct event chat item")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum RcvGroupEvent: Decodable {
|
||||
case memberAdded(groupMemberId: Int64, profile: Profile)
|
||||
case memberConnected
|
||||
|
||||
@@ -97,7 +97,7 @@ kotlin {
|
||||
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
|
||||
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
|
||||
implementation("org.slf4j:slf4j-simple:2.0.7")
|
||||
implementation("uk.co.caprica:vlcj:4.7.0")
|
||||
implementation("uk.co.caprica:vlcj:4.7.3")
|
||||
}
|
||||
}
|
||||
val desktopTest by getting
|
||||
|
||||
@@ -73,6 +73,11 @@ else()
|
||||
target_link_libraries(app-lib rts simplex)
|
||||
endif()
|
||||
|
||||
if(APPLE)
|
||||
add_custom_command(TARGET app-lib POST_BUILD
|
||||
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/patch-libapp-mac.sh
|
||||
)
|
||||
endif()
|
||||
|
||||
|
||||
# Trying to copy resulting files into needed directory, but none of these work for some reason. This could allow to
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
lib=libapp-lib.dylib
|
||||
RPATHS=$(otool -l $lib | grep -E '/Users|/opt/|/usr/local' | cut -d' ' -f11)
|
||||
for RPATH in $RPATHS; do
|
||||
install_name_tool -delete_rpath $RPATH $lib
|
||||
done
|
||||
+22
-1
@@ -797,6 +797,7 @@ data class Contact(
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
val contactUsed: Boolean,
|
||||
val contactStatus: ContactStatus,
|
||||
val chatSettings: ChatSettings,
|
||||
val userPreferences: ChatPreferences,
|
||||
val mergedPreferences: ContactUserPreferences,
|
||||
@@ -809,8 +810,9 @@ data class Contact(
|
||||
override val id get() = "@$contactId"
|
||||
override val apiId get() = contactId
|
||||
override val ready get() = activeConn.connStatus == ConnStatus.Ready
|
||||
val active get() = contactStatus == ContactStatus.Active
|
||||
override val sendMsgEnabled get() =
|
||||
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false))
|
||||
(ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false))
|
||||
|| nextSendGrpInv
|
||||
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
@@ -859,6 +861,7 @@ data class Contact(
|
||||
profile = LocalProfile.sampleData,
|
||||
activeConn = Connection.sampleData,
|
||||
contactUsed = true,
|
||||
contactStatus = ContactStatus.Active,
|
||||
chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false),
|
||||
userPreferences = ChatPreferences.sampleData,
|
||||
mergedPreferences = ContactUserPreferences.sampleData,
|
||||
@@ -869,6 +872,12 @@ data class Contact(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ContactStatus {
|
||||
@SerialName("active") Active,
|
||||
@SerialName("deleted") Deleted;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ContactRef(
|
||||
val contactId: Long,
|
||||
@@ -1471,6 +1480,7 @@ data class ChatItem (
|
||||
is CIContent.RcvDecryptionError -> showNtfDir
|
||||
is CIContent.RcvGroupInvitation -> showNtfDir
|
||||
is CIContent.SndGroupInvitation -> showNtfDir
|
||||
is CIContent.RcvDirectEventContent -> false
|
||||
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
||||
is RcvGroupEvent.MemberAdded -> false
|
||||
is RcvGroupEvent.MemberConnected -> false
|
||||
@@ -1854,6 +1864,7 @@ sealed class CIContent: ItemContent {
|
||||
@Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@@ -1881,6 +1892,7 @@ sealed class CIContent: ItemContent {
|
||||
is RcvDecryptionError -> msgDecryptError.text
|
||||
is RcvGroupInvitation -> groupInvitation.text
|
||||
is SndGroupInvitation -> groupInvitation.text
|
||||
is RcvDirectEventContent -> rcvDirectEvent.text
|
||||
is RcvGroupEventContent -> rcvGroupEvent.text
|
||||
is SndGroupEventContent -> sndGroupEvent.text
|
||||
is RcvConnEventContent -> rcvConnEvent.text
|
||||
@@ -2487,6 +2499,15 @@ sealed class MsgErrorType() {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class RcvDirectEvent() {
|
||||
@Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent()
|
||||
|
||||
val text: String get() = when (this) {
|
||||
is ContactDeleted -> generalGetString(MR.strings.rcv_direct_event_contact_deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class RcvGroupEvent() {
|
||||
@Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
|
||||
|
||||
+10
@@ -1366,6 +1366,11 @@ object ChatController {
|
||||
chatModel.removeChat(r.connection.id)
|
||||
}
|
||||
}
|
||||
is CR.ContactDeletedByContact -> {
|
||||
if (active(r.user) && r.contact.directOrUsed) {
|
||||
chatModel.updateContact(r.contact)
|
||||
}
|
||||
}
|
||||
is CR.ContactConnected -> {
|
||||
if (active(r.user) && r.contact.directOrUsed) {
|
||||
chatModel.updateContact(r.contact)
|
||||
@@ -3295,6 +3300,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR()
|
||||
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
|
||||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR()
|
||||
@@ -3426,6 +3432,7 @@ sealed class CR {
|
||||
is ContactAlreadyExists -> "contactAlreadyExists"
|
||||
is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted"
|
||||
is ContactDeleted -> "contactDeleted"
|
||||
is ContactDeletedByContact -> "contactDeletedByContact"
|
||||
is ChatCleared -> "chatCleared"
|
||||
is UserProfileNoChange -> "userProfileNoChange"
|
||||
is UserProfileUpdated -> "userProfileUpdated"
|
||||
@@ -3554,6 +3561,7 @@ sealed class CR {
|
||||
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
|
||||
is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact))
|
||||
is ContactDeleted -> withUser(user, json.encodeToString(contact))
|
||||
is ContactDeletedByContact -> withUser(user, json.encodeToString(contact))
|
||||
is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
|
||||
is UserProfileNoChange -> withUser(user, noDetails())
|
||||
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
|
||||
@@ -3822,6 +3830,7 @@ sealed class ChatErrorType {
|
||||
is InvalidConnReq -> "invalidConnReq"
|
||||
is InvalidChatMessage -> "invalidChatMessage"
|
||||
is ContactNotReady -> "contactNotReady"
|
||||
is ContactNotActive -> "contactNotActive"
|
||||
is ContactDisabled -> "contactDisabled"
|
||||
is ConnectionDisabled -> "connectionDisabled"
|
||||
is GroupUserRole -> "groupUserRole"
|
||||
@@ -3897,6 +3906,7 @@ sealed class ChatErrorType {
|
||||
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType()
|
||||
@Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType()
|
||||
|
||||
+2
-3
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -291,7 +290,7 @@ fun ChatInfoLayout(
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (contact.ready) {
|
||||
if (contact.ready && contact.active) {
|
||||
SectionView {
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(contact.verified, verifyClicked)
|
||||
@@ -318,7 +317,7 @@ fun ChatInfoLayout(
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (contact.ready) {
|
||||
if (contact.ready && contact.active) {
|
||||
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
|
||||
+10
-5
@@ -118,7 +118,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (chat.chatInfo is ChatInfo.Direct && !chat.chatInfo.contact.ready && !chat.chatInfo.contact.nextSendGrpInv) {
|
||||
if (
|
||||
chat.chatInfo is ChatInfo.Direct
|
||||
&& !chat.chatInfo.contact.ready
|
||||
&& chat.chatInfo.contact.active
|
||||
&& !chat.chatInfo.contact.nextSendGrpInv
|
||||
) {
|
||||
Text(
|
||||
generalGetString(MR.strings.contact_connection_pending),
|
||||
Modifier.padding(top = 4.dp),
|
||||
@@ -550,15 +555,15 @@ fun ChatInfoToolbar(
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Audio)
|
||||
},
|
||||
enabled = chat.chatInfo.contact.ready) {
|
||||
enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_call_500),
|
||||
stringResource(MR.strings.icon_descr_more_button),
|
||||
tint = if (chat.chatInfo.contact.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
if (chat.chatInfo.contact.ready) {
|
||||
if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) {
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
|
||||
showMenu.value = false
|
||||
@@ -576,7 +581,7 @@ fun ChatInfoToolbar(
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready) || chat.chatInfo is ChatInfo.Group) {
|
||||
if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) {
|
||||
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
|
||||
menuItems.add {
|
||||
ItemAction(
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
|
||||
)
|
||||
if (showPreview.value) {
|
||||
VideoPreviewImageView(preview, onClick, onLongClick)
|
||||
PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick)
|
||||
PlayButton(brokenVideo, onLongClick = onLongClick, play)
|
||||
}
|
||||
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
|
||||
}
|
||||
|
||||
+1
@@ -352,6 +352,7 @@ fun ChatItemView(
|
||||
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember)
|
||||
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.RcvDirectEventContent -> EventItemView()
|
||||
is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) {
|
||||
is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText())
|
||||
is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat)
|
||||
|
||||
+2
-3
@@ -158,12 +158,11 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
|
||||
player.stop()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
player.enableSound(true)
|
||||
snapshotFlow { isCurrentPage.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
// Do not autoplay on desktop because it needs workaround
|
||||
if (it && appPlatform.isAndroid) play() else if (!it) stop()
|
||||
if (it) play() else stop()
|
||||
player.enableSound(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-29
@@ -42,7 +42,7 @@ fun ChatPreviewView(
|
||||
val cInfo = chat.chatInfo
|
||||
|
||||
@Composable
|
||||
fun groupInactiveIcon() {
|
||||
fun inactiveIcon() {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_cancel_filled),
|
||||
stringResource(MR.strings.icon_descr_group_inactive),
|
||||
@@ -53,13 +53,19 @@ fun ChatPreviewView(
|
||||
|
||||
@Composable
|
||||
fun chatPreviewImageOverlayIcon() {
|
||||
if (cInfo is ChatInfo.Group) {
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemLeft -> groupInactiveIcon()
|
||||
GroupMemberStatus.MemRemoved -> groupInactiveIcon()
|
||||
GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon()
|
||||
else -> {}
|
||||
when (cInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
if (!cInfo.contact.active) {
|
||||
inactiveIcon()
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemLeft -> inactiveIcon()
|
||||
GroupMemberStatus.MemRemoved -> inactiveIcon()
|
||||
GroupMemberStatus.MemGroupDeleted -> inactiveIcon()
|
||||
else -> {}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +131,7 @@ fun ChatPreviewView(
|
||||
if (cInfo.contact.verified) {
|
||||
VerifiedIcon()
|
||||
}
|
||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else MaterialTheme.colors.secondary)
|
||||
chatPreviewTitleText()
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
@@ -174,7 +180,7 @@ fun ChatPreviewView(
|
||||
is ChatInfo.Direct ->
|
||||
if (cInfo.contact.nextSendGrpInv) {
|
||||
Text(stringResource(MR.strings.member_contact_send_direct_message), color = MaterialTheme.colors.secondary)
|
||||
} else if (!cInfo.ready) {
|
||||
} else if (!cInfo.ready && cInfo.contact.active) {
|
||||
Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
@@ -191,28 +197,32 @@ fun ChatPreviewView(
|
||||
@Composable
|
||||
fun chatStatusImage() {
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
val descr = contactNetworkStatus?.statusString
|
||||
when (contactNetworkStatus) {
|
||||
is NetworkStatus.Connected ->
|
||||
IncognitoIcon(chat.chatInfo.incognito)
|
||||
if (cInfo.contact.active) {
|
||||
val descr = contactNetworkStatus?.statusString
|
||||
when (contactNetworkStatus) {
|
||||
is NetworkStatus.Connected ->
|
||||
IncognitoIcon(chat.chatInfo.incognito)
|
||||
|
||||
is NetworkStatus.Error ->
|
||||
Icon(
|
||||
painterResource(MR.images.ic_error),
|
||||
contentDescription = descr,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(19.dp)
|
||||
)
|
||||
is NetworkStatus.Error ->
|
||||
Icon(
|
||||
painterResource(MR.images.ic_error),
|
||||
contentDescription = descr,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(19.dp)
|
||||
)
|
||||
|
||||
else ->
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(15.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 1.5.dp
|
||||
)
|
||||
else ->
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(15.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 1.5.dp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IncognitoIcon(chat.chatInfo.incognito)
|
||||
}
|
||||
} else {
|
||||
IncognitoIcon(chat.chatInfo.incognito)
|
||||
|
||||
@@ -1105,6 +1105,9 @@
|
||||
<string name="you_rejected_group_invitation">You rejected group invitation</string>
|
||||
<string name="group_invitation_expired">Group invitation expired</string>
|
||||
|
||||
<!-- Direct event chat items -->
|
||||
<string name="rcv_direct_event_contact_deleted">deleted contact</string>
|
||||
|
||||
<!-- Group event chat items -->
|
||||
<string name="rcv_group_event_member_added">invited %1$s</string>
|
||||
<string name="rcv_group_event_member_connected">connected</string>
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package org.jetbrains.compose.videoplayer
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asComposeImageBitmap
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.ColorAlphaType
|
||||
import org.jetbrains.skia.ColorType
|
||||
import org.jetbrains.skia.ImageInfo
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurfaceAdapters
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
|
||||
import java.nio.ByteBuffer
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
// https://github.com/JetBrains/compose-multiplatform/pull/3336/files
|
||||
internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) {
|
||||
|
||||
private val videoSurface = SkiaBitmapVideoSurface()
|
||||
private lateinit var imageInfo: ImageInfo
|
||||
private lateinit var frameBytes: ByteArray
|
||||
private val skiaBitmap: Bitmap = Bitmap()
|
||||
private val composeBitmap = mutableStateOf<ImageBitmap?>(null)
|
||||
|
||||
val bitmap: State<ImageBitmap?> = composeBitmap
|
||||
|
||||
override fun attach(mediaPlayer: MediaPlayer) {
|
||||
videoSurface.attach(mediaPlayer)
|
||||
}
|
||||
|
||||
private inner class SkiaBitmapBufferFormatCallback : BufferFormatCallback {
|
||||
private var sourceWidth: Int = 0
|
||||
private var sourceHeight: Int = 0
|
||||
|
||||
override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat {
|
||||
this.sourceWidth = sourceWidth
|
||||
this.sourceHeight = sourceHeight
|
||||
return RV32BufferFormat(sourceWidth, sourceHeight)
|
||||
}
|
||||
|
||||
override fun allocatedBuffers(buffers: Array<ByteBuffer>) {
|
||||
frameBytes = buffers[0].run { ByteArray(remaining()).also(::get) }
|
||||
imageInfo = ImageInfo(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
ColorType.BGRA_8888,
|
||||
ColorAlphaType.PREMUL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SkiaBitmapRenderCallback : RenderCallback {
|
||||
override fun display(
|
||||
mediaPlayer: MediaPlayer,
|
||||
nativeBuffers: Array<ByteBuffer>,
|
||||
bufferFormat: BufferFormat,
|
||||
) {
|
||||
SwingUtilities.invokeLater {
|
||||
nativeBuffers[0].rewind()
|
||||
nativeBuffers[0].get(frameBytes)
|
||||
skiaBitmap.installPixels(imageInfo, frameBytes, bufferFormat.width * 4)
|
||||
composeBitmap.value = skiaBitmap.asComposeImageBitmap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SkiaBitmapVideoSurface : CallbackVideoSurface(
|
||||
SkiaBitmapBufferFormatCallback(),
|
||||
SkiaBitmapRenderCallback(),
|
||||
true,
|
||||
videoSurfaceAdapter,
|
||||
)
|
||||
}
|
||||
+36
@@ -6,6 +6,8 @@ import boofcv.struct.image.GrayU8
|
||||
import chat.simplex.res.MR
|
||||
import org.jetbrains.skia.Image
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.geom.AffineTransform
|
||||
import java.awt.image.AffineTransformOp
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.*
|
||||
import java.net.URI
|
||||
@@ -171,3 +173,37 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean {
|
||||
@Suppress("NewApi")
|
||||
actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap =
|
||||
Image.makeFromEncoded(inputStream.readAllBytes()).toComposeImageBitmap()
|
||||
|
||||
// https://stackoverflow.com/a/68926993
|
||||
fun BufferedImage.rotate(angle: Double): BufferedImage {
|
||||
val sin = Math.abs(Math.sin(Math.toRadians(angle)))
|
||||
val cos = Math.abs(Math.cos(Math.toRadians(angle)))
|
||||
val w = width
|
||||
val h = height
|
||||
val neww = Math.floor(w * cos + h * sin).toInt()
|
||||
val newh = Math.floor(h * cos + w * sin).toInt()
|
||||
val rotated = BufferedImage(neww, newh, type)
|
||||
val graphic = rotated.createGraphics()
|
||||
graphic.translate((neww - w) / 2, (newh - h) / 2)
|
||||
graphic.rotate(Math.toRadians(angle), (w / 2).toDouble(), (h / 2).toDouble())
|
||||
graphic.drawRenderedImage(this, null)
|
||||
graphic.dispose()
|
||||
return rotated
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/9559043
|
||||
fun BufferedImage.flip(vertically: Boolean, horizontally: Boolean): BufferedImage {
|
||||
if (!vertically && !horizontally) return this
|
||||
val tx: AffineTransform
|
||||
if (vertically && horizontally) {
|
||||
tx = AffineTransform.getScaleInstance(-1.0, -1.0)
|
||||
tx.translate(-width.toDouble(), -height.toDouble())
|
||||
} else if (vertically) {
|
||||
tx = AffineTransform.getScaleInstance(1.0, -1.0)
|
||||
tx.translate(0.0, -height.toDouble())
|
||||
} else {
|
||||
tx = AffineTransform.getScaleInstance(-1.0, 1.0)
|
||||
tx.translate(-width.toDouble(), 0.0)
|
||||
}
|
||||
return AffineTransformOp(tx, AffineTransformOp.TYPE_NEAREST_NEIGHBOR).filter(this, null)
|
||||
}
|
||||
|
||||
+119
-60
@@ -2,17 +2,20 @@ package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.graphics.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.media.VideoOrientation
|
||||
import uk.co.caprica.vlcj.player.base.*
|
||||
import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent
|
||||
import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent
|
||||
import java.awt.Component
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.max
|
||||
|
||||
actual class VideoPlayer actual constructor(
|
||||
@@ -29,17 +32,14 @@ actual class VideoPlayer actual constructor(
|
||||
override val duration: MutableState<Long> = mutableStateOf(0L)
|
||||
override val preview: MutableState<ImageBitmap> = mutableStateOf(defaultPreview)
|
||||
|
||||
val mediaPlayerComponent = initializeMediaPlayerComponent()
|
||||
val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } }
|
||||
val player by lazy { mediaPlayerComponent.mediaPlayer() }
|
||||
|
||||
init {
|
||||
withBGApi {
|
||||
setPreviewAndDuration()
|
||||
}
|
||||
setPreviewAndDuration()
|
||||
}
|
||||
|
||||
private val currentVolume: Int by lazy { player.audio().volume() }
|
||||
private var isReleased: Boolean = false
|
||||
private var isReleased: AtomicBoolean = AtomicBoolean(false)
|
||||
|
||||
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
|
||||
private var progressJob: Job? = null
|
||||
@@ -48,6 +48,7 @@ actual class VideoPlayer actual constructor(
|
||||
PLAYING, PAUSED, STOPPED
|
||||
}
|
||||
|
||||
/** Should be called in [playerThread]. Otherwise, it creates deadlocks in [player.stop] and [player.release] calls */
|
||||
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
|
||||
val filepath = getAppFilePath(uri)
|
||||
if (filepath == null || !File(filepath).exists()) {
|
||||
@@ -87,7 +88,7 @@ actual class VideoPlayer actual constructor(
|
||||
// Player can only be accessed in one specific thread
|
||||
progressJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
|
||||
while (isActive && !isReleased && player.isPlaying) {
|
||||
while (isActive && !isReleased.get() && player.isPlaying) {
|
||||
// Even when current position is equal to duration, the player has isPlaying == true for some time,
|
||||
// so help to make the playback stopped in UI immediately
|
||||
if (player.currentPosition == player.duration) {
|
||||
@@ -97,7 +98,7 @@ actual class VideoPlayer actual constructor(
|
||||
delay(50)
|
||||
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
|
||||
}
|
||||
if (isActive && !isReleased) {
|
||||
if (isActive && !isReleased.get()) {
|
||||
onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED)
|
||||
}
|
||||
onProgressUpdate(null, TrackState.PAUSED)
|
||||
@@ -107,9 +108,11 @@ actual class VideoPlayer actual constructor(
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (isReleased || !videoPlaying.value) return
|
||||
player.controls().stop()
|
||||
stopListener()
|
||||
if (isReleased.get() || !videoPlaying.value) return
|
||||
playerThread.execute {
|
||||
player.stop()
|
||||
stopListener()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopListener() {
|
||||
@@ -133,45 +136,57 @@ actual class VideoPlayer actual constructor(
|
||||
if (progress.value == duration.value) {
|
||||
progress.value = 0
|
||||
}
|
||||
videoPlaying.value = start(progress.value) { pro, _ ->
|
||||
if (pro != null) {
|
||||
progress.value = pro
|
||||
}
|
||||
if ((pro == null || pro == duration.value) && duration.value != 0L) {
|
||||
videoPlaying.value = false
|
||||
if (pro == duration.value) {
|
||||
progress.value = if (resetOnEnd) 0 else duration.value
|
||||
}/* else if (state == TrackState.STOPPED) {
|
||||
playerThread.execute {
|
||||
videoPlaying.value = start(progress.value) { pro, _ ->
|
||||
if (pro != null) {
|
||||
progress.value = pro
|
||||
}
|
||||
if ((pro == null || pro == duration.value) && duration.value != 0L) {
|
||||
videoPlaying.value = false
|
||||
if (pro == duration.value) {
|
||||
progress.value = if (resetOnEnd) 0 else duration.value
|
||||
}/* else if (state == TrackState.STOPPED) {
|
||||
progress.value = 0 //
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun enableSound(enable: Boolean): Boolean {
|
||||
if (isReleased) return false
|
||||
if (soundEnabled.value == enable) return false
|
||||
// Impossible to change volume for only one player. It changes for every player
|
||||
// https://github.com/caprica/vlcj/issues/985
|
||||
return false
|
||||
/*if (isReleased.get() || soundEnabled.value == enable) return false
|
||||
soundEnabled.value = enable
|
||||
player.audio().setVolume(if (enable) currentVolume else 0)
|
||||
return true
|
||||
playerThread.execute {
|
||||
player.audio().isMute = !enable
|
||||
}
|
||||
return true*/
|
||||
}
|
||||
|
||||
override fun release(remove: Boolean) { withApi {
|
||||
if (isReleased) return@withApi
|
||||
isReleased = true
|
||||
// TODO
|
||||
/** [player.release] freezes thread for some reason. It happens periodically. So doing this we don't see the freeze, but it's still there */
|
||||
if (player.isPlaying) player.stop()
|
||||
CoroutineScope(Dispatchers.IO).launch { player.release() }
|
||||
if (remove) {
|
||||
VideoPlayerHolder.players.remove(uri to gallery)
|
||||
override fun release(remove: Boolean) {
|
||||
CoroutineScope(playerThread.asCoroutineDispatcher()).launch {
|
||||
if (isReleased.get()) return@launch
|
||||
isReleased.set(true)
|
||||
if (player.isPlaying) {
|
||||
player.stop()
|
||||
}
|
||||
if (usePool) {
|
||||
putPlayer(mediaPlayerComponent)
|
||||
} else {
|
||||
player.release()
|
||||
}
|
||||
if (remove) {
|
||||
VideoPlayerHolder.players.remove(uri to gallery)
|
||||
}
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
private val MediaPlayer.currentPosition: Int
|
||||
get() = if (isReleased) 0 else max(0, player.status().time().toInt())
|
||||
get() = if (isReleased.get()) 0 else max(0, status().time().toInt())
|
||||
|
||||
private suspend fun setPreviewAndDuration() {
|
||||
private fun setPreviewAndDuration() {
|
||||
// It freezes main thread, doing it in IO thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(defaultPreview, uri) }
|
||||
@@ -182,35 +197,79 @@ actual class VideoPlayer actual constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeMediaPlayerComponent(): Component {
|
||||
return if (desktopPlatform.isMac()) {
|
||||
CallbackMediaPlayerComponent()
|
||||
} else {
|
||||
EmbeddedMediaPlayerComponent()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Component.mediaPlayer() = when (this) {
|
||||
is CallbackMediaPlayerComponent -> mediaPlayer()
|
||||
is EmbeddedMediaPlayerComponent -> mediaPlayer()
|
||||
else -> error("mediaPlayer() can only be called on vlcj player components")
|
||||
}
|
||||
|
||||
companion object {
|
||||
suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration {
|
||||
val player = CallbackMediaPlayerComponent().mediaPlayer()
|
||||
private val usePool = false
|
||||
|
||||
private fun Component.mediaPlayer() = when (this) {
|
||||
is CallbackMediaPlayerComponent -> mediaPlayer()
|
||||
is EmbeddedMediaPlayerComponent -> mediaPlayer()
|
||||
else -> error("mediaPlayer() can only be called on vlcj player components")
|
||||
}
|
||||
|
||||
private fun initializeMediaPlayerComponent(): Component {
|
||||
return if (desktopPlatform.isMac()) {
|
||||
CallbackMediaPlayerComponent()
|
||||
} else {
|
||||
EmbeddedMediaPlayerComponent()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) {
|
||||
val mediaComponent = getOrCreateHelperPlayer()
|
||||
val player = mediaComponent.mediaPlayer()
|
||||
if (uri == null || !File(uri.rawPath).exists()) {
|
||||
return VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L)
|
||||
return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L)
|
||||
}
|
||||
player.media().startPaused(uri.toString().replaceFirst("file:", "file://"))
|
||||
val start = System.currentTimeMillis()
|
||||
while (player.snapshots()?.get() == null && start + 5000 > System.currentTimeMillis()) {
|
||||
var snap: BufferedImage? = null
|
||||
while (snap == null && start + 5000 > System.currentTimeMillis()) {
|
||||
snap = player.snapshots()?.get()
|
||||
delay(10)
|
||||
}
|
||||
val preview = player.snapshots()?.get()?.toComposeImageBitmap()
|
||||
val orientation = player.media().info().videoTracks().first().orientation()
|
||||
val preview: ImageBitmap? = when (orientation) {
|
||||
VideoOrientation.TOP_LEFT -> snap
|
||||
VideoOrientation.TOP_RIGHT -> snap?.flip(false, true)
|
||||
VideoOrientation.BOTTOM_LEFT -> snap?.flip(true, false)
|
||||
VideoOrientation.BOTTOM_RIGHT -> snap?.rotate(180.0)
|
||||
VideoOrientation.LEFT_TOP -> snap /* Transposed */
|
||||
VideoOrientation.LEFT_BOTTOM -> snap?.rotate(-90.0)
|
||||
VideoOrientation.RIGHT_TOP -> snap?.rotate(90.0)
|
||||
VideoOrientation.RIGHT_BOTTOM -> snap /* Anti-transposed */
|
||||
else -> snap
|
||||
}?.toComposeImageBitmap()
|
||||
val duration = player.duration.toLong()
|
||||
CoroutineScope(Dispatchers.IO).launch { player.release() }
|
||||
return VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
|
||||
player.stop()
|
||||
putHelperPlayer(mediaComponent)
|
||||
return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
|
||||
}
|
||||
|
||||
val playerThread = Executors.newSingleThreadExecutor()
|
||||
private val playersPool: ArrayList<Component> = ArrayList()
|
||||
private val helperPlayersPool: ArrayList<CallbackMediaPlayerComponent> = ArrayList()
|
||||
|
||||
private fun getOrCreatePlayer(): Component = playersPool.removeFirstOrNull() ?: createNew()
|
||||
|
||||
private fun createNew(): Component =
|
||||
initializeMediaPlayerComponent().apply {
|
||||
mediaPlayer().events().addMediaPlayerEventListener(object: MediaPlayerEventAdapter() {
|
||||
override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) {
|
||||
playerThread.execute {
|
||||
mediaPlayer?.audio()?.setVolume(100)
|
||||
mediaPlayer?.audio()?.isMute = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopped(mediaPlayer: MediaPlayer?) {
|
||||
//playerThread.execute { mediaPlayer().videoSurface().set(null) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun putPlayer(player: Component) = playersPool.add(player)
|
||||
|
||||
private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent()
|
||||
private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -1,12 +1,29 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import chat.simplex.common.platform.VideoPlayer
|
||||
import chat.simplex.common.platform.isPlaying
|
||||
import chat.simplex.common.views.helpers.onRightClick
|
||||
|
||||
@Composable
|
||||
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {}
|
||||
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {
|
||||
Box {
|
||||
SurfaceFromPlayer(player,
|
||||
Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
onLongClick = onLongClick,
|
||||
onClick = { if (player.player.isPlaying) stop() else onClick() }
|
||||
)
|
||||
.onRightClick(onLongClick)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun LocalWindowWidth(): Dp {
|
||||
|
||||
+27
-22
@@ -6,17 +6,15 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.awt.SwingPanel
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.simplexWindowState
|
||||
import chat.simplex.common.views.helpers.getBitmapFromByteArray
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
@@ -28,30 +26,40 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap:
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) {
|
||||
// Workaround. Without changing size of the window the screen flashes a lot even if it's not being recomposed
|
||||
LaunchedEffect(Unit) {
|
||||
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width + 1.dp)
|
||||
delay(50)
|
||||
player.play(true)
|
||||
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width - 1.dp)
|
||||
}
|
||||
Box {
|
||||
Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) {
|
||||
val factory = remember { { player.mediaPlayerComponent } }
|
||||
SwingPanel(
|
||||
background = Color.Transparent,
|
||||
modifier = Modifier,
|
||||
factory = factory
|
||||
)
|
||||
SurfaceFromPlayer(player, modifier)
|
||||
IconButton(onClick = close, Modifier.padding(top = 5.dp)) {
|
||||
Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
Controls(player, close)
|
||||
Controls(player)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) {
|
||||
fun BoxScope.SurfaceFromPlayer(player: VideoPlayer, modifier: Modifier) {
|
||||
val surface = remember {
|
||||
SkiaBitmapVideoSurface().also {
|
||||
player.player.videoSurface().set(it)
|
||||
}
|
||||
}
|
||||
surface.bitmap.value?.let { bitmap ->
|
||||
Image(
|
||||
bitmap,
|
||||
modifier = modifier.align(Alignment.Center),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.Controls(player: VideoPlayer) {
|
||||
val playing = remember(player) { player.videoPlaying }
|
||||
val progress = remember(player) { player.progress }
|
||||
val duration = remember(player) { player.duration }
|
||||
@@ -62,10 +70,7 @@ private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) {
|
||||
Slider(
|
||||
value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()),
|
||||
onValueChange = { player.player.seekTo((it * duration.value).toInt()) },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
IconButton(onClick = close,) {
|
||||
Icon(painterResource(MR.images.ic_close), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user