(feat): improve mobile
(feat): add camera sharing (fix): vite tauri connection (fix): methanium/ui theme not applied to call popout
This commit is contained in:
parent
b5ce3c554d
commit
62aaa7c01d
31 changed files with 1915 additions and 859 deletions
|
|
@ -95,7 +95,11 @@
|
||||||
"target": [
|
"target": [
|
||||||
"dmg"
|
"dmg"
|
||||||
],
|
],
|
||||||
"icon": "build/icons/icon.icns"
|
"icon": "build/icons/icon.icns",
|
||||||
|
"extendInfo": {
|
||||||
|
"NSCameraUsageDescription": "Tensamin uses your camera when you choose to share it in a call.",
|
||||||
|
"NSMicrophoneUsageDescription": "Tensamin uses your microphone for calls and shared audio."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"publish": null
|
"publish": null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,29 @@ function registerDisplayMediaHandler() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerMediaPermissionHandler() {
|
||||||
|
const isTrustedRenderer = (url: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === "file:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionCheckHandler(
|
||||||
|
(_webContents, permission, requestingOrigin) =>
|
||||||
|
permission === "media" && isTrustedRenderer(requestingOrigin),
|
||||||
|
);
|
||||||
|
session.defaultSession.setPermissionRequestHandler(
|
||||||
|
(_webContents, permission, callback, details) => {
|
||||||
|
callback(
|
||||||
|
permission === "media" && isTrustedRenderer(details.requestingUrl),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function registerIpc() {
|
function registerIpc() {
|
||||||
verboseLog("registering ipc handlers");
|
verboseLog("registering ipc handlers");
|
||||||
|
|
||||||
|
|
@ -360,6 +383,7 @@ async function start() {
|
||||||
verboseLog("app ready");
|
verboseLog("app ready");
|
||||||
registerIpc();
|
registerIpc();
|
||||||
registerDisplayMediaHandler();
|
registerDisplayMediaHandler();
|
||||||
|
registerMediaPermissionHandler();
|
||||||
initTray(() => mainWindow);
|
initTray(() => mainWindow);
|
||||||
await createWindow();
|
await createWindow();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- AndroidTV support -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
@ -39,6 +45,11 @@
|
||||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".MediaProjectionService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaProjection" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,65 @@
|
||||||
package net.tensamin.client
|
package net.tensamin.client
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
private var contentRoot: FrameLayout? = null
|
private var contentRoot: FrameLayout? = null
|
||||||
private var contentChild: android.view.View? = null
|
private var contentChild: android.view.View? = null
|
||||||
private var previousUsableHeight = 0
|
private var previousUsableHeight = 0
|
||||||
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||||
|
private var mediaWebView: WebView? = null
|
||||||
|
private var pendingScreenAudio: Boolean? = null
|
||||||
|
|
||||||
|
private val screenCaptureLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
val includeAudio = pendingScreenAudio
|
||||||
|
pendingScreenAudio = null
|
||||||
|
|
||||||
|
if (includeAudio == null) return@registerForActivityResult
|
||||||
|
val data = result.data
|
||||||
|
if (result.resultCode != Activity.RESULT_OK || data == null) {
|
||||||
|
MobileMediaEvents.emitError("Screen capture permission was denied")
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaProjectionService.start(this, result.resultCode, data, includeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cameraPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) {
|
||||||
|
emitCameraPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val screenAudioPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) {
|
||||||
|
launchScreenCaptureIntent()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onWebViewCreate(webView: WebView) {
|
override fun onWebViewCreate(webView: WebView) {
|
||||||
webView.setInitialScale(300)
|
webView.setInitialScale(300)
|
||||||
|
mediaWebView = webView
|
||||||
|
MobileMediaEvents.attach(webView)
|
||||||
|
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
|
@ -35,9 +76,84 @@ class MainActivity : TauriActivity() {
|
||||||
attachLayoutListener = null
|
attachLayoutListener = null
|
||||||
contentRoot = null
|
contentRoot = null
|
||||||
contentChild = null
|
contentChild = null
|
||||||
|
mediaWebView?.removeJavascriptInterface("tensaminMobileMedia")
|
||||||
|
mediaWebView = null
|
||||||
|
MobileMediaEvents.detach()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startScreenShare(includeAudio: Boolean) {
|
||||||
|
runOnUiThread {
|
||||||
|
if (pendingScreenAudio != null) {
|
||||||
|
MobileMediaEvents.emitError("Screen capture permission is already pending")
|
||||||
|
return@runOnUiThread
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingScreenAudio = includeAudio
|
||||||
|
if (
|
||||||
|
includeAudio &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
screenAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
} else {
|
||||||
|
launchScreenCaptureIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchScreenCaptureIntent() {
|
||||||
|
val manager = getSystemService(MediaProjectionManager::class.java)
|
||||||
|
screenCaptureLauncher.launch(manager.createScreenCaptureIntent())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopScreenShare() {
|
||||||
|
runOnUiThread {
|
||||||
|
pendingScreenAudio = null
|
||||||
|
MediaProjectionService.stop(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestCameraPermission() {
|
||||||
|
runOnUiThread {
|
||||||
|
cameraPermissionLauncher.launch(
|
||||||
|
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitCameraPermission() {
|
||||||
|
val detail = JSONObject()
|
||||||
|
.put(
|
||||||
|
"camera",
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
"microphone",
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
MobileMediaEvents.emit("tensamin-mobile-camera-permission", detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class MobileMediaJavascriptInterface {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun startScreenShare(includeAudio: Boolean) {
|
||||||
|
this@MainActivity.startScreenShare(includeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun stopScreenShare() {
|
||||||
|
this@MainActivity.stopScreenShare()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun requestCameraPermission() {
|
||||||
|
this@MainActivity.requestCameraPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun installKeyboardResizeWorkaround() {
|
private fun installKeyboardResizeWorkaround() {
|
||||||
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
|
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
|
||||||
contentRoot = content
|
contentRoot = content
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
package net.tensamin.client
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.hardware.display.VirtualDisplay
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioPlaybackCaptureConfiguration
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.projection.MediaProjection
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import android.media.ImageReader
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.DisplayMetrics
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class MediaProjectionService : Service() {
|
||||||
|
private var projection: MediaProjection? = null
|
||||||
|
private var virtualDisplay: VirtualDisplay? = null
|
||||||
|
private var imageReader: ImageReader? = null
|
||||||
|
private var captureThread: HandlerThread? = null
|
||||||
|
private var audioRecord: AudioRecord? = null
|
||||||
|
private var audioThread: Thread? = null
|
||||||
|
private val captureActive = AtomicBoolean(false)
|
||||||
|
private var lastFrameAt = 0L
|
||||||
|
|
||||||
|
private val projectionCallback = object : MediaProjection.Callback() {
|
||||||
|
override fun onStop() {
|
||||||
|
stopCapture(stopProjection = false, emitStopped = true)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
stopCapture(stopProjection = true, emitStopped = true)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val permissionData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA)
|
||||||
|
}
|
||||||
|
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Int.MIN_VALUE) ?: Int.MIN_VALUE
|
||||||
|
if (permissionData == null || resultCode == Int.MIN_VALUE) {
|
||||||
|
MobileMediaEvents.emitError("Screen capture permission data is missing")
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val includeAudio = intent?.getBooleanExtra(EXTRA_INCLUDE_AUDIO, false) ?: false
|
||||||
|
try {
|
||||||
|
startForegroundNotification()
|
||||||
|
startCapture(resultCode, permissionData, includeAudio)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
stopCapture(stopProjection = true, emitStopped = false)
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
MobileMediaEvents.emitError(error.message ?: "Unable to start screen capture")
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopCapture(stopProjection = true, emitStopped = true)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundNotification() {
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
notificationManager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
"Screen sharing",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val stopIntent = Intent(this, MediaProjectionService::class.java).setAction(ACTION_STOP)
|
||||||
|
val stopPendingIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
stopIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
android.app.Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
} else {
|
||||||
|
android.app.Notification.Builder(this)
|
||||||
|
}
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_share)
|
||||||
|
.setContentTitle("Tensamin is sharing your screen")
|
||||||
|
.setContentText("Tap Stop to end screen sharing")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setCategory(android.app.Notification.CATEGORY_SERVICE)
|
||||||
|
.addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun startCapture(resultCode: Int, permissionData: Intent, includeAudio: Boolean) {
|
||||||
|
stopCapture(stopProjection = true, emitStopped = false)
|
||||||
|
|
||||||
|
val manager = getSystemService(MediaProjectionManager::class.java)
|
||||||
|
val newProjection = manager.getMediaProjection(resultCode, permissionData)
|
||||||
|
?: error("Android did not provide a screen capture session")
|
||||||
|
projection = newProjection
|
||||||
|
|
||||||
|
val thread = HandlerThread("tensamin-screen-capture").also { it.start() }
|
||||||
|
captureThread = thread
|
||||||
|
val handler = Handler(thread.looper)
|
||||||
|
newProjection.registerCallback(projectionCallback, handler)
|
||||||
|
|
||||||
|
val metrics = DisplayMetrics()
|
||||||
|
getSystemService(android.view.WindowManager::class.java).defaultDisplay.getRealMetrics(metrics)
|
||||||
|
val displayWidth = metrics.widthPixels
|
||||||
|
val displayHeight = metrics.heightPixels
|
||||||
|
val width = minOf(displayWidth, MAX_FRAME_WIDTH)
|
||||||
|
val height = (displayHeight.toLong() * width / displayWidth).toInt()
|
||||||
|
val density = metrics.densityDpi
|
||||||
|
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
|
||||||
|
imageReader = reader
|
||||||
|
reader.setOnImageAvailableListener({ source -> captureFrame(source, width, height) }, handler)
|
||||||
|
|
||||||
|
virtualDisplay = newProjection.createVirtualDisplay(
|
||||||
|
"Tensamin screen sharing",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
density,
|
||||||
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||||
|
reader.surface,
|
||||||
|
null,
|
||||||
|
handler,
|
||||||
|
)
|
||||||
|
captureActive.set(true)
|
||||||
|
|
||||||
|
val audioStarted = includeAudio && startAudioCapture(newProjection)
|
||||||
|
MobileMediaEvents.emit(
|
||||||
|
"tensamin-mobile-screen-started",
|
||||||
|
JSONObject().put("includeAudio", audioStarted),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureFrame(source: ImageReader, width: Int, height: Int) {
|
||||||
|
val image = source.acquireLatestImage() ?: return
|
||||||
|
try {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!captureActive.get() || now - lastFrameAt < FRAME_INTERVAL_MS) return
|
||||||
|
lastFrameAt = now
|
||||||
|
|
||||||
|
val plane = image.planes[0]
|
||||||
|
val paddedWidth = plane.rowStride / plane.pixelStride
|
||||||
|
val paddedBitmap = Bitmap.createBitmap(paddedWidth, height, Bitmap.Config.ARGB_8888)
|
||||||
|
paddedBitmap.copyPixelsFromBuffer(plane.buffer)
|
||||||
|
val croppedBitmap = if (paddedWidth == width) {
|
||||||
|
paddedBitmap
|
||||||
|
} else {
|
||||||
|
Bitmap.createBitmap(paddedBitmap, 0, 0, width, height).also { paddedBitmap.recycle() }
|
||||||
|
}
|
||||||
|
val outputBitmap = croppedBitmap
|
||||||
|
|
||||||
|
val bytes = ByteArrayOutputStream()
|
||||||
|
outputBitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, bytes)
|
||||||
|
val outputWidth = outputBitmap.width
|
||||||
|
val outputHeight = outputBitmap.height
|
||||||
|
outputBitmap.recycle()
|
||||||
|
MobileMediaEvents.emit(
|
||||||
|
"tensamin-mobile-screen-frame",
|
||||||
|
JSONObject()
|
||||||
|
.put("data", Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP))
|
||||||
|
.put("mimeType", "image/jpeg")
|
||||||
|
.put("width", outputWidth)
|
||||||
|
.put("height", outputHeight),
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (captureActive.get()) {
|
||||||
|
MobileMediaEvents.emitError(error.message ?: "Unable to read a screen frame")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
image.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAudioCapture(activeProjection: MediaProjection): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
MobileMediaEvents.emitError("System audio capture requires Android 10 or newer")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
MobileMediaEvents.emitError("Microphone permission is required for system audio capture")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val format = AudioFormat.Builder()
|
||||||
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||||
|
.setSampleRate(AUDIO_SAMPLE_RATE)
|
||||||
|
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||||
|
.build()
|
||||||
|
val configuration = AudioPlaybackCaptureConfiguration.Builder(activeProjection)
|
||||||
|
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||||
|
.build()
|
||||||
|
val minimumBuffer = AudioRecord.getMinBufferSize(
|
||||||
|
AUDIO_SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
)
|
||||||
|
check(minimumBuffer > 0) { "Android could not allocate a system audio buffer" }
|
||||||
|
val bufferSize = maxOf(minimumBuffer * 2, AUDIO_BATCH_BYTES)
|
||||||
|
val record = AudioRecord.Builder()
|
||||||
|
.setAudioFormat(format)
|
||||||
|
.setAudioPlaybackCaptureConfig(configuration)
|
||||||
|
.setBufferSizeInBytes(bufferSize)
|
||||||
|
.build()
|
||||||
|
check(record.state == AudioRecord.STATE_INITIALIZED) {
|
||||||
|
"Android could not initialize system audio capture"
|
||||||
|
}
|
||||||
|
audioRecord = record
|
||||||
|
record.startRecording()
|
||||||
|
audioThread = Thread({ readAudio(record) }, "tensamin-audio-capture").also { it.start() }
|
||||||
|
true
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
audioRecord?.release()
|
||||||
|
audioRecord = null
|
||||||
|
MobileMediaEvents.emitError(error.message ?: "Unable to capture system audio")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAudio(record: AudioRecord) {
|
||||||
|
val buffer = ByteArray(AUDIO_BATCH_BYTES)
|
||||||
|
while (captureActive.get() && !Thread.currentThread().isInterrupted) {
|
||||||
|
val read = record.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
|
||||||
|
if (read > 0 && captureActive.get()) {
|
||||||
|
MobileMediaEvents.emit(
|
||||||
|
"tensamin-mobile-screen-audio",
|
||||||
|
JSONObject()
|
||||||
|
.put("data", Base64.encodeToString(buffer, 0, read, Base64.NO_WRAP))
|
||||||
|
.put("sampleRate", AUDIO_SAMPLE_RATE)
|
||||||
|
.put("channelCount", 1)
|
||||||
|
.put("encoding", "pcm16le"),
|
||||||
|
)
|
||||||
|
} else if (read < 0 && captureActive.get()) {
|
||||||
|
MobileMediaEvents.emitError("System audio capture stopped with code $read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun stopCapture(stopProjection: Boolean, emitStopped: Boolean) {
|
||||||
|
val wasActive = captureActive.getAndSet(false)
|
||||||
|
|
||||||
|
val record = audioRecord
|
||||||
|
audioRecord = null
|
||||||
|
try {
|
||||||
|
record?.stop()
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
}
|
||||||
|
record?.release()
|
||||||
|
audioThread?.interrupt()
|
||||||
|
audioThread = null
|
||||||
|
|
||||||
|
imageReader?.setOnImageAvailableListener(null, null)
|
||||||
|
virtualDisplay?.release()
|
||||||
|
virtualDisplay = null
|
||||||
|
imageReader?.close()
|
||||||
|
imageReader = null
|
||||||
|
|
||||||
|
val oldProjection = projection
|
||||||
|
projection = null
|
||||||
|
oldProjection?.unregisterCallback(projectionCallback)
|
||||||
|
if (stopProjection) oldProjection?.stop()
|
||||||
|
|
||||||
|
captureThread?.quitSafely()
|
||||||
|
captureThread = null
|
||||||
|
lastFrameAt = 0L
|
||||||
|
|
||||||
|
if (wasActive && emitStopped) {
|
||||||
|
MobileMediaEvents.emit("tensamin-mobile-screen-stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ACTION_STOP = "net.tensamin.client.STOP_SCREEN_SHARE"
|
||||||
|
private const val EXTRA_RESULT_CODE = "resultCode"
|
||||||
|
private const val EXTRA_PERMISSION_DATA = "permissionData"
|
||||||
|
private const val EXTRA_INCLUDE_AUDIO = "includeAudio"
|
||||||
|
private const val NOTIFICATION_CHANNEL_ID = "screen-sharing"
|
||||||
|
private const val NOTIFICATION_ID = 7314
|
||||||
|
private const val FRAME_INTERVAL_MS = 75L
|
||||||
|
private const val MAX_FRAME_WIDTH = 1280
|
||||||
|
private const val JPEG_QUALITY = 72
|
||||||
|
private const val AUDIO_SAMPLE_RATE = 48_000
|
||||||
|
private const val AUDIO_BATCH_BYTES = 9_600
|
||||||
|
|
||||||
|
fun start(context: Context, resultCode: Int, data: Intent, includeAudio: Boolean) {
|
||||||
|
val intent = Intent(context, MediaProjectionService::class.java)
|
||||||
|
.putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||||
|
.putExtra(EXTRA_PERMISSION_DATA, data)
|
||||||
|
.putExtra(EXTRA_INCLUDE_AUDIO, includeAudio)
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(
|
||||||
|
Intent(context, MediaProjectionService::class.java).setAction(ACTION_STOP),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package net.tensamin.client
|
||||||
|
|
||||||
|
import android.webkit.WebView
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
object MobileMediaEvents {
|
||||||
|
private var webView = WeakReference<WebView>(null)
|
||||||
|
|
||||||
|
fun attach(value: WebView) {
|
||||||
|
webView = WeakReference(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detach() {
|
||||||
|
webView.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitError(message: String) {
|
||||||
|
emit("tensamin-mobile-screen-error", JSONObject().put("message", message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(name: String, detail: JSONObject = JSONObject()) {
|
||||||
|
val view = webView.get() ?: return
|
||||||
|
val script = "window.dispatchEvent(new CustomEvent(" +
|
||||||
|
JSONObject.quote(name) +
|
||||||
|
", { detail: " + detail.toString() + " }));"
|
||||||
|
view.post { view.evaluateJavascript(script, null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,14 @@ import {
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
EllipsisVertical,
|
||||||
|
House,
|
||||||
|
Phone,
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { joinCall, useCall } from "@tensamin/call/store";
|
import { joinCall, useCall } from "@tensamin/call/store";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
|
|
@ -40,6 +47,8 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
|
|
||||||
const [userInfoOpen, setUserInfoOpen] = useState(false);
|
const [userInfoOpen, setUserInfoOpen] = useState(false);
|
||||||
|
|
||||||
|
const { callId } = useCall();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
|
|
@ -76,6 +85,9 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isMobile && pathname === "/call" && callId && (
|
||||||
|
<p className="font-medium text-[1.07rem]">{displayCallId(callId)}</p>
|
||||||
|
)}
|
||||||
{pathname === "/chat" && id && (
|
{pathname === "/chat" && id && (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
userId={id}
|
userId={id}
|
||||||
|
|
@ -172,6 +184,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isMobile && pathname === "/call" && callId && (
|
||||||
|
<Button variant="ghost">
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Controls className="mr-2" />
|
<Controls className="mr-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ export default defineConfig({
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
host,
|
host,
|
||||||
port: 1421,
|
clientPort: 3000,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "^2.9.20",
|
"@livekit/components-react": "^2.9.20",
|
||||||
"@tanstack/react-router": "^1.169.1",
|
"@tanstack/react-router": "^1.169.1",
|
||||||
"@tauri-apps/api": "^2",
|
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import MuteButton from "./buttons/mute";
|
import MuteButton from "./buttons/mute";
|
||||||
import DeafButton from "./buttons/deaf";
|
import DeafButton from "./buttons/deaf";
|
||||||
import ScreenshareButton from "./buttons/screenshare";
|
import MediaShareButton from "./buttons/mediaShare";
|
||||||
import LeaveButton from "./buttons/leave";
|
import LeaveButton from "./buttons/leave";
|
||||||
import {
|
import {
|
||||||
setCallIsPopout,
|
setCallIsPopout,
|
||||||
|
|
@ -132,10 +132,10 @@ export default function Actions() {
|
||||||
tooltip="Deafen"
|
tooltip="Deafen"
|
||||||
portalContainer={portalContainer}
|
portalContainer={portalContainer}
|
||||||
/>
|
/>
|
||||||
<ScreenshareButton
|
<MediaShareButton
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
iconSize={sharedIconSize}
|
iconSize={sharedIconSize}
|
||||||
tooltip="Screenshare"
|
tooltip="Share media"
|
||||||
/>
|
/>
|
||||||
<InviteButton
|
<InviteButton
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
|
|
|
||||||
171
packages/call/src/components/buttons/mediaShare.tsx
Normal file
171
packages/call/src/components/buttons/mediaShare.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@methanium/ui";
|
||||||
|
import { MonitorDot, ScreenShare } from "lucide-react";
|
||||||
|
import { toast } from "@tensamin/shared/log";
|
||||||
|
import {
|
||||||
|
startScreenShare,
|
||||||
|
stopCameraShare,
|
||||||
|
stopScreenShare,
|
||||||
|
useCall,
|
||||||
|
} from "../../store";
|
||||||
|
import { getMediaShareAdapter, type MediaShareKind } from "../../mediaShare";
|
||||||
|
import MediaShareDialog from "../mediaShareDialog";
|
||||||
|
|
||||||
|
export default function MediaShareButton({
|
||||||
|
className,
|
||||||
|
iconSize,
|
||||||
|
tooltip,
|
||||||
|
defaultPortal,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
iconSize?: number;
|
||||||
|
tooltip?: string;
|
||||||
|
defaultPortal?: boolean;
|
||||||
|
}) {
|
||||||
|
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
||||||
|
const cameraEnabled = useCall((state) => state.cameraEnabled);
|
||||||
|
const screenRef = useCall((state) => state.screenRef);
|
||||||
|
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
|
||||||
|
const [dialogKind, setDialogKind] = useState<MediaShareKind | null>(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultPortal) setPortalContainer(screenRef?.current ?? undefined);
|
||||||
|
}, [screenRef, defaultPortal]);
|
||||||
|
|
||||||
|
async function beginScreenShare() {
|
||||||
|
try {
|
||||||
|
const capabilities = await getMediaShareAdapter().getCapabilities();
|
||||||
|
if (capabilities.screenPicker === "sources") {
|
||||||
|
setDialogKind("screen");
|
||||||
|
} else {
|
||||||
|
await startScreenShare({ includeAudio: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start screen sharing", error);
|
||||||
|
toast(
|
||||||
|
"error",
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to start screen sharing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(kind: MediaShareKind) {
|
||||||
|
try {
|
||||||
|
await (kind === "screen" ? stopScreenShare() : stopCameraShare());
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to stop ${kind} sharing`, error);
|
||||||
|
toast("error", `Failed to stop ${kind} sharing.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<Button
|
||||||
|
variant={isScreensharing || cameraEnabled ? "subtleDefault" : "default"}
|
||||||
|
className="w-full! h-full!"
|
||||||
|
>
|
||||||
|
{isScreensharing || cameraEnabled ? (
|
||||||
|
<MonitorDot
|
||||||
|
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScreenShare
|
||||||
|
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip>
|
||||||
|
<Popover onOpenChange={setMenuOpen} open={menuOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={({ onClick }) =>
|
||||||
|
tooltip ? (
|
||||||
|
<TooltipTrigger
|
||||||
|
render={({ ref }) => (
|
||||||
|
<span
|
||||||
|
ref={ref as React.Ref<HTMLSpanElement>}
|
||||||
|
onClick={onClick}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={className} onClick={onClick}>
|
||||||
|
{trigger}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
className="flex w-48 flex-col gap-2"
|
||||||
|
portalProps={{
|
||||||
|
container: defaultPortal ? undefined : portalContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={isScreensharing}
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
void beginScreenShare();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Share screen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={cameraEnabled}
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setDialogKind("camera");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Share camera
|
||||||
|
</Button>
|
||||||
|
{isScreensharing ? (
|
||||||
|
<Button variant="destructive" onClick={() => void stop("screen")}>
|
||||||
|
Stop screen sharing
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{cameraEnabled ? (
|
||||||
|
<Button variant="destructive" onClick={() => void stop("camera")}>
|
||||||
|
Stop camera
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{tooltip ? (
|
||||||
|
<TooltipContent
|
||||||
|
portalProps={{
|
||||||
|
container: defaultPortal ? undefined : portalContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tooltip}
|
||||||
|
</TooltipContent>
|
||||||
|
) : null}
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{dialogKind ? (
|
||||||
|
<MediaShareDialog
|
||||||
|
kind={dialogKind}
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => !open && setDialogKind(null)}
|
||||||
|
portalContainer={portalContainer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@methanium/ui";
|
|
||||||
import { MonitorDot, ScreenShare } from "lucide-react";
|
|
||||||
import { toast } from "@tensamin/shared/log";
|
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
|
||||||
import { setScreenShareEnabled, useCall } from "../../store";
|
|
||||||
import ScreenShareDialog from "../screenshareDialog";
|
|
||||||
|
|
||||||
export default function ScreenshareButton({
|
|
||||||
className,
|
|
||||||
iconSize,
|
|
||||||
tooltip,
|
|
||||||
defaultPortal,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
iconSize?: number;
|
|
||||||
tooltip?: string;
|
|
||||||
defaultPortal?: boolean;
|
|
||||||
}) {
|
|
||||||
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
|
||||||
const screenRef = useCall((state) => state.screenRef);
|
|
||||||
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (defaultPortal) return;
|
|
||||||
setPortalContainer(screenRef?.current ?? undefined);
|
|
||||||
}, [screenRef, defaultPortal]);
|
|
||||||
|
|
||||||
async function startWebShare() {
|
|
||||||
try {
|
|
||||||
await setScreenShareEnabled(true, {
|
|
||||||
audio: true,
|
|
||||||
systemAudio: "include",
|
|
||||||
surfaceSwitching: "include",
|
|
||||||
video: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to start web screen share", error);
|
|
||||||
toast(
|
|
||||||
"error",
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to start screen sharing.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopShare() {
|
|
||||||
try {
|
|
||||||
await setScreenShareEnabled(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to stop screen share", error);
|
|
||||||
toast("error", "Failed to stop screen sharing.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Tooltip>
|
|
||||||
<Popover onOpenChange={setMenuOpen} open={menuOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={({ onClick: popoverClick }) =>
|
|
||||||
tooltip ? (
|
|
||||||
<TooltipTrigger
|
|
||||||
render={({ ref, onClick: tooltipClick }) => (
|
|
||||||
<Button
|
|
||||||
ref={ref as React.Ref<HTMLButtonElement>}
|
|
||||||
onClick={(event) => {
|
|
||||||
popoverClick?.(event);
|
|
||||||
tooltipClick?.(event);
|
|
||||||
}}
|
|
||||||
variant={isScreensharing ? "subtleDefault" : "default"}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{isScreensharing ? (
|
|
||||||
<MonitorDot
|
|
||||||
style={{
|
|
||||||
scale: (iconSize ? iconSize + 100 : 100) + "%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ScreenShare
|
|
||||||
style={{
|
|
||||||
scale: (iconSize ? iconSize + 100 : 100) + "%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant={isScreensharing ? "subtleDefault" : "default"}
|
|
||||||
className={className}
|
|
||||||
onClick={popoverClick}
|
|
||||||
>
|
|
||||||
{isScreensharing ? (
|
|
||||||
<MonitorDot
|
|
||||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ScreenShare
|
|
||||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent
|
|
||||||
className="flex w-40 flex-col gap-2"
|
|
||||||
portalProps={{
|
|
||||||
container: defaultPortal ? undefined : portalContainer,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
disabled={isScreensharing}
|
|
||||||
onClick={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
|
|
||||||
if (isTauri()) {
|
|
||||||
setDialogOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void startWebShare();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start screenshare
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={!isScreensharing}
|
|
||||||
onClick={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
void stopShare();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stop screenshare
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline">Change quality</Button>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
{tooltip && (
|
|
||||||
<TooltipContent
|
|
||||||
portalProps={{
|
|
||||||
container: defaultPortal ? undefined : portalContainer,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tooltip}
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{isTauri() && (
|
|
||||||
<ScreenShareDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onOpenChange={setDialogOpen}
|
|
||||||
isScreensharing={isScreensharing}
|
|
||||||
portalContainer={portalContainer}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
224
packages/call/src/components/mediaShareDialog.tsx
Normal file
224
packages/call/src/components/mediaShareDialog.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Label,
|
||||||
|
Switch,
|
||||||
|
} from "@methanium/ui";
|
||||||
|
import { toast } from "@tensamin/shared/log";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getMediaShareAdapter,
|
||||||
|
type MediaShareKind,
|
||||||
|
type MediaShareSource,
|
||||||
|
} from "../mediaShare";
|
||||||
|
import { startCameraShare, startScreenShare } from "../store";
|
||||||
|
import MediaSourceCard from "./mediaSourceCard";
|
||||||
|
|
||||||
|
export default function MediaShareDialog({
|
||||||
|
kind,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
portalContainer,
|
||||||
|
}: {
|
||||||
|
kind: MediaShareKind;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
portalContainer?: HTMLElement;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sources, setSources] = useState<MediaShareSource[]>([]);
|
||||||
|
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
||||||
|
const [shareAudio, setShareAudio] = useState(true);
|
||||||
|
const [canShareAudio, setCanShareAudio] = useState(false);
|
||||||
|
const [cameraPreview, setCameraPreview] = useState<MediaStream | null>(null);
|
||||||
|
const [cameraPreviewVersion, setCameraPreviewVersion] = useState(0);
|
||||||
|
const cameraPreviewRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
function stopCameraPreview() {
|
||||||
|
cameraPreviewRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
|
cameraPreviewRef.current = null;
|
||||||
|
setCameraPreview(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setSelectedSourceId(null);
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
getMediaShareAdapter().listSources(kind),
|
||||||
|
getMediaShareAdapter().getCapabilities(),
|
||||||
|
])
|
||||||
|
.then(([nextSources, capabilities]) => {
|
||||||
|
if (!active) return;
|
||||||
|
const availableSources =
|
||||||
|
kind === "camera" && nextSources.length === 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "__default_camera__",
|
||||||
|
kind: "camera" as const,
|
||||||
|
name: "Default camera",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: nextSources;
|
||||||
|
setSources(availableSources);
|
||||||
|
setSelectedSourceId(availableSources[0]?.id ?? null);
|
||||||
|
setCanShareAudio(kind === "screen" && capabilities.canShareScreenAudio);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load media share sources", error);
|
||||||
|
toast("error", "Failed to load media sources.");
|
||||||
|
})
|
||||||
|
.finally(() => active && setLoading(false));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [kind, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (kind !== "camera" || !open || !selectedSourceId) {
|
||||||
|
stopCameraPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
stopCameraPreview();
|
||||||
|
const sourceId =
|
||||||
|
selectedSourceId === "__default_camera__" ? undefined : selectedSourceId;
|
||||||
|
|
||||||
|
void navigator.mediaDevices
|
||||||
|
.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: sourceId ? { deviceId: { exact: sourceId } } : true,
|
||||||
|
})
|
||||||
|
.then((stream) => {
|
||||||
|
if (!active) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cameraPreviewRef.current = stream;
|
||||||
|
setCameraPreview(stream);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to preview camera", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
stopCameraPreview();
|
||||||
|
};
|
||||||
|
}, [cameraPreviewVersion, kind, open, selectedSourceId]);
|
||||||
|
|
||||||
|
async function startSharing() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (kind === "camera") {
|
||||||
|
stopCameraPreview();
|
||||||
|
await startCameraShare(
|
||||||
|
selectedSourceId === "__default_camera__"
|
||||||
|
? undefined
|
||||||
|
: (selectedSourceId ?? undefined),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await startScreenShare({
|
||||||
|
sourceId: selectedSourceId ?? undefined,
|
||||||
|
includeAudio: canShareAudio && shareAudio,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to share ${kind}`, error);
|
||||||
|
if (kind === "camera") {
|
||||||
|
setCameraPreviewVersion((version) => version + 1);
|
||||||
|
}
|
||||||
|
toast(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error.message : `Failed to share ${kind}.`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-[90%] gap-0 overflow-hidden p-0"
|
||||||
|
portalProps={{ container: portalContainer }}
|
||||||
|
>
|
||||||
|
<DialogHeader className="p-4 pb-3">
|
||||||
|
<DialogTitle>
|
||||||
|
{kind === "camera" ? "Share a camera" : "Share your screen"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex max-h-[75vh] flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{sources.map((source) => {
|
||||||
|
const selected = source.id === selectedSourceId;
|
||||||
|
return (
|
||||||
|
<MediaSourceCard
|
||||||
|
key={source.id}
|
||||||
|
source={source}
|
||||||
|
selected={selected}
|
||||||
|
previewStream={selected ? cameraPreview : null}
|
||||||
|
onSelect={() => setSelectedSourceId(source.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && sources.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
No {kind === "camera" ? "cameras" : "windows or displays"} found.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canShareAudio ? (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-xl border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="share-system-audio">Share audio</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Some apps and protected media do not allow audio capture.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="share-system-audio"
|
||||||
|
checked={shareAudio}
|
||||||
|
onCheckedChange={setShareAudio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading sources...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="m-0! p-2!">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={loading || sources.length === 0 || !selectedSourceId}
|
||||||
|
onClick={startSharing}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||||
|
Start sharing
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
packages/call/src/components/mediaSourceCard.tsx
Normal file
77
packages/call/src/components/mediaSourceCard.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { cn } from "@methanium/ui";
|
||||||
|
import { AppWindow, Camera, MonitorUp } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { MediaShareSource } from "../mediaShare";
|
||||||
|
|
||||||
|
function SourceIcon({ source }: { source: MediaShareSource }) {
|
||||||
|
const className = "size-8 text-muted-foreground";
|
||||||
|
|
||||||
|
if (source.kind === "camera") return <Camera className={className} />;
|
||||||
|
if (source.kind === "window") return <AppWindow className={className} />;
|
||||||
|
return <MonitorUp className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoPreview({ stream }: { stream: MediaStream }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.srcObject = stream;
|
||||||
|
void video.play().catch(() => undefined);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.srcObject = null;
|
||||||
|
};
|
||||||
|
}, [stream]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaSourceCard({
|
||||||
|
source,
|
||||||
|
selected,
|
||||||
|
previewStream,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
source: MediaShareSource;
|
||||||
|
selected: boolean;
|
||||||
|
previewStream?: MediaStream | null;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border bg-card text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||||
|
selected
|
||||||
|
? "border-4 border-(--primary)!"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex aspect-video w-full items-center justify-center overflow-hidden bg-muted">
|
||||||
|
{previewStream ? (
|
||||||
|
<VideoPreview stream={previewStream} />
|
||||||
|
) : source.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={source.thumbnail}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SourceIcon source={source} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -178,6 +178,13 @@ export default function Base({
|
||||||
Track.Source.ScreenShare,
|
Track.Source.ScreenShare,
|
||||||
);
|
);
|
||||||
const screenSharePreview = participant?.attributes["screenSharePreview"];
|
const screenSharePreview = participant?.attributes["screenSharePreview"];
|
||||||
|
const cameraPublication = getTrackPublicationBySource(
|
||||||
|
participant,
|
||||||
|
Track.Source.Camera,
|
||||||
|
);
|
||||||
|
const cameraDisabled = useCall((state) =>
|
||||||
|
user ? state.disabledCameraParticipantIds.includes(user.UserId) : false,
|
||||||
|
);
|
||||||
|
|
||||||
const [ownId, setOwnId] = useState(0);
|
const [ownId, setOwnId] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -271,7 +278,7 @@ export default function Base({
|
||||||
flush && !fill && "border-x-0!",
|
flush && !fill && "border-x-0!",
|
||||||
type === "user" &&
|
type === "user" &&
|
||||||
isSpeaking &&
|
isSpeaking &&
|
||||||
"border-4 border-(--primary-foreground-alt)/75!",
|
"border-4 border-(--primary-foreground-alt)/75! rounded-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
|
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
|
||||||
|
|
@ -341,23 +348,31 @@ export default function Base({
|
||||||
</div>
|
</div>
|
||||||
) : null)}
|
) : null)}
|
||||||
|
|
||||||
{type === "user" && (
|
{type === "user" &&
|
||||||
<Avatar
|
(cameraPublication?.track && !cameraDisabled ? (
|
||||||
style={{
|
<VideoViewer
|
||||||
width: "32cqh",
|
fill={fill}
|
||||||
height: "32cqh",
|
flush={flush}
|
||||||
}}
|
participantId={participant.identity}
|
||||||
>
|
publication={cameraPublication}
|
||||||
<AvatarImage src={user.Avatar} />
|
/>
|
||||||
<AvatarFallback
|
) : (
|
||||||
|
<Avatar
|
||||||
style={{
|
style={{
|
||||||
fontSize: "11cqh",
|
width: "32cqh",
|
||||||
|
height: "32cqh",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{user.Display.slice(0, 2).toUpperCase()}
|
<AvatarImage src={user.Avatar} />
|
||||||
</AvatarFallback>
|
<AvatarFallback
|
||||||
</Avatar>
|
style={{
|
||||||
)}
|
fontSize: "11cqh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.Display.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
Slider,
|
Slider,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
import { useCall } from "../../store";
|
import { setParticipantCameraDisabled, useCall } from "../../store";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
|
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
|
||||||
|
|
@ -33,6 +33,9 @@ export default function ContextMenu({
|
||||||
const watchedStreamParticipantIds = useCall(
|
const watchedStreamParticipantIds = useCall(
|
||||||
(state) => state.watchedStreamParticipantIds,
|
(state) => state.watchedStreamParticipantIds,
|
||||||
);
|
);
|
||||||
|
const cameraDisabled = useCall((state) =>
|
||||||
|
state.disabledCameraParticipantIds.includes(user.UserId),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenuContent className="p-1">
|
<ContextMenuContent className="p-1">
|
||||||
|
|
@ -71,6 +74,19 @@ export default function ContextMenu({
|
||||||
<p>Mute Soundboard</p>
|
<p>Mute Soundboard</p>
|
||||||
<EmptyCheckboxIndicator checked={soundboardMuted} />
|
<EmptyCheckboxIndicator checked={soundboardMuted} />
|
||||||
</ContextMenuCheckboxItem>
|
</ContextMenuCheckboxItem>
|
||||||
|
{user.UserId !== ownId ? (
|
||||||
|
<ContextMenuCheckboxItem
|
||||||
|
checked={cameraDisabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setParticipantCameraDisabled(user.UserId, checked)
|
||||||
|
}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
className="flex justify-between"
|
||||||
|
>
|
||||||
|
<p>Disable camera</p>
|
||||||
|
<EmptyCheckboxIndicator checked={cameraDisabled} />
|
||||||
|
</ContextMenuCheckboxItem>
|
||||||
|
) : null}
|
||||||
<ContextMenuCheckboxItem
|
<ContextMenuCheckboxItem
|
||||||
checked={serverDeafened}
|
checked={serverDeafened}
|
||||||
onCheckedChange={setServerDeafened}
|
onCheckedChange={setServerDeafened}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ function MobileCallPill({
|
||||||
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
const safeAreaRef = useRef<HTMLDivElement>(null);
|
||||||
const pillRef = useRef<HTMLDivElement>(null);
|
const pillRef = useRef<HTMLDivElement>(null);
|
||||||
const initialCoords = {
|
const initialCoords = {
|
||||||
x: window.innerWidth - MOBILE_PILL_WIDTH - MOBILE_MARGIN,
|
x: window.innerWidth - MOBILE_PILL_WIDTH - MOBILE_MARGIN,
|
||||||
|
|
@ -120,21 +121,37 @@ function MobileCallPill({
|
||||||
};
|
};
|
||||||
}, [lastSpeakingUser?.Avatar]);
|
}, [lastSpeakingUser?.Avatar]);
|
||||||
|
|
||||||
const getCoordsForPosition = useCallback((nextPosition: Positions): Point => {
|
const getSafeArea = useCallback(() => {
|
||||||
const bounds = pillRef.current?.getBoundingClientRect();
|
const element = safeAreaRef.current;
|
||||||
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
|
if (!element) return { top: 0, right: 0, bottom: 0, left: 0 };
|
||||||
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
|
const style = getComputedStyle(element);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: nextPosition.endsWith("right")
|
top: parseFloat(style.paddingTop) || 0,
|
||||||
? window.innerWidth - width - MOBILE_MARGIN
|
right: parseFloat(style.paddingRight) || 0,
|
||||||
: MOBILE_MARGIN,
|
bottom: parseFloat(style.paddingBottom) || 0,
|
||||||
y: nextPosition.startsWith("bottom")
|
left: parseFloat(style.paddingLeft) || 0,
|
||||||
? window.innerHeight - height - MOBILE_MARGIN
|
|
||||||
: MOBILE_MARGIN,
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getCoordsForPosition = useCallback(
|
||||||
|
(nextPosition: Positions): Point => {
|
||||||
|
const bounds = pillRef.current?.getBoundingClientRect();
|
||||||
|
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
|
||||||
|
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
|
||||||
|
const safeArea = getSafeArea();
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: nextPosition.endsWith("right")
|
||||||
|
? window.innerWidth - safeArea.right - width - MOBILE_MARGIN
|
||||||
|
: safeArea.left + MOBILE_MARGIN,
|
||||||
|
y: nextPosition.startsWith("bottom")
|
||||||
|
? window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN
|
||||||
|
: safeArea.top + MOBILE_MARGIN,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[getSafeArea],
|
||||||
|
);
|
||||||
|
|
||||||
const setCoordsSafe = useCallback((next: Point) => {
|
const setCoordsSafe = useCallback((next: Point) => {
|
||||||
coordsRef.current = next;
|
coordsRef.current = next;
|
||||||
setCoords(next);
|
setCoords(next);
|
||||||
|
|
@ -166,90 +183,102 @@ function MobileCallPill({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
ref={pillRef}
|
ref={safeAreaRef}
|
||||||
onClick={() => {
|
className="pointer-events-none fixed inset-0 z-200 pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]"
|
||||||
if (movedRef.current) {
|
|
||||||
movedRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpenMobile(false);
|
|
||||||
void openCallPage(callId);
|
|
||||||
}}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (event.button !== 0) return;
|
|
||||||
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
dragOffsetRef.current = {
|
|
||||||
x: event.clientX - coordsRef.current.x,
|
|
||||||
y: event.clientY - coordsRef.current.y,
|
|
||||||
};
|
|
||||||
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
|
||||||
movedRef.current = false;
|
|
||||||
setIsDragging(true);
|
|
||||||
}}
|
|
||||||
onPointerMove={(event) => {
|
|
||||||
if (!isDragging) return;
|
|
||||||
|
|
||||||
const bounds = pillRef.current?.getBoundingClientRect();
|
|
||||||
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
|
|
||||||
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
|
|
||||||
const next = {
|
|
||||||
x: Math.min(
|
|
||||||
window.innerWidth - width - MOBILE_MARGIN,
|
|
||||||
Math.max(MOBILE_MARGIN, event.clientX - dragOffsetRef.current.x),
|
|
||||||
),
|
|
||||||
y: Math.min(
|
|
||||||
window.innerHeight - height - MOBILE_MARGIN,
|
|
||||||
Math.max(MOBILE_MARGIN, event.clientY - dragOffsetRef.current.y),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
Math.abs(event.clientX - dragStartRef.current.x) > 3 ||
|
|
||||||
Math.abs(event.clientY - dragStartRef.current.y) > 3
|
|
||||||
) {
|
|
||||||
movedRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCoordsSafe(next);
|
|
||||||
}}
|
|
||||||
onPointerUp={(event) => {
|
|
||||||
if (!isDragging) return;
|
|
||||||
|
|
||||||
const nextPosition = `${
|
|
||||||
event.clientY < window.innerHeight / 2 ? "top" : "bottom"
|
|
||||||
}-${event.clientX < window.innerWidth / 2 ? "left" : "right"}` as Positions;
|
|
||||||
|
|
||||||
setIsDragging(false);
|
|
||||||
snapToPosition(nextPosition);
|
|
||||||
}}
|
|
||||||
onPointerCancel={() => {
|
|
||||||
setIsDragging(false);
|
|
||||||
snapToPosition(position);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"fixed left-0 top-0 z-200 flex w-23 h-23! touch-none select-none shadow-xl rounded-2xl flex items-center justify-center",
|
|
||||||
isSpeaking && "border-3! border-(--primary-foreground-alt)/75!",
|
|
||||||
isDragging ? "cursor-grabbing" : "cursor-grab",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: avatarBackgroundColor,
|
|
||||||
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
|
|
||||||
transition: isDragging
|
|
||||||
? "none"
|
|
||||||
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
|
|
||||||
willChange: "transform",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Avatar className="size-14">
|
<Card
|
||||||
<AvatarImage src={lastSpeakingUser?.Avatar} />
|
ref={pillRef}
|
||||||
<AvatarFallback className="text-lg">
|
onClick={() => {
|
||||||
{lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."}
|
if (movedRef.current) {
|
||||||
</AvatarFallback>
|
movedRef.current = false;
|
||||||
</Avatar>
|
return;
|
||||||
</Card>
|
}
|
||||||
|
|
||||||
|
setOpenMobile(false);
|
||||||
|
void openCallPage(callId);
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
dragOffsetRef.current = {
|
||||||
|
x: event.clientX - coordsRef.current.x,
|
||||||
|
y: event.clientY - coordsRef.current.y,
|
||||||
|
};
|
||||||
|
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
||||||
|
movedRef.current = false;
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const bounds = pillRef.current?.getBoundingClientRect();
|
||||||
|
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
|
||||||
|
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
|
||||||
|
const safeArea = getSafeArea();
|
||||||
|
const next = {
|
||||||
|
x: Math.min(
|
||||||
|
window.innerWidth - safeArea.right - width - MOBILE_MARGIN,
|
||||||
|
Math.max(
|
||||||
|
safeArea.left + MOBILE_MARGIN,
|
||||||
|
event.clientX - dragOffsetRef.current.x,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
y: Math.min(
|
||||||
|
window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN,
|
||||||
|
Math.max(
|
||||||
|
safeArea.top + MOBILE_MARGIN,
|
||||||
|
event.clientY - dragOffsetRef.current.y,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(event.clientX - dragStartRef.current.x) > 3 ||
|
||||||
|
Math.abs(event.clientY - dragStartRef.current.y) > 3
|
||||||
|
) {
|
||||||
|
movedRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCoordsSafe(next);
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const nextPosition = `${
|
||||||
|
event.clientY < window.innerHeight / 2 ? "top" : "bottom"
|
||||||
|
}-${event.clientX < window.innerWidth / 2 ? "left" : "right"}` as Positions;
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
snapToPosition(nextPosition);
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
snapToPosition(position);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto fixed left-0 top-0 z-200 flex w-23 h-23! touch-none select-none shadow-xl rounded-2xl flex items-center justify-center",
|
||||||
|
isSpeaking && "border-3! border-(--primary-foreground-alt)/75!",
|
||||||
|
isDragging ? "cursor-grabbing" : "cursor-grab",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: avatarBackgroundColor,
|
||||||
|
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
|
||||||
|
transition: isDragging
|
||||||
|
? "none"
|
||||||
|
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||||
|
willChange: "transform",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar className="size-14">
|
||||||
|
<AvatarImage src={lastSpeakingUser?.Avatar} />
|
||||||
|
<AvatarFallback className="text-lg">
|
||||||
|
{lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,336 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Label,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
Switch,
|
|
||||||
} from "@methanium/ui";
|
|
||||||
import {
|
|
||||||
type DesktopScreenShareAudioOutput,
|
|
||||||
type DesktopScreenShareCapabilities,
|
|
||||||
type DesktopScreenShareSource,
|
|
||||||
useDesktopMedia,
|
|
||||||
} from "@tensamin/shared/desktopMedia";
|
|
||||||
import { toast } from "@tensamin/shared/log";
|
|
||||||
import { AppWindow, Loader2, MonitorUp } from "lucide-react";
|
|
||||||
import type { ScreenShareCaptureOptions } from "livekit-client";
|
|
||||||
import { setScreenShareEnabled, startLinuxDesktopScreenShare } from "../store";
|
|
||||||
|
|
||||||
const NONE_AUDIO_OUTPUT = "__none__";
|
|
||||||
|
|
||||||
function buildScreenShareOptions(
|
|
||||||
source: DesktopScreenShareSource,
|
|
||||||
capabilities: DesktopScreenShareCapabilities,
|
|
||||||
selectedAudioOutputId: string,
|
|
||||||
shareAudio: boolean,
|
|
||||||
): ScreenShareCaptureOptions {
|
|
||||||
const wantsAudio = capabilities.showAudioOutputSelector
|
|
||||||
? selectedAudioOutputId !== NONE_AUDIO_OUTPUT
|
|
||||||
: capabilities.hasReliableSystemAudio && shareAudio;
|
|
||||||
|
|
||||||
return {
|
|
||||||
audio: wantsAudio
|
|
||||||
? {
|
|
||||||
autoGainControl: false,
|
|
||||||
echoCancellation: false,
|
|
||||||
noiseSuppression: false,
|
|
||||||
}
|
|
||||||
: false,
|
|
||||||
video: {
|
|
||||||
displaySurface: source.kind === "window" ? "window" : "monitor",
|
|
||||||
},
|
|
||||||
systemAudio: wantsAudio ? "include" : "exclude",
|
|
||||||
surfaceSwitching: "exclude",
|
|
||||||
selfBrowserSurface: "exclude",
|
|
||||||
contentHint: "detail",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScreenShareDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
isScreensharing,
|
|
||||||
portalContainer,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
isScreensharing: boolean;
|
|
||||||
portalContainer?: HTMLElement;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
getScreenShareCapabilities,
|
|
||||||
listScreenShareAudioOutputs,
|
|
||||||
listScreenShareSources,
|
|
||||||
} = useDesktopMedia();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [sources, setSources] = useState<DesktopScreenShareSource[]>([]);
|
|
||||||
const [audioOutputs, setAudioOutputs] = useState<
|
|
||||||
DesktopScreenShareAudioOutput[]
|
|
||||||
>([]);
|
|
||||||
const [capabilities, setCapabilities] =
|
|
||||||
useState<DesktopScreenShareCapabilities | null>(null);
|
|
||||||
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
|
||||||
const [selectedAudioOutputId, setSelectedAudioOutputId] =
|
|
||||||
useState<string>(NONE_AUDIO_OUTPUT);
|
|
||||||
const [shareAudio, setShareAudio] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setSelectedSourceId(null);
|
|
||||||
setSelectedAudioOutputId(NONE_AUDIO_OUTPUT);
|
|
||||||
setShareAudio(false);
|
|
||||||
|
|
||||||
Promise.all([listScreenShareSources(), getScreenShareCapabilities()])
|
|
||||||
.then(async ([nextSources, nextCapabilities]) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSources(nextSources);
|
|
||||||
setCapabilities(nextCapabilities);
|
|
||||||
|
|
||||||
if (nextCapabilities.showAudioOutputSelector) {
|
|
||||||
const nextOutputs = await listScreenShareAudioOutputs();
|
|
||||||
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAudioOutputs(nextOutputs);
|
|
||||||
} else {
|
|
||||||
setAudioOutputs([]);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to load desktop share sources", error);
|
|
||||||
toast("error", "Failed to load screen share sources.");
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (active) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
getScreenShareCapabilities,
|
|
||||||
listScreenShareAudioOutputs,
|
|
||||||
listScreenShareSources,
|
|
||||||
open,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const selectedSource =
|
|
||||||
sources.find((source) => source.id === selectedSourceId) ?? null;
|
|
||||||
|
|
||||||
async function startSharing() {
|
|
||||||
if (!selectedSource || !capabilities) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (capabilities.runtime === "electron") {
|
|
||||||
await startLinuxDesktopScreenShare(selectedSource.id);
|
|
||||||
} else if (capabilities.platform === "linux") {
|
|
||||||
await startLinuxDesktopScreenShare(selectedSource.id);
|
|
||||||
} else {
|
|
||||||
await setScreenShareEnabled(
|
|
||||||
true,
|
|
||||||
buildScreenShareOptions(
|
|
||||||
selectedSource,
|
|
||||||
capabilities,
|
|
||||||
selectedAudioOutputId,
|
|
||||||
shareAudio,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to start screen share", error);
|
|
||||||
toast(
|
|
||||||
"error",
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to start screen sharing.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopSharing() {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setScreenShareEnabled(false);
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to stop screen share", error);
|
|
||||||
toast("error", "Failed to stop screen sharing.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent
|
|
||||||
className="max-w-3xl gap-0 overflow-hidden p-0"
|
|
||||||
portalProps={{ container: portalContainer }}
|
|
||||||
>
|
|
||||||
<DialogHeader className="p-4 pb-3">
|
|
||||||
<DialogTitle>Share your screen</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Choose a window or display you want to share.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex max-h-[75vh] flex-col gap-4 overflow-y-auto px-4 pb-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{sources.map((source) => {
|
|
||||||
const selected = source.id === selectedSourceId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={source.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedSourceId(source.id)}
|
|
||||||
className={[
|
|
||||||
"flex min-h-28 flex-col justify-between rounded-xl border p-4 text-left transition-colors",
|
|
||||||
selected
|
|
||||||
? "border-primary bg-primary/5"
|
|
||||||
: "border-border bg-card hover:bg-muted/50",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
{source.kind === "window" ? (
|
|
||||||
<AppWindow className="size-4" />
|
|
||||||
) : (
|
|
||||||
<MonitorUp className="size-4" />
|
|
||||||
)}
|
|
||||||
{source.name}
|
|
||||||
</div>
|
|
||||||
{source.subtitle && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{source.subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!loading && sources.length === 0 && (
|
|
||||||
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
|
||||||
No windows or displays found.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{capabilities?.showAudioOutputSelector ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>Share audio output</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedAudioOutputId}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setSelectedAudioOutputId(value ?? NONE_AUDIO_OUTPUT)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue>
|
|
||||||
{selectedAudioOutputId === NONE_AUDIO_OUTPUT
|
|
||||||
? "None"
|
|
||||||
: (audioOutputs.find(
|
|
||||||
(output) => output.id === selectedAudioOutputId,
|
|
||||||
)?.name ?? "None")}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={NONE_AUDIO_OUTPUT}>None</SelectItem>
|
|
||||||
{audioOutputs.map((output) => (
|
|
||||||
<SelectItem key={output.id} value={output.id}>
|
|
||||||
{output.isDefault
|
|
||||||
? `${output.name} (Default)`
|
|
||||||
: output.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
) : capabilities?.showAudioSwitch ? (
|
|
||||||
<div className="flex flex-col gap-2 rounded-xl border p-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="share-system-audio">Share audio</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Share system audio alongside your screen when the runtime
|
|
||||||
can provide it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="share-system-audio"
|
|
||||||
checked={shareAudio}
|
|
||||||
disabled={!capabilities.hasReliableSystemAudio}
|
|
||||||
onCheckedChange={setShareAudio}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!capabilities.hasReliableSystemAudio && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
System audio sharing is not available on this platform.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading sources...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="m-0! p-2!">
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
{isScreensharing && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={stopSharing}
|
|
||||||
>
|
|
||||||
Stop sharing
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
disabled={loading || selectedSource == null}
|
|
||||||
onClick={startSharing}
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
|
|
||||||
Start sharing
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import ScreenshareButton from "./buttons/screenshare";
|
import MediaShareButton from "./buttons/mediaShare";
|
||||||
import MuteButton from "./buttons/mute";
|
import MuteButton from "./buttons/mute";
|
||||||
import DeafButton from "./buttons/deaf";
|
import DeafButton from "./buttons/deaf";
|
||||||
import { Room, Track } from "livekit-client";
|
import { Room, Track } from "livekit-client";
|
||||||
|
|
@ -199,7 +199,7 @@ export default function SidebarBox() {
|
||||||
<div className="flex justify-center gap-1">
|
<div className="flex justify-center gap-1">
|
||||||
<MuteButton className="h-9! w-[24%]" />
|
<MuteButton className="h-9! w-[24%]" />
|
||||||
<DeafButton className="h-9! w-[24%]" />
|
<DeafButton className="h-9! w-[24%]" />
|
||||||
<ScreenshareButton className="h-9! w-[24%]" defaultPortal />
|
<MediaShareButton className="h-9! w-[24%]!" defaultPortal />
|
||||||
<LeaveButton className="h-9! w-[24%]" />
|
<LeaveButton className="h-9! w-[24%]" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
84
packages/call/src/mediaShare/browser.ts
Normal file
84
packages/call/src/mediaShare/browser.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import type {
|
||||||
|
MediaShareAdapter,
|
||||||
|
MediaShareCapabilities,
|
||||||
|
MediaShareKind,
|
||||||
|
MediaShareRequest,
|
||||||
|
MediaShareSession,
|
||||||
|
MediaShareSource,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export async function listCameraSources(): Promise<MediaShareSource[]> {
|
||||||
|
const permissionStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: true,
|
||||||
|
});
|
||||||
|
let devices: MediaDeviceInfo[];
|
||||||
|
try {
|
||||||
|
devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
} finally {
|
||||||
|
permissionStream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
let cameraIndex = 0;
|
||||||
|
|
||||||
|
return devices
|
||||||
|
.filter((device) => device.kind === "videoinput")
|
||||||
|
.map((device) => ({
|
||||||
|
id: device.deviceId,
|
||||||
|
kind: "camera" as const,
|
||||||
|
name: device.label || `Camera ${++cameraIndex}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCamera(
|
||||||
|
sourceId?: string,
|
||||||
|
): Promise<MediaShareSession> {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: sourceId ? { deviceId: { exact: sourceId } } : true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return streamSession(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamSession(stream: MediaStream): MediaShareSession {
|
||||||
|
return {
|
||||||
|
tracks: stream.getTracks(),
|
||||||
|
stop: async () => {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowserMediaShareAdapter implements MediaShareAdapter {
|
||||||
|
async getCapabilities(): Promise<MediaShareCapabilities> {
|
||||||
|
return {
|
||||||
|
runtime: "browser",
|
||||||
|
screenPicker: "native",
|
||||||
|
canShareScreenAudio: true,
|
||||||
|
canSelectScreenAudioOutput: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSources(kind: MediaShareKind): Promise<MediaShareSource[]> {
|
||||||
|
return kind === "camera" ? listCameraSources() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(request: MediaShareRequest): Promise<MediaShareSession> {
|
||||||
|
if (request.kind === "camera") {
|
||||||
|
return startCamera(request.sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: DisplayMediaStreamOptions & {
|
||||||
|
systemAudio: "include" | "exclude";
|
||||||
|
surfaceSwitching: "include" | "exclude";
|
||||||
|
} = {
|
||||||
|
audio: request.includeAudio ?? true,
|
||||||
|
video: true,
|
||||||
|
systemAudio: request.includeAudio === false ? "exclude" : "include",
|
||||||
|
surfaceSwitching: "include",
|
||||||
|
};
|
||||||
|
const stream = await navigator.mediaDevices.getDisplayMedia(options);
|
||||||
|
|
||||||
|
return streamSession(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
packages/call/src/mediaShare/controller.ts
Normal file
154
packages/call/src/mediaShare/controller.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import { type LocalTrack, Room, Track } from "livekit-client";
|
||||||
|
import {
|
||||||
|
getMediaShareAdapter,
|
||||||
|
type MediaShareKind,
|
||||||
|
type MediaShareRequest,
|
||||||
|
type MediaShareSession,
|
||||||
|
} from ".";
|
||||||
|
|
||||||
|
export type LocalMediaShareSession = {
|
||||||
|
tracks: Array<LocalTrack | MediaStreamTrack>;
|
||||||
|
capture: MediaShareSession;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaShareStoreState = {
|
||||||
|
screenShareSession: LocalMediaShareSession | null;
|
||||||
|
cameraSession: LocalMediaShareSession | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaShareStoreSetState = (
|
||||||
|
updater:
|
||||||
|
| Partial<MediaShareStoreState>
|
||||||
|
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type MediaShareControllerOptions = {
|
||||||
|
room: Room;
|
||||||
|
getState: () => MediaShareStoreState;
|
||||||
|
setState: MediaShareStoreSetState;
|
||||||
|
getLocalParticipantId: () => number | null;
|
||||||
|
startWatching: (participantId: number) => void;
|
||||||
|
stopWatching: (participantId: number) => void;
|
||||||
|
syncParticipantState: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMediaShareController({
|
||||||
|
room,
|
||||||
|
getState,
|
||||||
|
setState,
|
||||||
|
getLocalParticipantId,
|
||||||
|
startWatching,
|
||||||
|
stopWatching,
|
||||||
|
syncParticipantState,
|
||||||
|
}: MediaShareControllerOptions) {
|
||||||
|
function getSession(kind: MediaShareKind) {
|
||||||
|
return kind === "screen"
|
||||||
|
? getState().screenShareSession
|
||||||
|
: getState().cameraSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSession(
|
||||||
|
kind: MediaShareKind,
|
||||||
|
session: LocalMediaShareSession | null,
|
||||||
|
) {
|
||||||
|
setState(
|
||||||
|
kind === "screen"
|
||||||
|
? { screenShareSession: session }
|
||||||
|
: { cameraSession: session },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPublishedShare(kind: MediaShareKind) {
|
||||||
|
const session = getSession(kind);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
setSession(kind, null);
|
||||||
|
await Promise.all(
|
||||||
|
session.tracks.map((track) =>
|
||||||
|
room.localParticipant.unpublishTrack(track, true).catch((error) => {
|
||||||
|
log(1, "call", "red", `Failed to unpublish ${kind} track`, error);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await session.capture.stop().catch((error) => {
|
||||||
|
log(1, "call", "red", `Failed to stop ${kind} capture`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (kind === "screen") {
|
||||||
|
const localParticipantId = getLocalParticipantId();
|
||||||
|
if (localParticipantId != null) stopWatching(localParticipantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishShare(
|
||||||
|
kind: MediaShareKind,
|
||||||
|
capture: MediaShareSession,
|
||||||
|
) {
|
||||||
|
if (capture.tracks.length === 0) {
|
||||||
|
await capture.stop();
|
||||||
|
throw new Error(`No ${kind} tracks were created.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const published: MediaStreamTrack[] = [];
|
||||||
|
try {
|
||||||
|
for (const track of capture.tracks) {
|
||||||
|
await room.localParticipant.publishTrack(track, {
|
||||||
|
source:
|
||||||
|
track.kind === Track.Kind.Audio
|
||||||
|
? Track.Source.ScreenShareAudio
|
||||||
|
: kind === "screen"
|
||||||
|
? Track.Source.ScreenShare
|
||||||
|
: Track.Source.Camera,
|
||||||
|
});
|
||||||
|
published.push(track);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.all(
|
||||||
|
published.map((track) =>
|
||||||
|
room.localParticipant.unpublishTrack(track, true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await capture.stop();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const track of capture.tracks) {
|
||||||
|
track.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => {
|
||||||
|
void stop(kind);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(kind, { tracks: capture.tracks, capture });
|
||||||
|
syncParticipantState();
|
||||||
|
|
||||||
|
if (kind === "screen") {
|
||||||
|
const localParticipantId = getLocalParticipantId();
|
||||||
|
if (localParticipantId != null) startWatching(localParticipantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(request: MediaShareRequest) {
|
||||||
|
await clearPublishedShare(request.kind);
|
||||||
|
const capture = await getMediaShareAdapter().start(request);
|
||||||
|
await publishShare(request.kind, capture);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(kind: MediaShareKind) {
|
||||||
|
await clearPublishedShare(kind);
|
||||||
|
syncParticipantState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAll() {
|
||||||
|
await Promise.all([
|
||||||
|
clearPublishedShare("screen"),
|
||||||
|
clearPublishedShare("camera"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clearAll, start, stop };
|
||||||
|
}
|
||||||
54
packages/call/src/mediaShare/electron.ts
Normal file
54
packages/call/src/mediaShare/electron.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import type {} from "@tensamin/shared/desktopMedia";
|
||||||
|
import { BrowserMediaShareAdapter, listCameraSources } from "./browser";
|
||||||
|
import type {
|
||||||
|
MediaShareCapabilities,
|
||||||
|
MediaShareKind,
|
||||||
|
MediaShareRequest,
|
||||||
|
MediaShareSession,
|
||||||
|
MediaShareSource,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export class ElectronMediaShareAdapter extends BrowserMediaShareAdapter {
|
||||||
|
override async getCapabilities(): Promise<MediaShareCapabilities> {
|
||||||
|
const capabilities =
|
||||||
|
await window.tensaminDesktop?.media?.getScreenShareCapabilities?.();
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime: "electron",
|
||||||
|
screenPicker: "sources",
|
||||||
|
canShareScreenAudio: capabilities?.hasReliableSystemAudio ?? false,
|
||||||
|
canSelectScreenAudioOutput:
|
||||||
|
capabilities?.showAudioOutputSelector ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override async listSources(
|
||||||
|
kind: MediaShareKind,
|
||||||
|
): Promise<MediaShareSource[]> {
|
||||||
|
if (kind === "camera") {
|
||||||
|
return listCameraSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(await window.tensaminDesktop?.media?.listScreenShareSources?.()) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async start(request: MediaShareRequest): Promise<MediaShareSession> {
|
||||||
|
if (request.kind === "camera") {
|
||||||
|
return super.start(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.sourceId) {
|
||||||
|
throw new Error("Choose a screen or window to share.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = window.tensaminDesktop?.media?.selectScreenShareSource;
|
||||||
|
if (!select) {
|
||||||
|
throw new Error("Electron screen capture is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await select(request.sourceId);
|
||||||
|
return super.start({ ...request, sourceId: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/call/src/mediaShare/index.ts
Normal file
27
packages/call/src/mediaShare/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { BrowserMediaShareAdapter } from "./browser";
|
||||||
|
import { ElectronMediaShareAdapter } from "./electron";
|
||||||
|
import { TauriMediaShareAdapter } from "./tauri";
|
||||||
|
import type { MediaShareAdapter } from "./types";
|
||||||
|
|
||||||
|
let adapter: MediaShareAdapter | null = null;
|
||||||
|
|
||||||
|
export function getMediaShareAdapter(): MediaShareAdapter {
|
||||||
|
if (!adapter) {
|
||||||
|
adapter = window.tensaminMobileMedia
|
||||||
|
? new TauriMediaShareAdapter()
|
||||||
|
: window.tensaminDesktop?.media
|
||||||
|
? new ElectronMediaShareAdapter()
|
||||||
|
: new BrowserMediaShareAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
MediaShareAdapter,
|
||||||
|
MediaShareCapabilities,
|
||||||
|
MediaShareKind,
|
||||||
|
MediaShareRequest,
|
||||||
|
MediaShareSession,
|
||||||
|
MediaShareSource,
|
||||||
|
} from "./types";
|
||||||
270
packages/call/src/mediaShare/tauri.ts
Normal file
270
packages/call/src/mediaShare/tauri.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
import { listCameraSources, startCamera } from "./browser";
|
||||||
|
import type {
|
||||||
|
MediaShareAdapter,
|
||||||
|
MediaShareCapabilities,
|
||||||
|
MediaShareKind,
|
||||||
|
MediaShareRequest,
|
||||||
|
MediaShareSession,
|
||||||
|
MediaShareSource,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type MobileMediaApi = {
|
||||||
|
startScreenShare: (includeAudio: boolean) => void;
|
||||||
|
stopScreenShare: () => void;
|
||||||
|
requestCameraPermission: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
tensaminMobileMedia?: MobileMediaApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FrameDetail = {
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AudioDetail = {
|
||||||
|
data: string;
|
||||||
|
sampleRate: number;
|
||||||
|
channelCount: number;
|
||||||
|
encoding: "pcm16le";
|
||||||
|
};
|
||||||
|
|
||||||
|
function eventDetail<T>(event: Event): T {
|
||||||
|
return (event as CustomEvent<T>).detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(value: string): Uint8Array {
|
||||||
|
const decoded = atob(value);
|
||||||
|
const bytes = new Uint8Array(decoded.length);
|
||||||
|
for (let index = 0; index < decoded.length; index += 1) {
|
||||||
|
bytes[index] = decoded.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCameraPermission() {
|
||||||
|
const bridge = window.tensaminMobileMedia;
|
||||||
|
if (!bridge) throw new Error("Tauri mobile media bridge is unavailable.");
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Timed out waiting for camera permission."));
|
||||||
|
}, 30_000);
|
||||||
|
const onPermission = (event: Event) => {
|
||||||
|
cleanup();
|
||||||
|
const permission = eventDetail<{ camera: boolean }>(event);
|
||||||
|
if (permission.camera) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error("Camera permission was denied."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
window.removeEventListener(
|
||||||
|
"tensamin-mobile-camera-permission",
|
||||||
|
onPermission,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("tensamin-mobile-camera-permission", onPermission);
|
||||||
|
bridge.requestCameraPermission();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMobileScreen(
|
||||||
|
includeAudio: boolean,
|
||||||
|
): Promise<MediaShareSession> {
|
||||||
|
const bridge = window.tensaminMobileMedia;
|
||||||
|
if (!bridge) throw new Error("Tauri mobile media bridge is unavailable.");
|
||||||
|
|
||||||
|
return new Promise<MediaShareSession>((resolve, reject) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
reject(new Error("Unable to create the mobile capture canvas."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = canvas.captureStream(15);
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
let audioNode: ScriptProcessorNode | null = null;
|
||||||
|
let audioDestination: MediaStreamAudioDestinationNode | null = null;
|
||||||
|
const audioQueue: Float32Array[] = [];
|
||||||
|
let audioQueueOffset = 0;
|
||||||
|
let queuedAudioSamples = 0;
|
||||||
|
let started = false;
|
||||||
|
let resolved = false;
|
||||||
|
let firstFrame = false;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
let errorTimer = 0;
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
bridge.stopScreenShare();
|
||||||
|
reject(
|
||||||
|
lastError ?? new Error("Timed out starting mobile screen sharing."),
|
||||||
|
);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
const complete = () => {
|
||||||
|
if (!started || !firstFrame || resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
resolve({
|
||||||
|
tracks: stream.getTracks(),
|
||||||
|
stop: async () => {
|
||||||
|
bridge.stopScreenShare();
|
||||||
|
cleanup();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStarted = (event: Event) => {
|
||||||
|
started = true;
|
||||||
|
const detail = eventDetail<{ includeAudio: boolean }>(event);
|
||||||
|
if (detail.includeAudio) {
|
||||||
|
audioContext = new AudioContext({ sampleRate: 48_000 });
|
||||||
|
audioNode = audioContext.createScriptProcessor(2048, 0, 1);
|
||||||
|
audioDestination = audioContext.createMediaStreamDestination();
|
||||||
|
audioNode.onaudioprocess = ({ outputBuffer }) => {
|
||||||
|
const output = outputBuffer.getChannelData(0);
|
||||||
|
output.fill(0);
|
||||||
|
let outputOffset = 0;
|
||||||
|
while (outputOffset < output.length && audioQueue.length > 0) {
|
||||||
|
const chunk = audioQueue[0];
|
||||||
|
const available = chunk.length - audioQueueOffset;
|
||||||
|
const count = Math.min(available, output.length - outputOffset);
|
||||||
|
output.set(
|
||||||
|
chunk.subarray(audioQueueOffset, audioQueueOffset + count),
|
||||||
|
outputOffset,
|
||||||
|
);
|
||||||
|
outputOffset += count;
|
||||||
|
audioQueueOffset += count;
|
||||||
|
queuedAudioSamples -= count;
|
||||||
|
if (audioQueueOffset === chunk.length) {
|
||||||
|
audioQueue.shift();
|
||||||
|
audioQueueOffset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
audioNode.connect(audioDestination);
|
||||||
|
void audioContext.resume();
|
||||||
|
for (const track of audioDestination.stream.getAudioTracks()) {
|
||||||
|
stream.addTrack(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
complete();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFrame = (event: Event) => {
|
||||||
|
const detail = eventDetail<FrameDetail>(event);
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
if (canvas.width !== detail.width || canvas.height !== detail.height) {
|
||||||
|
canvas.width = detail.width;
|
||||||
|
canvas.height = detail.height;
|
||||||
|
}
|
||||||
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
firstFrame = true;
|
||||||
|
complete();
|
||||||
|
};
|
||||||
|
image.src = `data:${detail.mimeType};base64,${detail.data}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAudio = (event: Event) => {
|
||||||
|
const bytes = decodeBase64(eventDetail<AudioDetail>(event).data);
|
||||||
|
const samples = new Int16Array(
|
||||||
|
bytes.buffer,
|
||||||
|
bytes.byteOffset,
|
||||||
|
Math.floor(bytes.byteLength / 2),
|
||||||
|
);
|
||||||
|
const chunk = new Float32Array(samples.length);
|
||||||
|
for (let index = 0; index < samples.length; index += 1) {
|
||||||
|
chunk[index] = samples[index] / 32768;
|
||||||
|
}
|
||||||
|
audioQueue.push(chunk);
|
||||||
|
queuedAudioSamples += chunk.length;
|
||||||
|
const maximumQueuedSamples = 48_000 * 2;
|
||||||
|
while (queuedAudioSamples > maximumQueuedSamples && audioQueue.length) {
|
||||||
|
const dropped = audioQueue.shift();
|
||||||
|
if (!dropped) break;
|
||||||
|
queuedAudioSamples -= dropped.length - audioQueueOffset;
|
||||||
|
audioQueueOffset = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStopped = () => {
|
||||||
|
cleanup();
|
||||||
|
if (!resolved) reject(new Error("Mobile screen sharing was stopped."));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (event: Event) => {
|
||||||
|
lastError = new Error(eventDetail<{ message: string }>(event).message);
|
||||||
|
window.clearTimeout(errorTimer);
|
||||||
|
errorTimer = window.setTimeout(() => {
|
||||||
|
if (!started && !resolved) {
|
||||||
|
cleanup();
|
||||||
|
reject(lastError ?? new Error("Mobile screen sharing failed."));
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<[string, EventListener]> = [
|
||||||
|
["tensamin-mobile-screen-started", onStarted],
|
||||||
|
["tensamin-mobile-screen-frame", onFrame],
|
||||||
|
["tensamin-mobile-screen-audio", onAudio],
|
||||||
|
["tensamin-mobile-screen-stopped", onStopped],
|
||||||
|
["tensamin-mobile-screen-error", onError],
|
||||||
|
];
|
||||||
|
const cleanup = () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
window.clearTimeout(errorTimer);
|
||||||
|
listeners.forEach(([name, listener]) =>
|
||||||
|
window.removeEventListener(name, listener),
|
||||||
|
);
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
audioNode?.disconnect();
|
||||||
|
audioNode = null;
|
||||||
|
void audioContext?.close();
|
||||||
|
audioContext = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
listeners.forEach(([name, listener]) =>
|
||||||
|
window.addEventListener(name, listener),
|
||||||
|
);
|
||||||
|
bridge.startScreenShare(includeAudio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TauriMediaShareAdapter implements MediaShareAdapter {
|
||||||
|
async getCapabilities(): Promise<MediaShareCapabilities> {
|
||||||
|
return {
|
||||||
|
runtime: "tauri",
|
||||||
|
screenPicker: "system",
|
||||||
|
canShareScreenAudio: true,
|
||||||
|
canSelectScreenAudioOutput: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSources(kind: MediaShareKind): Promise<MediaShareSource[]> {
|
||||||
|
if (kind !== "camera") return [];
|
||||||
|
await requestCameraPermission();
|
||||||
|
return listCameraSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(request: MediaShareRequest): Promise<MediaShareSession> {
|
||||||
|
if (request.kind === "screen") {
|
||||||
|
return startMobileScreen(request.includeAudio ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestCameraPermission();
|
||||||
|
return startCamera(request.sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
packages/call/src/mediaShare/types.ts
Normal file
33
packages/call/src/mediaShare/types.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export type MediaShareKind = "screen" | "camera";
|
||||||
|
|
||||||
|
export type MediaShareSource = {
|
||||||
|
id: string;
|
||||||
|
kind: "screen" | "window" | "camera";
|
||||||
|
name: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaShareCapabilities = {
|
||||||
|
runtime: "browser" | "electron" | "tauri";
|
||||||
|
screenPicker: "native" | "sources" | "system";
|
||||||
|
canShareScreenAudio: boolean;
|
||||||
|
canSelectScreenAudioOutput: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaShareRequest = {
|
||||||
|
kind: MediaShareKind;
|
||||||
|
sourceId?: string;
|
||||||
|
includeAudio?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaShareSession = {
|
||||||
|
tracks: MediaStreamTrack[];
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MediaShareAdapter {
|
||||||
|
getCapabilities(): Promise<MediaShareCapabilities>;
|
||||||
|
listSources(kind: MediaShareKind): Promise<MediaShareSource[]>;
|
||||||
|
start(request: MediaShareRequest): Promise<MediaShareSession>;
|
||||||
|
}
|
||||||
|
|
@ -31,8 +31,13 @@ function copyDocumentStyles(targetDocument: Document) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDocumentClasses(targetDocument: Document) {
|
function syncDocumentAttributes(targetDocument: Document) {
|
||||||
targetDocument.documentElement.className = document.documentElement.className;
|
for (const attribute of document.documentElement.attributes) {
|
||||||
|
targetDocument.documentElement.setAttribute(
|
||||||
|
attribute.name,
|
||||||
|
attribute.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
targetDocument.body.className = document.body.className;
|
targetDocument.body.className = document.body.className;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,12 +63,12 @@ function PopoutScreen() {
|
||||||
popoutWindow.document.title = document.title;
|
popoutWindow.document.title = document.title;
|
||||||
popoutWindow.document.body.innerHTML = "";
|
popoutWindow.document.body.innerHTML = "";
|
||||||
popoutWindow.document.body.style.margin = "0";
|
popoutWindow.document.body.style.margin = "0";
|
||||||
|
copyDocumentStyles(popoutWindow.document);
|
||||||
|
syncDocumentAttributes(popoutWindow.document);
|
||||||
|
|
||||||
popoutWindow.document.documentElement.style.height = "100%";
|
popoutWindow.document.documentElement.style.height = "100%";
|
||||||
popoutWindow.document.body.style.height = "100%";
|
popoutWindow.document.body.style.height = "100%";
|
||||||
|
|
||||||
copyDocumentStyles(popoutWindow.document);
|
|
||||||
syncDocumentClasses(popoutWindow.document);
|
|
||||||
|
|
||||||
const containerElement = popoutWindow.document.createElement("div");
|
const containerElement = popoutWindow.document.createElement("div");
|
||||||
containerElement.style.width = "100%";
|
containerElement.style.width = "100%";
|
||||||
containerElement.style.height = "100%";
|
containerElement.style.height = "100%";
|
||||||
|
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
import { log } from "@tensamin/shared/log";
|
|
||||||
import type {} from "@tensamin/shared/desktopMedia";
|
|
||||||
import {
|
|
||||||
type LocalTrack,
|
|
||||||
Room,
|
|
||||||
type ScreenShareCaptureOptions,
|
|
||||||
Track,
|
|
||||||
} from "livekit-client";
|
|
||||||
|
|
||||||
export type ScreenShareSession = {
|
|
||||||
tracks: Array<LocalTrack | MediaStreamTrack>;
|
|
||||||
cleanup?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenShareStoreState = {
|
|
||||||
screenShareSession: ScreenShareSession | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenShareStoreSetState = (
|
|
||||||
updater:
|
|
||||||
| Partial<ScreenShareStoreState>
|
|
||||||
| ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>),
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
type ScreenShareControllerOptions = {
|
|
||||||
room: Room;
|
|
||||||
getState: () => ScreenShareStoreState;
|
|
||||||
setState: ScreenShareStoreSetState;
|
|
||||||
getLocalParticipantId: () => number | null;
|
|
||||||
startWatching: (participantId: number, options?: { focus?: boolean }) => void;
|
|
||||||
stopWatching: (participantId: number) => void;
|
|
||||||
syncParticipantState: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createScreenShareController({
|
|
||||||
room,
|
|
||||||
getState,
|
|
||||||
setState,
|
|
||||||
getLocalParticipantId,
|
|
||||||
startWatching,
|
|
||||||
stopWatching,
|
|
||||||
syncParticipantState,
|
|
||||||
}: ScreenShareControllerOptions) {
|
|
||||||
async function clearPublishedScreenShare() {
|
|
||||||
const screenShareSession = getState().screenShareSession;
|
|
||||||
|
|
||||||
if (!screenShareSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
screenShareSession.tracks.map((track) =>
|
|
||||||
room.localParticipant.unpublishTrack(track, true).catch((error) => {
|
|
||||||
log(
|
|
||||||
1,
|
|
||||||
"call",
|
|
||||||
"red",
|
|
||||||
"Failed to unpublish screen share track",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
screenShareSession.cleanup?.();
|
|
||||||
|
|
||||||
const localParticipantId = getLocalParticipantId();
|
|
||||||
|
|
||||||
if (localParticipantId != null) {
|
|
||||||
stopWatching(localParticipantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({ screenShareSession: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishScreenShareTracks(
|
|
||||||
tracks: Array<LocalTrack | MediaStreamTrack>,
|
|
||||||
cleanup?: () => void,
|
|
||||||
) {
|
|
||||||
if (tracks.length === 0) {
|
|
||||||
throw new Error("No screen share tracks were created.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
tracks.map((track) =>
|
|
||||||
room.localParticipant.publishTrack(track, {
|
|
||||||
source:
|
|
||||||
track.kind === Track.Kind.Video
|
|
||||||
? Track.Source.ScreenShare
|
|
||||||
: Track.Source.ScreenShareAudio,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const track of tracks) {
|
|
||||||
const mediaStreamTrack =
|
|
||||||
track instanceof MediaStreamTrack ? track : track.mediaStreamTrack;
|
|
||||||
|
|
||||||
mediaStreamTrack.addEventListener(
|
|
||||||
"ended",
|
|
||||||
() => {
|
|
||||||
void stopScreenShare();
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({ screenShareSession: { tracks, cleanup } });
|
|
||||||
syncParticipantState();
|
|
||||||
|
|
||||||
const localParticipantId = getLocalParticipantId();
|
|
||||||
|
|
||||||
if (localParticipantId != null) {
|
|
||||||
startWatching(localParticipantId, { focus: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startScreenShare(options?: ScreenShareCaptureOptions) {
|
|
||||||
await clearPublishedScreenShare();
|
|
||||||
|
|
||||||
const tracks = await room.localParticipant.createScreenTracks(options);
|
|
||||||
|
|
||||||
await publishScreenShareTracks(tracks, () => {
|
|
||||||
tracks.forEach((track) => track.stop());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startLinuxDesktopScreenShare(sourceId: string) {
|
|
||||||
await clearPublishedScreenShare();
|
|
||||||
|
|
||||||
if (window.tensaminDesktop?.media?.selectScreenShareSource) {
|
|
||||||
await window.tensaminDesktop.media.selectScreenShareSource(sourceId);
|
|
||||||
const tracks = await room.localParticipant.createScreenTracks({
|
|
||||||
audio: false,
|
|
||||||
video: true,
|
|
||||||
systemAudio: "exclude",
|
|
||||||
surfaceSwitching: "exclude",
|
|
||||||
selfBrowserSurface: "exclude",
|
|
||||||
contentHint: "detail",
|
|
||||||
});
|
|
||||||
|
|
||||||
await publishScreenShareTracks(tracks, () => {
|
|
||||||
tracks.forEach((track) => track.stop());
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Electron desktop media bridge is unavailable. Cannot capture ${sourceId}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopScreenShare() {
|
|
||||||
await clearPublishedScreenShare();
|
|
||||||
syncParticipantState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setScreenShareEnabled(
|
|
||||||
enabled: boolean,
|
|
||||||
options?: ScreenShareCaptureOptions,
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
await startScreenShare(options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await stopScreenShare();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
clearPublishedScreenShare,
|
|
||||||
startLinuxDesktopScreenShare,
|
|
||||||
startScreenShare,
|
|
||||||
stopScreenShare,
|
|
||||||
setScreenShareEnabled,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -26,16 +26,16 @@ import {
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
type RemoteTrack,
|
type RemoteTrack,
|
||||||
type ScreenShareCaptureOptions,
|
|
||||||
Track,
|
Track,
|
||||||
setLogExtension,
|
setLogExtension,
|
||||||
getLogger,
|
getLogger,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import {
|
import {
|
||||||
createScreenShareController,
|
createMediaShareController,
|
||||||
type ScreenShareSession,
|
type LocalMediaShareSession,
|
||||||
} from "./screenshare";
|
} from "./mediaShare/controller";
|
||||||
|
import type { MediaShareRequest } from "./mediaShare";
|
||||||
import {
|
import {
|
||||||
getSpeakingDetector,
|
getSpeakingDetector,
|
||||||
disposeSpeakingDetector,
|
disposeSpeakingDetector,
|
||||||
|
|
@ -69,8 +69,7 @@ type IncomingCallInvite = {
|
||||||
senderId: number;
|
senderId: number;
|
||||||
};
|
};
|
||||||
type CurrentCallData =
|
type CurrentCallData =
|
||||||
| (z.infer<typeof mtp.CallData.response> & { exists: boolean })
|
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
|
||||||
| null;
|
|
||||||
|
|
||||||
type NavigateFn = (options: {
|
type NavigateFn = (options: {
|
||||||
to: string;
|
to: string;
|
||||||
|
|
@ -102,8 +101,11 @@ type CallStore = {
|
||||||
currentCallData: CurrentCallData;
|
currentCallData: CurrentCallData;
|
||||||
deaf: boolean;
|
deaf: boolean;
|
||||||
micEnabled: boolean;
|
micEnabled: boolean;
|
||||||
|
cameraEnabled: boolean;
|
||||||
screenShareEnabled: boolean;
|
screenShareEnabled: boolean;
|
||||||
screenShareSession: ScreenShareSession | null;
|
screenShareSession: LocalMediaShareSession | null;
|
||||||
|
cameraSession: LocalMediaShareSession | null;
|
||||||
|
disabledCameraParticipantIds: number[];
|
||||||
focusedParticipantId: number | null;
|
focusedParticipantId: number | null;
|
||||||
focusedParticipantType: "user" | "stream" | null;
|
focusedParticipantType: "user" | "stream" | null;
|
||||||
usersInFocusedViewHidden: boolean;
|
usersInFocusedViewHidden: boolean;
|
||||||
|
|
@ -302,11 +304,21 @@ function matchesRemoteTrackSelector(
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncRemoteParticipantTrackSubscriptions(participantId: number) {
|
function syncRemoteParticipantTrackSubscriptions(participantId: number) {
|
||||||
|
const state = useCall.getState();
|
||||||
|
const watchesScreen =
|
||||||
|
state.watchedStreamParticipantIds.includes(participantId);
|
||||||
|
const cameraDisabled =
|
||||||
|
state.disabledCameraParticipantIds.includes(participantId);
|
||||||
|
|
||||||
for (const publication of getRemoteTrackPublications(participantId)) {
|
for (const publication of getRemoteTrackPublications(participantId)) {
|
||||||
publication.setSubscribed(
|
const subscribed =
|
||||||
publication.kind === Track.Kind.Audio &&
|
publication.source === Track.Source.Camera
|
||||||
publication.source !== Track.Source.ScreenShareAudio,
|
? !cameraDisabled
|
||||||
);
|
: publication.source === Track.Source.ScreenShare ||
|
||||||
|
publication.source === Track.Source.ScreenShareAudio
|
||||||
|
? watchesScreen
|
||||||
|
: publication.kind === Track.Kind.Audio;
|
||||||
|
publication.setSubscribed(subscribed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -596,11 +608,13 @@ export function getRoomMetadata() {
|
||||||
|
|
||||||
// Sync local participant flags and screen-share derived state for the active call UI.
|
// Sync local participant flags and screen-share derived state for the active call UI.
|
||||||
export function syncParticipantState() {
|
export function syncParticipantState() {
|
||||||
const { screenShareSession } = useCall.getState();
|
const { cameraSession, screenShareSession } = useCall.getState();
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
|
|
||||||
useCall.setState({
|
useCall.setState({
|
||||||
micEnabled: room.localParticipant.isMicrophoneEnabled,
|
micEnabled: room.localParticipant.isMicrophoneEnabled,
|
||||||
|
cameraEnabled:
|
||||||
|
cameraSession != null || room.localParticipant.isCameraEnabled,
|
||||||
screenShareEnabled:
|
screenShareEnabled:
|
||||||
screenShareSession != null || room.localParticipant.isScreenShareEnabled,
|
screenShareSession != null || room.localParticipant.isScreenShareEnabled,
|
||||||
isEncrypted:
|
isEncrypted:
|
||||||
|
|
@ -769,6 +783,20 @@ export function setParticipantTrackSubscribed(
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setParticipantCameraDisabled(
|
||||||
|
participantId: number,
|
||||||
|
disabled: boolean,
|
||||||
|
) {
|
||||||
|
useCall.setState((state) => ({
|
||||||
|
disabledCameraParticipantIds: disabled
|
||||||
|
? state.disabledCameraParticipantIds.includes(participantId)
|
||||||
|
? state.disabledCameraParticipantIds
|
||||||
|
: [...state.disabledCameraParticipantIds, participantId]
|
||||||
|
: state.disabledCameraParticipantIds.filter((id) => id !== participantId),
|
||||||
|
}));
|
||||||
|
setParticipantTrackSubscribed(participantId, Track.Source.Camera, !disabled);
|
||||||
|
}
|
||||||
|
|
||||||
// Focus a participant in the main call view even when they are not sharing a screen.
|
// Focus a participant in the main call view even when they are not sharing a screen.
|
||||||
export function focusParticipant(
|
export function focusParticipant(
|
||||||
participantId: number,
|
participantId: number,
|
||||||
|
|
@ -835,9 +863,8 @@ export function stopWatchingFocusedStream() {
|
||||||
stopWatchingStream(focusedParticipantId);
|
stopWatchingStream(focusedParticipantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let screenShareController: ReturnType<
|
let mediaShareController: ReturnType<typeof createMediaShareController> | null =
|
||||||
typeof createScreenShareController
|
null;
|
||||||
> | null = null;
|
|
||||||
|
|
||||||
function getNoiseFilterAssetBaseUrl() {
|
function getNoiseFilterAssetBaseUrl() {
|
||||||
if (window.location.protocol === "file:") {
|
if (window.location.protocol === "file:") {
|
||||||
|
|
@ -847,17 +874,21 @@ function getNoiseFilterAssetBaseUrl() {
|
||||||
return "/assets";
|
return "/assets";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScreenShareController() {
|
function getMediaShareController() {
|
||||||
if (!screenShareController) {
|
if (!mediaShareController) {
|
||||||
screenShareController = createScreenShareController({
|
mediaShareController = createMediaShareController({
|
||||||
room: getRoom(),
|
room: getRoom(),
|
||||||
getState: () => ({
|
getState: () => ({
|
||||||
screenShareSession: useCall.getState().screenShareSession,
|
screenShareSession: useCall.getState().screenShareSession,
|
||||||
|
cameraSession: useCall.getState().cameraSession,
|
||||||
}),
|
}),
|
||||||
setState: (updater) => {
|
setState: (updater) => {
|
||||||
useCall.setState((state) =>
|
useCall.setState((state) =>
|
||||||
typeof updater === "function"
|
typeof updater === "function"
|
||||||
? updater({ screenShareSession: state.screenShareSession })
|
? updater({
|
||||||
|
screenShareSession: state.screenShareSession,
|
||||||
|
cameraSession: state.cameraSession,
|
||||||
|
})
|
||||||
: updater,
|
: updater,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -869,7 +900,7 @@ function getScreenShareController() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return screenShareController;
|
return mediaShareController;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to LiveKit, enable the microphone, and move the UI into the live call.
|
// Connect to LiveKit, enable the microphone, and move the UI into the live call.
|
||||||
|
|
@ -921,7 +952,7 @@ export async function disconnect() {
|
||||||
await clearScreenSharePreview();
|
await clearScreenSharePreview();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getScreenShareController().clearPublishedScreenShare();
|
await getMediaShareController().clearAll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(
|
log(
|
||||||
1,
|
1,
|
||||||
|
|
@ -943,6 +974,9 @@ export async function disconnect() {
|
||||||
deaf: false,
|
deaf: false,
|
||||||
view: "preview",
|
view: "preview",
|
||||||
screenShareSession: null,
|
screenShareSession: null,
|
||||||
|
cameraSession: null,
|
||||||
|
cameraEnabled: false,
|
||||||
|
disabledCameraParticipantIds: [],
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
focusedParticipantType: null,
|
focusedParticipantType: null,
|
||||||
usersInFocusedViewHidden: false,
|
usersInFocusedViewHidden: false,
|
||||||
|
|
@ -1070,37 +1104,37 @@ export async function toggleMute() {
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start browser-native screen sharing for the current participant.
|
export async function startScreenShare(
|
||||||
export async function startScreenShare(options?: ScreenShareCaptureOptions) {
|
request: Omit<MediaShareRequest, "kind"> = {},
|
||||||
await getScreenShareController().startScreenShare(options);
|
) {
|
||||||
|
await getMediaShareController().start({ ...request, kind: "screen" });
|
||||||
await publishScreenSharePreview();
|
await publishScreenSharePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the Linux desktop capture path that renders frames through Tauri.
|
export async function startCameraShare(sourceId?: string) {
|
||||||
export async function startLinuxDesktopScreenShare(sourceId: string) {
|
await getMediaShareController().start({ kind: "camera", sourceId });
|
||||||
await getScreenShareController().startLinuxDesktopScreenShare(sourceId);
|
|
||||||
await publishScreenSharePreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the local participant's active screen share and related previews.
|
// Stop the local participant's active screen share and related previews.
|
||||||
export async function stopScreenShare() {
|
export async function stopScreenShare() {
|
||||||
await getScreenShareController().stopScreenShare();
|
await getMediaShareController().stop("screen");
|
||||||
await clearScreenSharePreview();
|
await clearScreenSharePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function stopCameraShare() {
|
||||||
|
await getMediaShareController().stop("camera");
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle screen sharing on or off from UI controls.
|
// Toggle screen sharing on or off from UI controls.
|
||||||
export async function setScreenShareEnabled(
|
export async function setScreenShareEnabled(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
options?: ScreenShareCaptureOptions,
|
request: Omit<MediaShareRequest, "kind"> = {},
|
||||||
) {
|
) {
|
||||||
await getScreenShareController().setScreenShareEnabled(enabled, options);
|
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
await publishScreenSharePreview();
|
await startScreenShare(request);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await stopScreenShare();
|
||||||
await clearScreenSharePreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the in-memory call store when leaving the call experience entirely.
|
// Reset the in-memory call store when leaving the call experience entirely.
|
||||||
|
|
@ -1115,7 +1149,10 @@ export function resetCallState() {
|
||||||
livekitToken: null,
|
livekitToken: null,
|
||||||
currentCallData: null,
|
currentCallData: null,
|
||||||
deaf: false,
|
deaf: false,
|
||||||
|
cameraEnabled: false,
|
||||||
screenShareSession: null,
|
screenShareSession: null,
|
||||||
|
cameraSession: null,
|
||||||
|
disabledCameraParticipantIds: [],
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
focusedParticipantType: null,
|
focusedParticipantType: null,
|
||||||
usersInFocusedViewHidden: false,
|
usersInFocusedViewHidden: false,
|
||||||
|
|
@ -1169,8 +1206,11 @@ export const useCall = create<CallStore>(() => ({
|
||||||
currentCallData: null,
|
currentCallData: null,
|
||||||
deaf: false,
|
deaf: false,
|
||||||
micEnabled: false,
|
micEnabled: false,
|
||||||
|
cameraEnabled: false,
|
||||||
screenShareEnabled: false,
|
screenShareEnabled: false,
|
||||||
screenShareSession: null,
|
screenShareSession: null,
|
||||||
|
cameraSession: null,
|
||||||
|
disabledCameraParticipantIds: [],
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
focusedParticipantType: null,
|
focusedParticipantType: null,
|
||||||
usersInFocusedViewHidden: false,
|
usersInFocusedViewHidden: false,
|
||||||
|
|
@ -1537,14 +1577,7 @@ export function useInitializeCall() {
|
||||||
const participantId = getParticipantId(participant.identity);
|
const participantId = getParticipantId(participant.identity);
|
||||||
|
|
||||||
if (participantId != null) {
|
if (participantId != null) {
|
||||||
if (
|
syncRemoteParticipantTrackSubscriptions(participantId);
|
||||||
publication.kind === Track.Kind.Audio &&
|
|
||||||
publication.source !== Track.Source.ScreenShareAudio
|
|
||||||
) {
|
|
||||||
publication.setSubscribed(true);
|
|
||||||
} else {
|
|
||||||
publication.setSubscribed(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onParticipantStateChange();
|
onParticipantStateChange();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { RoomEvent } from "livekit-client";
|
import { RoomEvent } from "livekit-client";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useCall, getRoom } from "../../store";
|
import { getRoom, setUsersInFocusedViewHidden, useCall } from "../../store";
|
||||||
import Base from "../../components/modals/base";
|
import Base from "../../components/modals/base";
|
||||||
|
import { useIsMobile } from "@methanium/ui";
|
||||||
|
|
||||||
const SECONDARY_ROW_HEIGHT_PX = 180;
|
const SECONDARY_ROW_HEIGHT_PX = 180;
|
||||||
const STACK_GAP_PX = 12;
|
const STACK_GAP_PX = 12;
|
||||||
|
|
||||||
export default function View() {
|
export default function View() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
const layoutVersion = useCall((state) => state.layoutVersion);
|
const layoutVersion = useCall((state) => state.layoutVersion);
|
||||||
|
|
||||||
|
|
@ -27,6 +29,10 @@ export default function View() {
|
||||||
const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false);
|
const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false);
|
||||||
const [participantVersion, setParticipantVersion] = useState(0);
|
const [participantVersion, setParticipantVersion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) setUsersInFocusedViewHidden(true);
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncParticipants = () => {
|
const syncParticipants = () => {
|
||||||
setParticipantVersion((version) => version + 1);
|
setParticipantVersion((version) => version + 1);
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,4 @@
|
||||||
- Add quality selection
|
- Add quality selection
|
||||||
- Good preview page
|
- Good preview page
|
||||||
- Settings page
|
- Settings page
|
||||||
|
- Make the Three-Dots button open a dialog/context-menu to configure the call for anonymous invites or other admin stuff
|
||||||
|
|
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -536,9 +536,6 @@ importers:
|
||||||
"@tanstack/react-router":
|
"@tanstack/react-router":
|
||||||
specifier: ^1.169.1
|
specifier: ^1.169.1
|
||||||
version: 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
"@tauri-apps/api":
|
|
||||||
specifier: ^2
|
|
||||||
version: 2.11.1
|
|
||||||
"@tensamin/crypto":
|
"@tensamin/crypto":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../crypto
|
version: link:../crypto
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue