diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 02bee37c24..b2b89ca041 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -15,7 +15,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -30,7 +32,10 @@ import java.net.URI @Composable fun CIFileView( file: CIFile?, - edited: Boolean, + meta: CIMeta, + chatTTL: Int?, + showViaProxy: Boolean, + showTimestamp: Boolean, showMenu: MutableState, smallView: Boolean = false, senderProfile: LocalProfile?, @@ -202,10 +207,13 @@ fun CIFileView( ) { fileIndicator() if (!smallView) { - val metaReserve = if (edited) - " " - else - " " + val secondaryColor = MaterialTheme.colors.secondary + val encrypted = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null + val metaReserve = buildAnnotatedString { + withStyle(reserveTimestampStyle) { + append(reserveSpaceForMeta(meta, chatTTL, encrypted, secondaryColor = secondaryColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp)) + } + } if (file != null) { Column { Text( @@ -213,8 +221,11 @@ fun CIFileView( maxLines = 1 ) Text( - formatBytes(file.fileSize) + metaReserve, - color = MaterialTheme.colors.secondary, + buildAnnotatedString { + append(formatBytes(file.fileSize)) + append(metaReserve) + }, + color = secondaryColor, fontSize = 14.sp, maxLines = 1 ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 5c07fe3abf..2e1db8928e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -201,7 +201,7 @@ fun FramedItemView( @Composable fun ciFileView(ci: ChatItem, text: String) { - CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) + CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) if (text != "" || ci.meta.isLive) { CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 2c7e443b4d..fbc4c7e336 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -342,7 +342,7 @@ fun ChatPreviewView( } } is MsgContent.MCFile -> SmallContentPreviewFile { - CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { + CIFileView(ci.file, ci.meta, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, showMenu = remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIFileView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 9afcdd0b94..5196a144b1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -1,4 +1,5 @@ import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -27,7 +28,7 @@ import chat.simplex.common.views.onboarding.SelectableCard import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import chat.simplex.res.MR -private val SectionCardShape = RoundedCornerShape(16.dp) +val SectionCardShape = RoundedCornerShape(16.dp) val CARD_PADDING = 18.dp val ICON_TEXT_SPACING = 8.dp @@ -113,15 +114,18 @@ fun SectionView( iconTint: Color = MaterialTheme.colors.secondary, leadingIcon: Boolean = false, padding: PaddingValues = PaddingValues(), + onIconClick: (() -> Unit)? = null, content: (@Composable ColumnScope.() -> Unit) ) { val card = LocalCardScreen.current Column { val iconSize = with(LocalDensity.current) { 21.sp.toDp() } + val interactionSource = remember { MutableInteractionSource() } + val iconClickable = if (onIconClick != null) Modifier.clickable(interactionSource = interactionSource, indication = ripple(bounded = false, radius = iconSize * 0.75f), onClick = onIconClick) else Modifier Row(Modifier.padding(start = if (card) DEFAULT_PADDING + DEFAULT_PADDING_HALF else DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { - if (leadingIcon) Icon(icon, null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint) + if (leadingIcon) Icon(icon, null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(iconSize).then(iconClickable), tint = iconTint) Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = if (card) 14.sp else 12.sp, fontWeight = if (card) FontWeight.Medium else FontWeight.Normal) - if (!leadingIcon) Icon(icon, null, Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint) + if (!leadingIcon) Icon(icon, null, Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize).then(iconClickable), tint = iconTint) } CardColumn(padding) { content() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index cd40585cad..833f53f2af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -31,6 +31,7 @@ fun TextEditor( modifier: Modifier, placeholder: String? = null, contentPadding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING), + shape: Shape = RoundedCornerShape(14.dp), isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null, enabled: Boolean = true @@ -53,7 +54,7 @@ fun TextEditor( .fillMaxWidth() .padding(contentPadding) .heightIn(min = 52.dp) - .border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(14.dp)), + .border(border = BorderStroke(1.dp, strokeColor), shape = shape), contentAlignment = Alignment.Center, ) { val textFieldModifier = modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 81e1afd22c..86be99d1bf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -148,7 +148,6 @@ private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { AppBarTitle(stringResource(MR.strings.connecting_to_desktop)) SectionView(stringResource(MR.strings.connecting_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { CtrlDeviceNameText(session, rc) - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) CtrlDeviceVersionText(session) } @@ -257,7 +256,6 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC AppBarTitle(stringResource(MR.strings.verify_connection)) SectionView(stringResource(MR.strings.connected_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { CtrlDeviceNameText(session, rc) - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) CtrlDeviceVersionText(session) } @@ -265,16 +263,15 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC SectionView(stringResource(MR.strings.verify_code_with_desktop)) { SessionCodeText(sessCode) + SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) { + Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.confirm_verb)) + } } SectionDividerSpaced() - SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) { - Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary) - TextIconSpaced(false) - Text(generalGetString(MR.strings.confirm_verb)) - } - SectionView { DisconnectButton(onClick = ::disconnectDesktop) } @@ -312,7 +309,6 @@ private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close: AppBarTitle(stringResource(MR.strings.connected_to_desktop)) SectionView(stringResource(MR.strings.connected_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(rc.deviceViewName) - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) CtrlDeviceVersionText(session) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt index cb36e4ae1a..43863d5794 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.appPlatform import chat.simplex.res.MR @Composable @@ -38,12 +39,14 @@ fun CallSettingsLayout( ) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.your_calls)) - val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) } SectionView(stringResource(MR.strings.settings_section_title_settings)) { SectionItemView(editIceServers) { Text(stringResource(MR.strings.webrtc_ice_servers)) } - val enabled = remember { mutableStateOf(true) } - LockscreenOpts(lockCallState, enabled, onSelected = { callOnLockScreen.set(it); lockCallState.value = it }) + if (appPlatform.isAndroid) { + val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) } + val enabled = remember { mutableStateOf(true) } + LockscreenOpts(lockCallState, enabled, onSelected = { callOnLockScreen.set(it); lockCallState.value = it }) + } SettingsPreferenceItem(null, stringResource(MR.strings.always_use_relay), webrtcPolicyRelay) } SectionTextFooter( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index cf34fd5a44..0b698b2c5d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -665,46 +665,46 @@ fun SimplexLockView( } } } - if (performLA.value && laMode.value == LAMode.PASSCODE) { - SectionDividerSpaced() - SectionView(stringResource(MR.strings.self_destruct_passcode)) { - val openInfo = { - ModalManager.start.showModal { - SelfDestructInfoView() - } + } + if (performLA.value && laMode.value == LAMode.PASSCODE) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.self_destruct_passcode)) { + val openInfo = { + ModalManager.start.showModal { + SelfDestructInfoView() } - SettingsActionItemWithContent(null, null, click = openInfo) { - SharedPreferenceToggleWithIcon( - stringResource(MR.strings.enable_self_destruct), - painterResource(MR.images.ic_info), - openInfo, - remember { selfDestructPref.state }.value - ) { - toggleSelfDestruct(selfDestructPref) - } + } + SettingsActionItemWithContent(null, null, click = openInfo) { + SharedPreferenceToggleWithIcon( + stringResource(MR.strings.enable_self_destruct), + painterResource(MR.images.ic_info), + openInfo, + remember { selfDestructPref.state }.value + ) { + toggleSelfDestruct(selfDestructPref) } + } - if (remember { selfDestructPref.state }.value) { - Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) { - Text( - stringResource(MR.strings.self_destruct_new_display_name), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(selfDestructDisplayName, "", { isValidDisplayName(it.trim()) }) - LaunchedEffect(selfDestructDisplayName.value) { - val new = selfDestructDisplayName.value - if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) { - selfDestructDisplayNamePref.set(new) - } + if (remember { selfDestructPref.state }.value) { + Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) { + Text( + stringResource(MR.strings.self_destruct_new_display_name), + fontSize = 16.sp, + modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) + ) + ProfileNameField(selfDestructDisplayName, "", { isValidDisplayName(it.trim()) }) + LaunchedEffect(selfDestructDisplayName.value) { + val new = selfDestructDisplayName.value + if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) { + selfDestructDisplayNamePref.set(new) } } - SectionItemView({ changeSelfDestructPassword() }) { - Text( - stringResource(MR.strings.change_self_destruct_passcode), - color = MaterialTheme.colors.primary - ) - } + } + SectionItemView({ changeSelfDestructPassword() }) { + Text( + stringResource(MR.strings.change_self_destruct_passcode), + color = MaterialTheme.colors.primary + ) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index c55eaf6c10..36c74cceb6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer +import SectionCardShape import SectionDividerSpaced import SectionItemView import SectionTextFooter @@ -699,7 +700,13 @@ private fun AcceptIncognitoToggle(addressSettingsState: MutableState) { val autoReply = rememberSaveable { mutableStateOf(addressSettingsState.value.autoReply) } - TextEditor(autoReply, Modifier.height(100.dp), placeholder = stringResource(MR.strings.enter_welcome_message_optional)) + TextEditor( + autoReply, + Modifier.height(100.dp), + placeholder = stringResource(MR.strings.enter_welcome_message_optional), + contentPadding = PaddingValues(), + shape = SectionCardShape + ) LaunchedEffect(autoReply.value) { if (autoReply.value != addressSettingsState.value.autoReply) { addressSettingsState.value = AddressSettingsState( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt index ab63067226..9a2d7f8e61 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.usersettings.networkAndServers import SectionBottomSpacer +import SectionCardShape import SectionDividerSpaced import SectionItemView import SectionItemViewSpaceBetween @@ -10,9 +11,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.sp import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource @@ -230,43 +229,35 @@ private fun CustomRelay( } SectionView( - stringResource(MR.strings.your_relay_address).uppercase(), + stringResource(MR.strings.your_relay_address), icon = painterResource(MR.images.ic_error), iconTint = if (!validAddress.value) MaterialTheme.colors.error else Color.Transparent, ) { TextEditor( relayAddress, - Modifier.height(144.dp) + Modifier.height(144.dp), + contentPadding = PaddingValues(), + shape = SectionCardShape ) } SectionDividerSpaced(maxTopPadding = true) - Column { - val iconSize = with(LocalDensity.current) { 21.sp.toDp() } - Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(MR.strings.your_relay_name).uppercase(), - color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp - ) - IconButton( - onClick = { if (!validName.value) showInvalidRelayNameAlert(relayName) }, - enabled = !validName.value, - modifier = Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize) - ) { - Icon( - painterResource(MR.images.ic_error), null, - tint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent - ) - } - } - Column(Modifier.fillMaxWidth()) { - TextEditor( - relayName, - Modifier, - placeholder = generalGetString(MR.strings.enter_relay_name), - enabled = relay.value.tested != true - ) - } + SectionView( + stringResource(MR.strings.your_relay_name), + icon = painterResource(MR.images.ic_error), + iconTint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent, + onIconClick = if (!validName.value) { + { showInvalidRelayNameAlert(relayName) } + } else null + ) { + TextEditor( + relayName, + Modifier, + placeholder = generalGetString(MR.strings.enter_relay_name), + contentPadding = PaddingValues(), + shape = SectionCardShape, + enabled = relay.value.tested != true + ) } if (relay.value.tested != true) { SectionTextFooter(annotatedStringResource(MR.strings.test_relay_to_retrieve_name)) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 8d26f2f085..d59677b726 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -13,9 +13,14 @@ import java.io.File import java.util.* import kotlin.math.max -internal val vlcFactory: MediaPlayerFactory by lazy { MediaPlayerFactory() } +// Serialize the two factory constructions: each MediaPlayerFactory() runs VLC native discovery via +// a JDK ServiceLoader, which is not thread-safe. Building both factories concurrently (e.g. vlcFactory +// on the render thread while vlcPreviewFactory is built on the preview thread) corrupts the ServiceLoader +// enumeration and throws NoSuchElementException from CompoundEnumeration.nextElement. +private val vlcFactoryLock = Any() +internal val vlcFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory() } } // No hardware acceleration - more secure for previews -internal val vlcPreviewFactory: MediaPlayerFactory by lazy { MediaPlayerFactory("--avcodec-hw=none") } +internal val vlcPreviewFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory("--avcodec-hw=none") } } actual class RecorderNative: RecorderInterface { private var player: MediaPlayer? = null diff --git a/plans/2026-06-29-fix-desktop-video-vlc-factory-race.md b/plans/2026-06-29-fix-desktop-video-vlc-factory-race.md new file mode 100644 index 0000000000..f75b962a4b --- /dev/null +++ b/plans/2026-06-29-fix-desktop-video-vlc-factory-race.md @@ -0,0 +1,77 @@ +# Fix desktop crash when opening a video (VLC factory init race) + +## Problem (user-facing) + +Opening a video in full screen on desktop can crash the app with: + +``` +java.util.NoSuchElementException + at java.base/java.lang.CompoundEnumeration.nextElement + ... java.util.ServiceLoader ... + at uk.co.caprica.vlcj.factory.discovery.provider.DirectoryProviderDiscoveryStrategy.getSupportedProviders + at uk.co.caprica.vlcj.factory.MediaPlayerFactory. + at chat.simplex.common.platform.RecAndPlay_desktopKt.vlcFactory_delegate$lambda$0(RecAndPlay.desktop.kt:16) +``` + +The crash is intermittent and originates from the lazy initialization of the shared +`MediaPlayerFactory` while a video full-screen view is being composed. + +## Cause + +Each `MediaPlayerFactory()` constructor runs VLC native-library discovery, which iterates a +JDK `ServiceLoader` over `DiscoveryDirectoryProvider`. `ServiceLoader` and the underlying +`CompoundEnumeration` are **not thread-safe**: when two factory constructions run concurrently +on different threads, one enumeration reports `hasNext() == true` and then throws +`NoSuchElementException` from `nextElement()`. + +There are two factories on the desktop: + +- `vlcFactory` — used by the real audio/video players. Its lazy init is triggered on the + AWT/Compose render thread when a video is opened full screen + (`VideoPlayer.initializeMediaPlayerComponent` -> `RecAndPlay.desktop.kt:16`). +- `vlcPreviewFactory` (`--avcodec-hw=none`) — used by preview snapshot helpers, whose lazy init + runs on the dedicated `previewThread` (`VideoPlayer.getOrCreateHelperPlayer`). + +The single-factory invariant established by #6739 ("use shared VLC media-player factory") was +the original protection against concurrent factory construction. #6924 reintroduced a second +factory (`vlcPreviewFactory`) for hardware-acceleration-free previews, reopening the race: the +render thread can construct `vlcFactory` while `previewThread` constructs `vlcPreviewFactory`, +producing two concurrent `ServiceLoader` discoveries and the crash. + +## Fix + +Serialize the two `MediaPlayerFactory()` constructions behind a shared lock so their +native-discovery / `ServiceLoader` runs can never overlap: + +```kotlin +private val vlcFactoryLock = Any() +internal val vlcFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory() } } +internal val vlcPreviewFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory("--avcodec-hw=none") } } +``` + +Both factories are preserved, including the preview factory's `--avcodec-hw=none` option. The +lock guards only the one-time construction of each factory, so there is no steady-state +contention once both are built. + +### Why this approach + +- **Minimal and intent-preserving.** Keeps both factories (preview still needs + `--avcodec-hw=none`) and only adds serialization, restoring the no-concurrent-construction + guarantee that #6739 relied on. +- **Lazy-preserving.** Each factory is still built strictly on demand; the lock only matters in + the rare window where both initialize at the same time. A smaller diff (forcing + `vlcFactory` first inside the preview initializer) was rejected because it would eagerly + construct the main factory whenever a preview is generated and reads as dead code. + +### Known trade-off + +Because `vlcFactory` is initialized on the AWT/render thread, if `previewThread` is mid-construction +of `vlcPreviewFactory` the render thread can briefly block on the lock until native discovery +finishes. This replaces an intermittent crash with a rare, short stall — an acceptable trade. +A more thorough follow-up would either collapse to a single factory (passing `:avcodec-hw=none` +as a per-media option on preview prepare) or eagerly initialize both factories off the render +thread at startup. + +## Scope + +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt`