diff --git a/apps/electron/package.json b/apps/electron/package.json index f410033..3fcfa03 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -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 } diff --git a/apps/electron/src/main/main.ts b/apps/electron/src/main/main.ts index a5843e0..2a0ebfa 100644 --- a/apps/electron/src/main/main.ts +++ b/apps/electron/src/main/main.ts @@ -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(); } diff --git a/apps/tauri/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/apps/tauri/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 697b23b..7757466 100644 --- a/apps/tauri/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/apps/tauri/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ + + + + + + @@ -39,6 +45,11 @@ + + + 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(android.R.id.content) contentRoot = content diff --git a/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MediaProjectionService.kt b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MediaProjectionService.kt new file mode 100644 index 0000000..0afa75a --- /dev/null +++ b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MediaProjectionService.kt @@ -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), + ) + } + } +} diff --git a/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MobileMediaEvents.kt b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MobileMediaEvents.kt new file mode 100644 index 0000000..c8c377c --- /dev/null +++ b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MobileMediaEvents.kt @@ -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(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) } + } +} diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index bafdfa8..6c86f96 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -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 (
)} + {isMobile && pathname === "/call" && callId && ( +

{displayCallId(callId)}

+ )} {pathname === "/chat" && id && ( )} + {isMobile && pathname === "/call" && callId && ( + + )}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 592d338..2ad8a19 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -106,7 +106,7 @@ export default defineConfig({ ? { protocol: "ws", host, - port: 1421, + clientPort: 3000, } : undefined, watch: { diff --git a/packages/call/package.json b/packages/call/package.json index 1f1bd37..4fafe09 100644 --- a/packages/call/package.json +++ b/packages/call/package.json @@ -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:*", diff --git a/packages/call/src/components/actions.tsx b/packages/call/src/components/actions.tsx index 7e33be7..7856d7e 100644 --- a/packages/call/src/components/actions.tsx +++ b/packages/call/src/components/actions.tsx @@ -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} /> - state.screenShareEnabled); + const cameraEnabled = useCall((state) => state.cameraEnabled); + const screenRef = useCall((state) => state.screenRef); + const [portalContainer, setPortalContainer] = useState(); + const [dialogKind, setDialogKind] = useState(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 = ( + + ); + + return ( + <> + + + + tooltip ? ( + ( + } + onClick={onClick} + className={className} + > + {trigger} + + )} + /> + ) : ( + + {trigger} + + ) + } + /> + + + + {isScreensharing ? ( + + ) : null} + {cameraEnabled ? ( + + ) : null} + + + {tooltip ? ( + + {tooltip} + + ) : null} + + + {dialogKind ? ( + !open && setDialogKind(null)} + portalContainer={portalContainer} + /> + ) : null} + + ); +} diff --git a/packages/call/src/components/buttons/screenshare.tsx b/packages/call/src/components/buttons/screenshare.tsx deleted file mode 100644 index 4312dda..0000000 --- a/packages/call/src/components/buttons/screenshare.tsx +++ /dev/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(); - 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 ? ( - ( - - )} - /> - ) : ( - - ) - } - /> - - - - - - - {tooltip && ( - - {tooltip} - - )} - - - {isTauri() && ( - - )} - - ); -} diff --git a/packages/call/src/components/mediaShareDialog.tsx b/packages/call/src/components/mediaShareDialog.tsx new file mode 100644 index 0000000..863e41c --- /dev/null +++ b/packages/call/src/components/mediaShareDialog.tsx @@ -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([]); + const [selectedSourceId, setSelectedSourceId] = useState(null); + const [shareAudio, setShareAudio] = useState(true); + const [canShareAudio, setCanShareAudio] = useState(false); + const [cameraPreview, setCameraPreview] = useState(null); + const [cameraPreviewVersion, setCameraPreviewVersion] = useState(0); + const cameraPreviewRef = useRef(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 ( + + + + + {kind === "camera" ? "Share a camera" : "Share your screen"} + + + +
+
+ {sources.map((source) => { + const selected = source.id === selectedSourceId; + return ( + setSelectedSourceId(source.id)} + /> + ); + })} +
+ + {!loading && sources.length === 0 ? ( +

+ No {kind === "camera" ? "cameras" : "windows or displays"} found. +

+ ) : null} + + {canShareAudio ? ( +
+
+ +

+ Some apps and protected media do not allow audio capture. +

+
+ +
+ ) : null} + + {loading ? ( +
+ + Loading sources... +
+ ) : null} +
+ + + + + +
+
+ ); +} diff --git a/packages/call/src/components/mediaSourceCard.tsx b/packages/call/src/components/mediaSourceCard.tsx new file mode 100644 index 0000000..f740dbc --- /dev/null +++ b/packages/call/src/components/mediaSourceCard.tsx @@ -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 ; + if (source.kind === "window") return ; + return ; +} + +function VideoPreview({ stream }: { stream: MediaStream }) { + const videoRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + video.srcObject = stream; + void video.play().catch(() => undefined); + + return () => { + video.srcObject = null; + }; + }, [stream]); + + return ( +