(feat): improve mobile
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled

(feat): add camera sharing
(fix): vite tauri connection
(fix): methanium/ui theme not applied to call popout
This commit is contained in:
Alois 2026-08-01 01:25:41 +02:00
commit 62aaa7c01d
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
31 changed files with 1915 additions and 859 deletions

View file

@ -95,7 +95,11 @@
"target": [
"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
}

View file

@ -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() {
verboseLog("registering ipc handlers");
@ -360,6 +383,7 @@ async function start() {
verboseLog("app ready");
registerIpc();
registerDisplayMediaHandler();
registerMediaPermissionHandler();
initTray(() => mainWindow);
await createWindow();
}

View file

@ -1,6 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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 -->
<uses-feature android:name="android.software.leanback" android:required="false" />
@ -39,6 +45,11 @@
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<service
android:name=".MediaProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View file

@ -1,24 +1,65 @@
package net.tensamin.client
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Rect
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
class MainActivity : TauriActivity() {
private var contentRoot: FrameLayout? = null
private var contentChild: android.view.View? = null
private var previousUsableHeight = 0
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) {
webView.setInitialScale(300)
mediaWebView = webView
MobileMediaEvents.attach(webView)
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -35,9 +76,84 @@ class MainActivity : TauriActivity() {
attachLayoutListener = null
contentRoot = null
contentChild = null
mediaWebView?.removeJavascriptInterface("tensaminMobileMedia")
mediaWebView = null
MobileMediaEvents.detach()
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() {
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
contentRoot = content

View file

@ -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),
)
}
}
}

View file

@ -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) }
}
}

View file

