desktop: video and audio players (#3052)
* desktop: video and audio players * making player working without preinstalled VLC * mac support * don't use vlc lib when not needed * updated jna version * changes in script * video player lazy loading * mac script changes * updated build gradle for preserving atrributes of file while copying * apply the same file stats on libs to make VLC checker happy * updated script * changes --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
parent
6de0ed4766
commit
2d7655281f
@ -139,8 +139,6 @@ dependencies {
|
||||
//implementation("androidx.compose.material:material-icons-extended:$compose_version")
|
||||
//implementation("androidx.compose.ui:ui-util:$compose_version")
|
||||
|
||||
implementation("com.google.accompanist:accompanist-pager:0.25.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.3")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
|
||||
|
@ -73,7 +73,7 @@ class MainActivity: FragmentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
VideoPlayer.stopAll()
|
||||
VideoPlayerHolder.stopAll()
|
||||
AppLock.appWasHidden()
|
||||
}
|
||||
|
||||
|
@ -97,6 +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")
|
||||
}
|
||||
}
|
||||
val desktopTest by getting
|
||||
|
@ -27,7 +27,7 @@ actual class RecorderNative: RecorderInterface {
|
||||
}
|
||||
|
||||
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
|
||||
VideoPlayer.stopAll()
|
||||
VideoPlayerHolder.stopAll()
|
||||
AudioPlayer.stop()
|
||||
val rec: MediaRecorder
|
||||
recorder = initRecorder().also { rec = it }
|
||||
@ -140,7 +140,7 @@ actual object AudioPlayer: AudioPlayerInterface {
|
||||
return null
|
||||
}
|
||||
|
||||
VideoPlayer.stopAll()
|
||||
VideoPlayerHolder.stopAll()
|
||||
RecorderInterface.stopRecording?.invoke()
|
||||
val current = currentlyPlaying.value
|
||||
if (current == null || current.first != fileSource.filePath) {
|
||||
|
@ -1,10 +1,13 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import chat.simplex.common.helpers.toUri
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import com.google.android.exoplayer2.*
|
||||
@ -17,49 +20,15 @@ import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
actual class VideoPlayer private constructor(
|
||||
private val uri: URI,
|
||||
private val gallery: Boolean,
|
||||
actual class VideoPlayer actual constructor(
|
||||
override val uri: URI,
|
||||
override val gallery: Boolean,
|
||||
private val defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayerInterface {
|
||||
actual companion object {
|
||||
private val players: MutableMap<Pair<URI, Boolean>, VideoPlayer> = mutableMapOf()
|
||||
private val previewsAndDurations: MutableMap<URI, VideoPlayerInterface.PreviewAndDuration> = mutableMapOf()
|
||||
|
||||
actual fun getOrCreate(
|
||||
uri: URI,
|
||||
gallery: Boolean,
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayer =
|
||||
players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) }
|
||||
|
||||
actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean =
|
||||
player(fileName, gallery)?.enableSound(enable) == true
|
||||
|
||||
private fun player(fileName: String?, gallery: Boolean): VideoPlayer? {
|
||||
fileName ?: return null
|
||||
return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery }
|
||||
}
|
||||
|
||||
actual fun release(uri: URI, gallery: Boolean, remove: Boolean) =
|
||||
player(uri.path, gallery)?.release(remove).run { }
|
||||
|
||||
actual fun stopAll() {
|
||||
players.values.forEach { it.stop() }
|
||||
}
|
||||
|
||||
actual fun releaseAll() {
|
||||
players.values.forEach { it.release(false) }
|
||||
players.clear()
|
||||
previewsAndDurations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private val currentVolume: Float
|
||||
|
||||
override val soundEnabled: MutableState<Boolean> = mutableStateOf(soundEnabled)
|
||||
override val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
|
||||
override val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
|
||||
@ -114,7 +83,7 @@ actual class VideoPlayer private constructor(
|
||||
RecorderInterface.stopRecording?.invoke()
|
||||
}
|
||||
AudioPlayer.stop()
|
||||
stopAll()
|
||||
VideoPlayerHolder.stopAll()
|
||||
if (listener.value == null) {
|
||||
runCatching {
|
||||
val dataSourceFactory = DefaultDataSource.Factory(androidAppContext, DefaultHttpDataSource.Factory())
|
||||
@ -224,14 +193,14 @@ actual class VideoPlayer private constructor(
|
||||
override fun release(remove: Boolean) {
|
||||
player.release()
|
||||
if (remove) {
|
||||
players.remove(uri to gallery)
|
||||
VideoPlayerHolder.players.remove(uri to gallery)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPreviewAndDuration() {
|
||||
// It freezes main thread, doing it in IO thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val previewAndDuration = previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) }
|
||||
val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) }
|
||||
withContext(Dispatchers.Main) {
|
||||
preview.value = previewAndDuration.preview ?: defaultPreview
|
||||
duration.value = (previewAndDuration.duration ?: 0)
|
||||
|
@ -51,7 +51,7 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap:
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) {
|
||||
actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
StyledPlayerView(ctx).apply {
|
||||
|
@ -7,13 +7,12 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.platform.AudioPlayer
|
||||
import chat.simplex.common.platform.chatController
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
@ -2113,6 +2112,23 @@ data class CryptoFile(
|
||||
val isAbsolutePath: Boolean
|
||||
get() = File(filePath).isAbsolute
|
||||
|
||||
@Transient
|
||||
private var tmpFile: File? = null
|
||||
|
||||
fun createTmpFileIfNeeded(): File {
|
||||
if (tmpFile == null) {
|
||||
val tmpFile = File(tmpDir, UUID.randomUUID().toString())
|
||||
tmpFile.deleteOnExit()
|
||||
ChatModel.filesToDelete.add(tmpFile)
|
||||
this.tmpFile = tmpFile
|
||||
}
|
||||
return tmpFile!!
|
||||
}
|
||||
|
||||
fun deleteTmpFile() {
|
||||
tmpFile?.delete()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun plain(f: String): CryptoFile = CryptoFile(f, null)
|
||||
}
|
||||
|
@ -7,6 +7,8 @@ import java.net.URI
|
||||
interface VideoPlayerInterface {
|
||||
data class PreviewAndDuration(val preview: ImageBitmap?, val duration: Long?, val timestamp: Long)
|
||||
|
||||
val uri: URI
|
||||
val gallery: Boolean
|
||||
val soundEnabled: MutableState<Boolean>
|
||||
val brokenVideo: MutableState<Boolean>
|
||||
val videoPlaying: MutableState<Boolean>
|
||||
@ -20,18 +22,45 @@ interface VideoPlayerInterface {
|
||||
fun release(remove: Boolean)
|
||||
}
|
||||
|
||||
expect class VideoPlayer: VideoPlayerInterface {
|
||||
companion object {
|
||||
fun getOrCreate(
|
||||
uri: URI,
|
||||
gallery: Boolean,
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayer
|
||||
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean
|
||||
fun release(uri: URI, gallery: Boolean, remove: Boolean)
|
||||
fun stopAll()
|
||||
fun releaseAll()
|
||||
expect class VideoPlayer(
|
||||
uri: URI,
|
||||
gallery: Boolean,
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayerInterface
|
||||
|
||||
object VideoPlayerHolder {
|
||||
val players: MutableMap<Pair<URI, Boolean>, VideoPlayer> = mutableMapOf()
|
||||
val previewsAndDurations: MutableMap<URI, VideoPlayerInterface.PreviewAndDuration> = mutableMapOf()
|
||||
|
||||
fun getOrCreate(
|
||||
uri: URI,
|
||||
gallery: Boolean,
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayer =
|
||||
players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) }
|
||||
|
||||
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean =
|
||||
player(fileName, gallery)?.enableSound(enable) == true
|
||||
|
||||
private fun player(fileName: String?, gallery: Boolean): VideoPlayer? {
|
||||
fileName ?: return null
|
||||
return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery }
|
||||
}
|
||||
|
||||
fun release(uri: URI, gallery: Boolean, remove: Boolean) =
|
||||
player(uri.path, gallery)?.release(remove).run { }
|
||||
|
||||
fun stopAll() {
|
||||
players.values.forEach { it.stop() }
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
players.values.forEach { it.release(false) }
|
||||
players.clear()
|
||||
previewsAndDurations.clear()
|
||||
}
|
||||
}
|
||||
|
@ -737,7 +737,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
DisposableEffectOnGone(
|
||||
whenGone = {
|
||||
VideoPlayer.releaseAll()
|
||||
VideoPlayerHolder.releaseAll()
|
||||
}
|
||||
)
|
||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||
|
@ -50,7 +50,7 @@ fun CIVideoView(
|
||||
})
|
||||
} else {
|
||||
Box {
|
||||
ImageView(preview, showMenu, onClick = {
|
||||
VideoPreviewImageView(preview, onClick = {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation ->
|
||||
@ -75,7 +75,10 @@ fun CIVideoView(
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onLongClick = {
|
||||
showMenu.value = true
|
||||
})
|
||||
if (file != null) {
|
||||
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
|
||||
}
|
||||
@ -90,7 +93,7 @@ fun CIVideoView(
|
||||
|
||||
@Composable
|
||||
private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true) }
|
||||
val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, false, defaultPreview, defaultDuration, true) }
|
||||
val videoPlaying = remember(uri.path) { player.videoPlaying }
|
||||
val progress = remember(uri.path) { player.progress }
|
||||
val duration = remember(uri.path) { player.duration }
|
||||
@ -111,6 +114,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
|
||||
stop()
|
||||
}
|
||||
}
|
||||
val onLongClick = { showMenu.value = true }
|
||||
Box {
|
||||
val windowWidth = LocalWindowWidth()
|
||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
|
||||
@ -118,12 +122,12 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
|
||||
player,
|
||||
width,
|
||||
onClick = onClick,
|
||||
onLongClick = { showMenu.value = true },
|
||||
onLongClick = onLongClick,
|
||||
stop
|
||||
)
|
||||
if (showPreview.value) {
|
||||
ImageView(preview, showMenu, onClick)
|
||||
PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play)
|
||||
VideoPreviewImageView(preview, onClick, onLongClick)
|
||||
PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick)
|
||||
}
|
||||
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
|
||||
}
|
||||
@ -201,7 +205,7 @@ private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, durat
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
fun VideoPreviewImageView(preview: ImageBitmap, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||
val windowWidth = LocalWindowWidth()
|
||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
|
||||
Image(
|
||||
@ -210,10 +214,10 @@ private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onC
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick
|
||||
)
|
||||
.onRightClick { showMenu.value = true },
|
||||
.onRightClick(onLongClick),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
@ -46,9 +46,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
val scope = rememberCoroutineScope()
|
||||
val playersToRelease = rememberSaveable { mutableSetOf<URI>() }
|
||||
DisposableEffectOnGone(
|
||||
whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } }
|
||||
whenGone = { playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) } }
|
||||
)
|
||||
HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
|
||||
|
||||
@Composable
|
||||
fun Content(index: Int) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@ -127,7 +129,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
FullScreenImageView(modifier, data, imageBitmap)
|
||||
} else if (media is ProviderMedia.Video) {
|
||||
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
|
||||
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
|
||||
VideoView(modifier, media.uri, preview, index == settledCurrentPage, close)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { playersToRelease.add(media.uri) }
|
||||
}
|
||||
@ -135,14 +137,19 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
}
|
||||
}
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Content(index) }
|
||||
} else {
|
||||
Content(pagerState.currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap)
|
||||
|
||||
@Composable
|
||||
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) {
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) }
|
||||
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean, close: () -> Unit) {
|
||||
val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, true, defaultPreview, 0L, true) }
|
||||
val isCurrentPage = rememberUpdatedState(currentPage)
|
||||
val play = {
|
||||
player.play(true)
|
||||
@ -154,13 +161,16 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
|
||||
player.enableSound(true)
|
||||
snapshotFlow { isCurrentPage.value }
|
||||
.distinctUntilChanged()
|
||||
.collect { if (it) play() else stop() }
|
||||
.collect {
|
||||
// Do not autoplay on desktop because it needs workaround
|
||||
if (it && appPlatform.isAndroid) play() else if (!it) stop()
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
FullScreenVideoView(player, modifier)
|
||||
FullScreenVideoView(player, modifier, close)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier)
|
||||
expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit)
|
||||
|
@ -66,6 +66,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
||||
if (chatModel.chatId.value != null) {
|
||||
ModalManager.end.closeModalsExceptFirst()
|
||||
}
|
||||
AudioPlayer.stop()
|
||||
VideoPlayerHolder.stopAll()
|
||||
}
|
||||
}
|
||||
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
|
||||
|
@ -29,6 +29,10 @@ fun initApp() {
|
||||
//testCrypto()
|
||||
}
|
||||
|
||||
fun discoverVlcLibs(path: String) {
|
||||
uk.co.caprica.vlcj.binding.LibC.INSTANCE.setenv("VLC_PLUGIN_PATH", path, 1)
|
||||
}
|
||||
|
||||
private fun applyAppLocale() {
|
||||
val lang = ChatController.appPrefs.appLanguage.get()
|
||||
if (lang == null || lang == Locale.getDefault().language) return
|
||||
|
@ -21,6 +21,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db"
|
||||
|
||||
actual val databaseExportDir: File = tmpDir
|
||||
|
||||
val vlcDir: File = File(System.getProperty("java.io.tmpdir") + File.separator + "simplex-vlc").also { it.deleteOnExit() }
|
||||
|
||||
actual fun desktopOpenDatabaseDir() {
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
try {
|
||||
|
@ -1,9 +1,17 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.views.usersettings.showInDevelopingAlert
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.base.State
|
||||
import uk.co.caprica.vlcj.player.component.AudioPlayerComponent
|
||||
import java.io.File
|
||||
import kotlin.math.max
|
||||
|
||||
actual class RecorderNative: RecorderInterface {
|
||||
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
|
||||
@ -18,36 +26,187 @@ actual class RecorderNative: RecorderInterface {
|
||||
}
|
||||
|
||||
actual object AudioPlayer: AudioPlayerInterface {
|
||||
override fun play(fileSource: CryptoFile, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, resetOnEnd: Boolean) {
|
||||
showInDevelopingAlert()
|
||||
val player by lazy { AudioPlayerComponent().mediaPlayer() }
|
||||
|
||||
// Filepath: String, onProgressUpdate
|
||||
private val currentlyPlaying: MutableState<Pair<CryptoFile, (position: Int?, state: TrackState) -> Unit>?> = mutableStateOf(null)
|
||||
private var progressJob: Job? = null
|
||||
|
||||
enum class TrackState {
|
||||
PLAYING, PAUSED, REPLACED
|
||||
}
|
||||
|
||||
// Returns real duration of the track
|
||||
private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
|
||||
val absoluteFilePath = getAppFilePath(fileSource.filePath)
|
||||
if (!File(absoluteFilePath).exists()) {
|
||||
Log.e(TAG, "No such file: ${fileSource.filePath}")
|
||||
return null
|
||||
}
|
||||
|
||||
VideoPlayerHolder.stopAll()
|
||||
RecorderInterface.stopRecording?.invoke()
|
||||
val current = currentlyPlaying.value
|
||||
if (current == null || current.first != fileSource) {
|
||||
stopListener()
|
||||
player.stop()
|
||||
runCatching {
|
||||
if (fileSource.cryptoArgs != null) {
|
||||
val tmpFile = fileSource.createTmpFileIfNeeded()
|
||||
decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath)
|
||||
player.media().prepare("file://${tmpFile.absolutePath}")
|
||||
} else {
|
||||
player.media().prepare("file://$absoluteFilePath")
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (seek != null) player.seekTo(seek)
|
||||
player.start()
|
||||
currentlyPlaying.value = fileSource to onProgressUpdate
|
||||
progressJob = CoroutineScope(Dispatchers.Default).launch {
|
||||
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||
while(isActive && (player.isPlaying || player.status().state() == State.OPENING)) {
|
||||
// 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) {
|
||||
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||
break
|
||||
}
|
||||
delay(50)
|
||||
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||
}
|
||||
onProgressUpdate(null, TrackState.PAUSED)
|
||||
currentlyPlaying.value?.first?.deleteTmpFile()
|
||||
}
|
||||
return player.duration
|
||||
}
|
||||
|
||||
private fun pause(): Int {
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
val position = player.currentPosition
|
||||
player.pause()
|
||||
return position
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
/*LALAL*/
|
||||
if (currentlyPlaying.value == null) return
|
||||
player.stop()
|
||||
stopListener()
|
||||
}
|
||||
|
||||
override fun stop(item: ChatItem) {
|
||||
/*LALAL*/
|
||||
}
|
||||
override fun stop(item: ChatItem) = stop(item.file?.fileName)
|
||||
|
||||
// FileName or filePath are ok
|
||||
override fun stop(fileName: String?) {
|
||||
TODO("Not yet implemented")
|
||||
if (fileName != null && currentlyPlaying.value?.first?.filePath?.endsWith(fileName) == true) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopListener() {
|
||||
val afterCoroutineCancel: CompletionHandler = {
|
||||
// Notify prev audio listener about stop
|
||||
currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED)
|
||||
currentlyPlaying.value?.first?.deleteTmpFile()
|
||||
currentlyPlaying.value = null
|
||||
}
|
||||
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
|
||||
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order)
|
||||
* */
|
||||
if (progressJob != null) {
|
||||
progressJob?.invokeOnCompletion(afterCoroutineCancel)
|
||||
} else {
|
||||
afterCoroutineCancel(null)
|
||||
}
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
override fun play(
|
||||
fileSource: CryptoFile,
|
||||
audioPlaying: MutableState<Boolean>,
|
||||
progress: MutableState<Int>,
|
||||
duration: MutableState<Int>,
|
||||
resetOnEnd: Boolean,
|
||||
) {
|
||||
if (progress.value == duration.value) {
|
||||
progress.value = 0
|
||||
}
|
||||
val realDuration = start(fileSource, progress.value) { pro, state ->
|
||||
if (pro != null) {
|
||||
progress.value = pro
|
||||
}
|
||||
if (pro == null || pro == duration.value) {
|
||||
audioPlaying.value = false
|
||||
if (pro == duration.value) {
|
||||
progress.value = if (resetOnEnd) 0 else duration.value
|
||||
} else if (state == TrackState.REPLACED) {
|
||||
progress.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
audioPlaying.value = realDuration != null
|
||||
// Update to real duration instead of what was received in ChatInfo
|
||||
realDuration?.let { duration.value = it }
|
||||
}
|
||||
|
||||
override fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
|
||||
TODO("Not yet implemented")
|
||||
pro.value = pause()
|
||||
audioPlaying.value = false
|
||||
}
|
||||
|
||||
override fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) {
|
||||
/*LALAL*/
|
||||
pro.value = ms
|
||||
if (currentlyPlaying.value?.first?.filePath == filePath) {
|
||||
player.seekTo(ms)
|
||||
}
|
||||
}
|
||||
|
||||
override fun duration(unencryptedFilePath: String): Int? {
|
||||
/*LALAL*/
|
||||
return null
|
||||
var res: Int? = null
|
||||
try {
|
||||
val helperPlayer = AudioPlayerComponent().mediaPlayer()
|
||||
helperPlayer.media().startPaused("file://$unencryptedFilePath")
|
||||
res = helperPlayer.duration
|
||||
helperPlayer.stop()
|
||||
helperPlayer.release()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
val MediaPlayer.isPlaying: Boolean
|
||||
get() = status().isPlaying
|
||||
|
||||
fun MediaPlayer.seekTo(time: Int) {
|
||||
controls().setTime(time.toLong())
|
||||
}
|
||||
|
||||
fun MediaPlayer.start() {
|
||||
controls().start()
|
||||
}
|
||||
|
||||
fun MediaPlayer.pause() {
|
||||
controls().pause()
|
||||
}
|
||||
|
||||
fun MediaPlayer.stop() {
|
||||
controls().stop()
|
||||
}
|
||||
|
||||
private val MediaPlayer.currentPosition: Int
|
||||
get() = max(0, status().time().toInt())
|
||||
|
||||
val MediaPlayer.duration: Int
|
||||
get() = media().info().duration().toInt()
|
||||
|
||||
actual object SoundPlayer: SoundPlayerInterface {
|
||||
override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ }
|
||||
override fun stop() { /*LALAL*/ }
|
||||
|
@ -3,51 +3,213 @@ package chat.simplex.common.platform
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import chat.simplex.common.views.usersettings.showInDevelopingAlert
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
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.player.component.CallbackMediaPlayerComponent
|
||||
import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent
|
||||
import java.awt.Component
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import kotlin.math.max
|
||||
|
||||
actual class VideoPlayer: VideoPlayerInterface {
|
||||
actual companion object {
|
||||
actual fun getOrCreate(
|
||||
uri: URI,
|
||||
gallery: Boolean,
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayer = VideoPlayer().also {
|
||||
it.preview.value = defaultPreview
|
||||
it.duration.value = defaultDuration
|
||||
it.soundEnabled.value = soundEnabled
|
||||
}
|
||||
actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean { /*TODO*/ return false }
|
||||
actual fun release(uri: URI, gallery: Boolean, remove: Boolean) { /*TODO*/ }
|
||||
actual fun stopAll() { /*LALAL*/ }
|
||||
actual fun releaseAll() { /*LALAL*/ }
|
||||
}
|
||||
|
||||
actual class VideoPlayer actual constructor(
|
||||
override val uri: URI,
|
||||
override val gallery: Boolean,
|
||||
private val defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayerInterface {
|
||||
override val soundEnabled: MutableState<Boolean> = mutableStateOf(false)
|
||||
override val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
|
||||
override val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
|
||||
override val progress: MutableState<Long> = mutableStateOf(0L)
|
||||
override val duration: MutableState<Long> = mutableStateOf(0L)
|
||||
override val preview: MutableState<ImageBitmap> = mutableStateOf(ImageBitmap(0, 0))
|
||||
override val preview: MutableState<ImageBitmap> = mutableStateOf(defaultPreview)
|
||||
|
||||
val mediaPlayerComponent = initializeMediaPlayerComponent()
|
||||
val player by lazy { mediaPlayerComponent.mediaPlayer() }
|
||||
|
||||
init {
|
||||
withBGApi {
|
||||
setPreviewAndDuration()
|
||||
}
|
||||
}
|
||||
|
||||
private val currentVolume: Int by lazy { player.audio().volume() }
|
||||
private var isReleased: Boolean = false
|
||||
|
||||
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
|
||||
private var progressJob: Job? = null
|
||||
|
||||
enum class TrackState {
|
||||
PLAYING, PAUSED, STOPPED
|
||||
}
|
||||
|
||||
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
|
||||
val filepath = getAppFilePath(uri)
|
||||
if (filepath == null || !File(filepath).exists()) {
|
||||
Log.e(TAG, "No such file: $uri")
|
||||
brokenVideo.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
if (soundEnabled.value) {
|
||||
RecorderInterface.stopRecording?.invoke()
|
||||
}
|
||||
AudioPlayer.stop()
|
||||
VideoPlayerHolder.stopAll()
|
||||
val playerFilePath = uri.toString().replaceFirst("file:", "file://")
|
||||
if (listener.value == null) {
|
||||
runCatching {
|
||||
player.media().prepare(playerFilePath)
|
||||
if (seek != null) {
|
||||
player.seekTo(seek.toInt())
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message)
|
||||
brokenVideo.value = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
player.start()
|
||||
if (seek != null) player.seekTo(seek.toInt())
|
||||
if (!player.isPlaying) {
|
||||
// Can happen when video file is broken
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error))
|
||||
brokenVideo.value = true
|
||||
return false
|
||||
}
|
||||
listener.value = onProgressUpdate
|
||||
// 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) {
|
||||
// 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) {
|
||||
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
|
||||
break
|
||||
}
|
||||
delay(50)
|
||||
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
|
||||
}
|
||||
if (isActive && !isReleased) {
|
||||
onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED)
|
||||
}
|
||||
onProgressUpdate(null, TrackState.PAUSED)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
/*TODO*/
|
||||
if (isReleased || !videoPlaying.value) return
|
||||
player.controls().stop()
|
||||
stopListener()
|
||||
}
|
||||
|
||||
private fun stopListener() {
|
||||
val afterCoroutineCancel: CompletionHandler = {
|
||||
// Notify prev video listener about stop
|
||||
listener.value?.invoke(null, TrackState.STOPPED)
|
||||
}
|
||||
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
|
||||
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.STOPPED] (in this order)
|
||||
* */
|
||||
if (progressJob != null) {
|
||||
progressJob?.invokeOnCompletion(afterCoroutineCancel)
|
||||
} else {
|
||||
afterCoroutineCancel(null)
|
||||
}
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
override fun play(resetOnEnd: Boolean) {
|
||||
if (appPlatform.isDesktop) {
|
||||
showInDevelopingAlert()
|
||||
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) {
|
||||
progress.value = 0 //
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun enableSound(enable: Boolean): Boolean {
|
||||
/*TODO*/
|
||||
return false
|
||||
if (isReleased) return false
|
||||
if (soundEnabled.value == enable) return false
|
||||
soundEnabled.value = enable
|
||||
player.audio().setVolume(if (enable) currentVolume else 0)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun release(remove: Boolean) {
|
||||
/*TODO*/
|
||||
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)
|
||||
}
|
||||
}}
|
||||
|
||||
private val MediaPlayer.currentPosition: Int
|
||||
get() = if (isReleased) 0 else max(0, player.status().time().toInt())
|
||||
|
||||
private suspend fun setPreviewAndDuration() {
|
||||
// It freezes main thread, doing it in IO thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo() }
|
||||
withContext(Dispatchers.Main) {
|
||||
preview.value = previewAndDuration.preview ?: defaultPreview
|
||||
duration.value = (previewAndDuration.duration ?: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getBitmapFromVideo(): VideoPlayerInterface.PreviewAndDuration {
|
||||
val player = CallbackMediaPlayerComponent().mediaPlayer()
|
||||
val filepath = getAppFilePath(uri)
|
||||
if (filepath == null || !File(filepath).exists()) {
|
||||
return VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L)
|
||||
}
|
||||
player.media().startPaused(filepath)
|
||||
val start = System.currentTimeMillis()
|
||||
while (player.snapshots()?.get() == null && start + 5000 > System.currentTimeMillis()) {
|
||||
delay(10)
|
||||
}
|
||||
val preview = player.snapshots()?.get()?.toComposeImageBitmap()
|
||||
val duration = player.duration.toLong()
|
||||
CoroutineScope(Dispatchers.IO).launch { player.release() }
|
||||
return VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
@ -6,9 +6,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import chat.simplex.common.platform.VideoPlayer
|
||||
|
||||
@Composable
|
||||
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {
|
||||
/* LALAL */
|
||||
}
|
||||
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {}
|
||||
|
||||
@Composable
|
||||
actual fun LocalWindowWidth(): Dp {
|
||||
|
@ -1,14 +1,23 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
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 chat.simplex.common.platform.VideoPlayer
|
||||
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 kotlin.math.max
|
||||
|
||||
@Composable
|
||||
actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) {
|
||||
@ -20,6 +29,43 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap:
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) {
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
Controls(player, close)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) {
|
||||
val playing = remember(player) { player.videoPlaying }
|
||||
val progress = remember(player) { player.progress }
|
||||
val duration = remember(player) { player.duration }
|
||||
Row(Modifier.fillMaxWidth().align(Alignment.BottomCenter).height(50.dp)) {
|
||||
IconButton(onClick = { if (playing.value) player.player.pause() else player.play(true) },) {
|
||||
Icon(painterResource(if (playing.value) MR.images.ic_pause_filled else MR.images.ic_play_arrow_filled), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
Slider(
|
||||
value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()),
|
||||
onValueChange = { player.player.seekTo((it * duration.value).toInt()) },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
)
|
||||
IconButton(onClick = close,) {
|
||||
Icon(painterResource(MR.images.ic_close), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -133,7 +133,6 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean)
|
||||
}
|
||||
|
||||
actual fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration {
|
||||
// LALAL
|
||||
return VideoPlayerInterface.PreviewAndDuration(preview = null, timestamp = 0L, duration = 0L)
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.kotlin.util.capitalizeDecapitalize.toLowerCaseAsciiOnly
|
||||
import java.util.*
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
@ -22,6 +21,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("net.java.dev.jna:jna:5.13.0")
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
@ -33,16 +33,23 @@ compose {
|
||||
desktop {
|
||||
application {
|
||||
// For debugging via VisualVM
|
||||
/*jvmArgs += listOf(
|
||||
"-Dcom.sun.management.jmxremote.port=8080",
|
||||
"-Dcom.sun.management.jmxremote.ssl=false",
|
||||
"-Dcom.sun.management.jmxremote.authenticate=false"
|
||||
)*/
|
||||
val debugJava = false
|
||||
if (debugJava) {
|
||||
jvmArgs += listOf(
|
||||
"-Dcom.sun.management.jmxremote.port=8080",
|
||||
"-Dcom.sun.management.jmxremote.ssl=false",
|
||||
"-Dcom.sun.management.jmxremote.authenticate=false"
|
||||
)
|
||||
}
|
||||
mainClass = "chat.simplex.desktop.MainKt"
|
||||
nativeDistributions {
|
||||
// For debugging via VisualVM
|
||||
//modules("jdk.zipfs", "jdk.management.agent")
|
||||
modules("jdk.zipfs")
|
||||
if (debugJava) {
|
||||
modules("jdk.zipfs", "jdk.unsupported", "jdk.management.agent")
|
||||
} else {
|
||||
// 'jdk.unsupported' is for vlcj
|
||||
modules("jdk.zipfs", "jdk.unsupported")
|
||||
}
|
||||
//includeAllModules = true
|
||||
outputBaseDir.set(project.file("../release"))
|
||||
targetFormats(
|
||||
@ -145,57 +152,119 @@ tasks.named("compileJava") {
|
||||
afterEvaluate {
|
||||
tasks.create("cmakeBuildAndCopy") {
|
||||
dependsOn("cmakeBuild")
|
||||
val copyDetails = mutableMapOf<String, ArrayList<FileCopyDetails>>()
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/linux-x86_64")
|
||||
include("*.so*")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/linux-x86_64/vlc"
|
||||
from("$cppPath/desktop/libs/linux-x86_64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps")
|
||||
into("src/jvmMain/resources/libs/linux-aarch64")
|
||||
include("*.so*")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/linux-aarch64/vlc"
|
||||
from("$cppPath/desktop/libs/linux-aarch64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/windows-x86_64")
|
||||
include("*.dll")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/windows-x86_64/vlc"
|
||||
from("$cppPath/desktop/libs/windows-x86_64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/mac-x86_64")
|
||||
include("*.dylib")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/mac-x86_64/vlc"
|
||||
from("$cppPath/desktop/libs/mac-x86_64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps")
|
||||
into("src/jvmMain/resources/libs/mac-aarch64")
|
||||
include("*.dylib")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/mac-aarch64/vlc"
|
||||
from("$cppPath/desktop/libs/mac-aarch64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
doLast {
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/linux-x86_64")
|
||||
include("*.so*")
|
||||
eachFile {
|
||||
path = name
|
||||
copyDetails.forEach { (destinationDir, details) ->
|
||||
details.forEach { detail ->
|
||||
val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path)
|
||||
if (target.exists()) {
|
||||
target.setLastModified(detail.lastModified)
|
||||
}
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps")
|
||||
into("src/jvmMain/resources/libs/linux-aarch64")
|
||||
include("*.so*")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/windows-x86_64")
|
||||
include("*.dll")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/mac-x86_64")
|
||||
include("*.dylib")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps")
|
||||
into("src/jvmMain/resources/libs/mac-aarch64")
|
||||
include("*.dylib")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun CopySpec.copyIfNeeded(destinationDir: String, into: MutableMap<String, ArrayList<FileCopyDetails>>) {
|
||||
val details = arrayListOf<FileCopyDetails>()
|
||||
eachFile {
|
||||
val targetFile = File(destinationDir, path)
|
||||
if (file.lastModified() == targetFile.lastModified() && file.length() == targetFile.length()) {
|
||||
exclude()
|
||||
} else {
|
||||
details.add(this)
|
||||
}
|
||||
}
|
||||
into[destinationDir] = details
|
||||
}
|
||||
|
@ -5,6 +5,8 @@ import chat.simplex.common.showApp
|
||||
import java.io.File
|
||||
import java.nio.file.*
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.nio.file.attribute.FileTime
|
||||
import kotlin.io.path.setLastModifiedTime
|
||||
|
||||
fun main() {
|
||||
initHaskell()
|
||||
@ -20,6 +22,15 @@ private fun initHaskell() {
|
||||
val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs")
|
||||
copyResources(desktopPlatform.libPath, libsTmpDir.toPath())
|
||||
System.load(File(libsTmpDir, libApp).absolutePath)
|
||||
|
||||
vlcDir.deleteRecursively()
|
||||
Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
// No picture without preloading it, only sound. However, with libs from AppImage it works without preloading
|
||||
//val libXcb = "libvlc_xcb_events.so.0.0.0"
|
||||
//System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath)
|
||||
System.setProperty("jna.library.path", vlcDir.absolutePath)
|
||||
//discoverVlcLibs(File(File(vlcDir, "vlc"), "plugins").absolutePath)
|
||||
|
||||
libsTmpDir.deleteRecursively()
|
||||
initHS()
|
||||
}
|
||||
@ -34,7 +45,12 @@ private fun copyResources(from: String, to: Path) {
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
|
||||
Files.copy(file, to.resolve(resPath.relativize(file).toString()), StandardCopyOption.REPLACE_EXISTING)
|
||||
val dest = to.resolve(resPath.relativize(file).toString())
|
||||
Files.copy(file, dest, StandardCopyOption.REPLACE_EXISTING)
|
||||
// Setting the same time on file as the time set in script that generates VLC libs
|
||||
if (dest.toString().contains("." + desktopPlatform.libExtension)) {
|
||||
dest.setLastModifiedTime(FileTime.fromMillis(0))
|
||||
}
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
})
|
||||
|
@ -23,3 +23,4 @@ rm -rf apps/multiplatform/desktop/build/cmake
|
||||
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
|
||||
cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
|
||||
cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
|
||||
scripts/desktop/prepare-vlc-linux.sh
|
||||
|
@ -95,3 +95,4 @@ rm -rf apps/multiplatform/desktop/build/cmake
|
||||
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
|
||||
cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
|
||||
cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
|
||||
scripts/desktop/prepare-vlc-mac.sh
|
||||
|
74
scripts/desktop/prepare-vlc-linux.sh
Executable file
74
scripts/desktop/prepare-vlc-linux.sh
Executable file
@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
function readlink() {
|
||||
echo "$(cd "$(dirname "$1")"; pwd -P)"
|
||||
}
|
||||
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
|
||||
vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-x86_64/deps/vlc
|
||||
|
||||
mkdir $vlc_dir || exit 0
|
||||
|
||||
|
||||
cd /tmp
|
||||
mkdir tmp 2>/dev/null || true
|
||||
cd tmp
|
||||
curl https://github.com/cmatomic/VLCplayer-AppImage/releases/download/3.0.11.1/VLC_media_player-3.0.11.1-x86_64.AppImage -L -o appimage
|
||||
chmod +x appimage
|
||||
./appimage --appimage-extract
|
||||
cp -r squashfs-root/usr/lib/* $vlc_dir
|
||||
cd ../
|
||||
rm -rf tmp
|
||||
exit 0
|
||||
|
||||
|
||||
# This is currently unneeded
|
||||
cd /tmp
|
||||
(
|
||||
mkdir tmp
|
||||
cd tmp
|
||||
curl http://archive.ubuntu.com/ubuntu/pool/universe/v/vlc/libvlc5_3.0.9.2-1_amd64.deb -o libvlc
|
||||
ar p libvlc data.tar.xz > data.tar.xz
|
||||
tar -xvf data.tar.xz
|
||||
mv usr/lib/x86_64-linux-gnu/libvlc.so{.5,}
|
||||
cp usr/lib/x86_64-linux-gnu/libvlc.so* $vlc_dir
|
||||
cd ../
|
||||
rm -rf tmp
|
||||
)
|
||||
|
||||
(
|
||||
mkdir tmp
|
||||
cd tmp
|
||||
curl http://archive.ubuntu.com/ubuntu/pool/universe/v/vlc/libvlccore9_3.0.9.2-1_amd64.deb -o libvlccore
|
||||
ar p libvlccore data.tar.xz > data.tar.xz
|
||||
tar -xvf data.tar.xz
|
||||
cp usr/lib/x86_64-linux-gnu/libvlccore.so* $vlc_dir
|
||||
cd ../
|
||||
rm -rf tmp
|
||||
)
|
||||
|
||||
(
|
||||
mkdir tmp
|
||||
cd tmp
|
||||
curl http://mirrors.edge.kernel.org/ubuntu/pool/universe/v/vlc/vlc-plugin-base_3.0.9.2-1_amd64.deb -o plugins
|
||||
ar p plugins data.tar.xz > data.tar.xz
|
||||
tar -xvf data.tar.xz
|
||||
find usr/lib/x86_64-linux-gnu/vlc/plugins/ -name "lib*.so*" -exec patchelf --set-rpath '$ORIGIN/../../' {} \;
|
||||
cp -r usr/lib/x86_64-linux-gnu/vlc/{libvlc*,plugins} $vlc_dir
|
||||
cd ../
|
||||
rm -rf tmp
|
||||
)
|
||||
|
||||
(
|
||||
mkdir tmp
|
||||
cd tmp
|
||||
curl http://archive.ubuntu.com/ubuntu/pool/main/libi/libidn/libidn11_1.33-2.2ubuntu2_amd64.deb -o idn
|
||||
ar p idn data.tar.xz > data.tar.xz
|
||||
tar -xvf data.tar.xz
|
||||
cp lib/x86_64-linux-gnu/lib* $vlc_dir
|
||||
cd ../
|
||||
rm -rf tmp
|
||||
)
|
||||
|
||||
find $vlc_dir -maxdepth 1 -name "lib*.so*" -exec patchelf --set-rpath '$ORIGIN' {} \;
|
40
scripts/desktop/prepare-vlc-mac.sh
Executable file
40
scripts/desktop/prepare-vlc-mac.sh
Executable file
@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}"
|
||||
if [ "$ARCH" == "arm64" ]; then
|
||||
ARCH=aarch64
|
||||
vlc_arch=arm64
|
||||
else
|
||||
vlc_arch=intel64
|
||||
fi
|
||||
vlc_version=3.0.19
|
||||
|
||||
function readlink() {
|
||||
echo "$(cd "$(dirname "$1")"; pwd -P)"
|
||||
}
|
||||
|
||||
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
|
||||
vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-$ARCH/deps/vlc
|
||||
#rm -rf $vlc_dir
|
||||
mkdir -p $vlc_dir/vlc || exit 0
|
||||
|
||||
cd /tmp
|
||||
mkdir tmp 2>/dev/null || true
|
||||
cd tmp
|
||||
curl https://github.com/simplex-chat/vlc/releases/download/v$vlc_version/vlc-macos-$ARCH.zip -L -o vlc
|
||||
unzip -oqq vlc
|
||||
install_name_tool -add_rpath "@loader_path/VLC.app/Contents/MacOS/lib" vlc-cache-gen
|
||||
cd VLC.app/Contents/MacOS/lib
|
||||
for lib in $(ls *.dylib); do install_name_tool -add_rpath "@loader_path" $lib 2> /dev/null || true; done
|
||||
cd ../plugins
|
||||
for lib in $(ls *.dylib); do
|
||||
install_name_tool -add_rpath "@loader_path/../../" $lib 2> /dev/null || true
|
||||
done
|
||||
cd ..
|
||||
../../../vlc-cache-gen plugins
|
||||
cp lib/* $vlc_dir/
|
||||
cp -r -p plugins/ $vlc_dir/vlc/plugins
|
||||
cd ../../../../
|
||||
rm -rf tmp
|
Loading…
Reference in New Issue
Block a user