@ -5,7 +5,14 @@ import {
PopoverTrigger,
useIsMobile,
} 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 { joinCall, useCall } from "@tensamin/call/store";
import Wrapper from "@tensamin/user/wrapper";
@ -40,6 +47,8 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const [userInfoOpen, setUserInfoOpen] = useState(false);
const { callId } = useCall();
return (
<div
data-tauri-drag-region
@ -76,6 +85,9 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
</Button>
</>
)}
{isMobile && pathname === "/call" && callId && (
<p className="font-medium text-[1.07rem]">{displayCallId(callId)}</p>
)}
{pathname === "/chat" && id && (
<Wrapper
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" />
</div>
</div>

View file

@ -106,7 +106,7 @@ export default defineConfig({
? {
protocol: "ws",
host,
port: 1421,
clientPort: 3000,
}
: undefined,
watch: {

View file

@ -19,7 +19,6 @@
"dependencies": {
"@livekit/components-react": "^2.9.20",
"@tanstack/react-router": "^1.169.1",
"@tauri-apps/api": "^2",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",

View file

@ -11,7 +11,7 @@ import {
import { useEffect, useState } from "react";
import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf";
import ScreenshareButton from "./buttons/screenshare";
import MediaShareButton from "./buttons/mediaShare";
import LeaveButton from "./buttons/leave";
import {
setCallIsPopout,
@ -132,10 +132,10 @@ export default function Actions() {
tooltip="Deafen"
portalContainer={portalContainer}
/>
<ScreenshareButton
<MediaShareButton
className={sharedClasses}
iconSize={sharedIconSize}
tooltip="Screenshare"
tooltip="Share media"
/>
<InviteButton
className={sharedClasses}

View 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}
</>
);
}

View file

@ -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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -178,6 +178,13 @@ export default function Base({
Track.Source.ScreenShare,
);
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);
useEffect(() => {
@ -271,7 +278,7 @@ export default function Base({
flush && !fill && "border-x-0!",
type === "user" &&
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">
@ -341,23 +348,31 @@ export default function Base({
</div>
) : null)}
{type === "user" && (
<Avatar
style={{
width: "32cqh",
height: "32cqh",
}}
>
<AvatarImage src={user.Avatar} />
<AvatarFallback
{type === "user" &&
(cameraPublication?.track && !cameraDisabled ? (
<VideoViewer
fill={fill}
flush={flush}
participantId={participant.identity}
publication={cameraPublication}
/>
) : (
<Avatar
style={{
fontSize: "11cqh",
width: "32cqh",
height: "32cqh",
}}
>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
<AvatarImage src={user.Avatar} />
<AvatarFallback
style={{
fontSize: "11cqh",
}}
>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
))}
</div>
</div>
}

View file

@ -6,7 +6,7 @@ import {
Slider,
} from "@methanium/ui";
import type { User } from "@tensamin/user/context";
import { useCall } from "../../store";
import { setParticipantCameraDisabled, useCall } from "../../store";
import { useState } from "react";
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
@ -33,6 +33,9 @@ export default function ContextMenu({
const watchedStreamParticipantIds = useCall(
(state) => state.watchedStreamParticipantIds,
);
const cameraDisabled = useCall((state) =>
state.disabledCameraParticipantIds.includes(user.UserId),
);
return (
<ContextMenuContent className="p-1">
@ -71,6 +74,19 @@ export default function ContextMenu({
<p>Mute Soundboard</p>
<EmptyCheckboxIndicator checked={soundboardMuted} />
</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
checked={serverDeafened}
onCheckedChange={setServerDeafened}

View file

@ -69,6 +69,7 @@ function MobileCallPill({
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
string | undefined
>(undefined);
const safeAreaRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const initialCoords = {
x: window.innerWidth - MOBILE_PILL_WIDTH - MOBILE_MARGIN,
@ -120,21 +121,37 @@ function MobileCallPill({
};
}, [lastSpeakingUser?.Avatar]);
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 getSafeArea = useCallback(() => {
const element = safeAreaRef.current;
if (!element) return { top: 0, right: 0, bottom: 0, left: 0 };
const style = getComputedStyle(element);
return {
x: nextPosition.endsWith("right")
? window.innerWidth - width - MOBILE_MARGIN
: MOBILE_MARGIN,
y: nextPosition.startsWith("bottom")
? window.innerHeight - height - MOBILE_MARGIN
: MOBILE_MARGIN,
top: parseFloat(style.paddingTop) || 0,
right: parseFloat(style.paddingRight) || 0,
bottom: parseFloat(style.paddingBottom) || 0,
left: parseFloat(style.paddingLeft) || 0,
};
}, []);
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) => {
coordsRef.current = next;
setCoords(next);
@ -166,90 +183,102 @@ function MobileCallPill({
}
return (
<Card
ref={pillRef}
onClick={() => {
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",
}}
<div
ref={safeAreaRef}
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)]"
>
<Avatar className="size-14">
<AvatarImage src={lastSpeakingUser?.Avatar} />
<AvatarFallback className="text-lg">
{lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."}
</AvatarFallback>
</Avatar>
</Card>
<Card
ref={pillRef}
onClick={() => {
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 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>
);
}

View file

@ -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>
);
}

View file

@ -10,7 +10,7 @@ import {
TooltipTrigger,
useIsMobile,
} from "@methanium/ui";
import ScreenshareButton from "./buttons/screenshare";
import MediaShareButton from "./buttons/mediaShare";
import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf";
import { Room, Track } from "livekit-client";
@ -199,7 +199,7 @@ export default function SidebarBox() {
<div className="flex justify-center gap-1">
<MuteButton 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%]" />
</div>
</CardContent>

View 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);
}
}

View 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 };
}

View 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 });
}
}

View 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";

View 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);
}
}

View 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>;
}

View file

@ -31,8 +31,13 @@ function copyDocumentStyles(targetDocument: Document) {
}
}
function syncDocumentClasses(targetDocument: Document) {
targetDocument.documentElement.className = document.documentElement.className;
function syncDocumentAttributes(targetDocument: Document) {
for (const attribute of document.documentElement.attributes) {
targetDocument.documentElement.setAttribute(
attribute.name,
attribute.value,
);
}
targetDocument.body.className = document.body.className;
}
@ -58,12 +63,12 @@ function PopoutScreen() {
popoutWindow.document.title = document.title;
popoutWindow.document.body.innerHTML = "";
popoutWindow.document.body.style.margin = "0";
copyDocumentStyles(popoutWindow.document);
syncDocumentAttributes(popoutWindow.document);
popoutWindow.document.documentElement.style.height = "100%";
popoutWindow.document.body.style.height = "100%";
copyDocumentStyles(popoutWindow.document);
syncDocumentClasses(popoutWindow.document);
const containerElement = popoutWindow.document.createElement("div");
containerElement.style.width = "100%";
containerElement.style.height = "100%";

View file

@ -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,
};
}

View file

@ -26,16 +26,16 @@ import {
Room,
RoomEvent,
type RemoteTrack,
type ScreenShareCaptureOptions,
Track,
setLogExtension,
getLogger,
} from "livekit-client";
import z from "zod";
import {
createScreenShareController,
type ScreenShareSession,
} from "./screenshare";
createMediaShareController,
type LocalMediaShareSession,
} from "./mediaShare/controller";
import type { MediaShareRequest } from "./mediaShare";
import {
getSpeakingDetector,
disposeSpeakingDetector,
@ -69,8 +69,7 @@ type IncomingCallInvite = {
senderId: number;
};
type CurrentCallData =
| (z.infer<typeof mtp.CallData.response> & { exists: boolean })
| null;
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
type NavigateFn = (options: {
to: string;
@ -102,8 +101,11 @@ type CallStore = {
currentCallData: CurrentCallData;
deaf: boolean;
micEnabled: boolean;
cameraEnabled: boolean;
screenShareEnabled: boolean;
screenShareSession: ScreenShareSession | null;
screenShareSession: LocalMediaShareSession | null;
cameraSession: LocalMediaShareSession | null;
disabledCameraParticipantIds: number[];
focusedParticipantId: number | null;
focusedParticipantType: "user" | "stream" | null;
usersInFocusedViewHidden: boolean;
@ -302,11 +304,21 @@ function matchesRemoteTrackSelector(
}
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)) {
publication.setSubscribed(
publication.kind === Track.Kind.Audio &&
publication.source !== Track.Source.ScreenShareAudio,
);
const subscribed =
publication.source === Track.Source.Camera
? !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.
export function syncParticipantState() {
const { screenShareSession } = useCall.getState();
const { cameraSession, screenShareSession } = useCall.getState();
const room = getRoom();
useCall.setState({
micEnabled: room.localParticipant.isMicrophoneEnabled,
cameraEnabled:
cameraSession != null || room.localParticipant.isCameraEnabled,
screenShareEnabled:
screenShareSession != null || room.localParticipant.isScreenShareEnabled,
isEncrypted:
@ -769,6 +783,20 @@ export function setParticipantTrackSubscribed(
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.
export function focusParticipant(
participantId: number,
@ -835,9 +863,8 @@ export function stopWatchingFocusedStream() {
stopWatchingStream(focusedParticipantId);
}
let screenShareController: ReturnType<
typeof createScreenShareController
> | null = null;
let mediaShareController: ReturnType<typeof createMediaShareController> | null =
null;
function getNoiseFilterAssetBaseUrl() {
if (window.location.protocol === "file:") {
@ -847,17 +874,21 @@ function getNoiseFilterAssetBaseUrl() {
return "/assets";
}
function getScreenShareController() {
if (!screenShareController) {
screenShareController = createScreenShareController({
function getMediaShareController() {
if (!mediaShareController) {
mediaShareController = createMediaShareController({
room: getRoom(),
getState: () => ({
screenShareSession: useCall.getState().screenShareSession,
cameraSession: useCall.getState().cameraSession,
}),
setState: (updater) => {
useCall.setState((state) =>
typeof updater === "function"
? updater({ screenShareSession: state.screenShareSession })
? updater({
screenShareSession: state.screenShareSession,
cameraSession: state.cameraSession,
})
: 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.
@ -921,7 +952,7 @@ export async function disconnect() {
await clearScreenSharePreview();
try {
await getScreenShareController().clearPublishedScreenShare();
await getMediaShareController().clearAll();
} catch (error) {
log(
1,
@ -943,6 +974,9 @@ export async function disconnect() {
deaf: false,
view: "preview",
screenShareSession: null,
cameraSession: null,
cameraEnabled: false,
disabledCameraParticipantIds: [],
focusedParticipantId: null,
focusedParticipantType: null,
usersInFocusedViewHidden: false,
@ -1070,37 +1104,37 @@ export async function toggleMute() {
syncParticipantState();
}
// Start browser-native screen sharing for the current participant.
export async function startScreenShare(options?: ScreenShareCaptureOptions) {
await getScreenShareController().startScreenShare(options);
export async function startScreenShare(
request: Omit<MediaShareRequest, "kind"> = {},
) {
await getMediaShareController().start({ ...request, kind: "screen" });
await publishScreenSharePreview();
}
// Start the Linux desktop capture path that renders frames through Tauri.
export async function startLinuxDesktopScreenShare(sourceId: string) {
await getScreenShareController().startLinuxDesktopScreenShare(sourceId);
await publishScreenSharePreview();
export async function startCameraShare(sourceId?: string) {
await getMediaShareController().start({ kind: "camera", sourceId });
}
// Stop the local participant's active screen share and related previews.
export async function stopScreenShare() {
await getScreenShareController().stopScreenShare();
await getMediaShareController().stop("screen");
await clearScreenSharePreview();
}
export async function stopCameraShare() {
await getMediaShareController().stop("camera");
}
// Toggle screen sharing on or off from UI controls.
export async function setScreenShareEnabled(
enabled: boolean,
options?: ScreenShareCaptureOptions,
request: Omit<MediaShareRequest, "kind"> = {},
) {
await getScreenShareController().setScreenShareEnabled(enabled, options);
if (enabled) {
await publishScreenSharePreview();
await startScreenShare(request);
return;
}
await clearScreenSharePreview();
await stopScreenShare();
}
// Reset the in-memory call store when leaving the call experience entirely.
@ -1115,7 +1149,10 @@ export function resetCallState() {
livekitToken: null,
currentCallData: null,
deaf: false,
cameraEnabled: false,
screenShareSession: null,
cameraSession: null,
disabledCameraParticipantIds: [],
focusedParticipantId: null,
focusedParticipantType: null,
usersInFocusedViewHidden: false,
@ -1169,8 +1206,11 @@ export const useCall = create<CallStore>(() => ({
currentCallData: null,
deaf: false,
micEnabled: false,
cameraEnabled: false,
screenShareEnabled: false,
screenShareSession: null,
cameraSession: null,
disabledCameraParticipantIds: [],
focusedParticipantId: null,
focusedParticipantType: null,
usersInFocusedViewHidden: false,
@ -1537,14 +1577,7 @@ export function useInitializeCall() {
const participantId = getParticipantId(participant.identity);
if (participantId != null) {
if (
publication.kind === Track.Kind.Audio &&
publication.source !== Track.Source.ScreenShareAudio
) {
publication.setSubscribed(true);
} else {
publication.setSubscribed(false);
}
syncRemoteParticipantTrackSubscriptions(participantId);
}
onParticipantStateChange();

View file

@ -1,12 +1,14 @@
import { RoomEvent } from "livekit-client";
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 { useIsMobile } from "@methanium/ui";
const SECONDARY_ROW_HEIGHT_PX = 180;
const STACK_GAP_PX = 12;
export default function View() {
const isMobile = useIsMobile();
const room = getRoom();
const layoutVersion = useCall((state) => state.layoutVersion);
@ -27,6 +29,10 @@ export default function View() {
const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false);
const [participantVersion, setParticipantVersion] = useState(0);
useEffect(() => {
if (isMobile) setUsersInFocusedViewHidden(true);
}, [isMobile]);
useEffect(() => {
const syncParticipants = () => {
setParticipantVersion((version) => version + 1);

View file

@ -7,3 +7,4 @@
- Add quality selection
- Good preview 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
View file

@ -536,9 +536,6 @@ importers:
"@tanstack/react-router":
specifier: ^1.169.1
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":
specifier: workspace:*
version: link:../crypto