From 3e65b587ae3fb34a5838ec83ffbdba4676de3355 Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Sun, 16 Jul 2023 17:07:19 +0800 Subject: [PATCH] Add camera mirroring Add --video-source=camera, and related options: - --camera=ID: select the camera (ids are listed by --list-cameras); - --camera-size=WIDTHxHEIGHT: select the capture size. Signed-off-by: Romain Vimont --- app/src/cli.c | 77 +++++++ app/src/options.c | 3 + app/src/options.h | 8 + app/src/scrcpy.c | 3 + app/src/server.c | 12 ++ app/src/server.h | 3 + .../com/genymobile/scrcpy/CameraCapture.java | 189 ++++++++++++++++++ .../genymobile/scrcpy/HandlerExecutor.java | 23 +++ .../java/com/genymobile/scrcpy/Options.java | 42 ++++ .../com/genymobile/scrcpy/ScreenCapture.java | 3 +- .../java/com/genymobile/scrcpy/Server.java | 11 +- .../com/genymobile/scrcpy/SurfaceCapture.java | 7 +- .../com/genymobile/scrcpy/SurfaceEncoder.java | 8 +- .../com/genymobile/scrcpy/VideoSource.java | 24 +++ 14 files changed, 404 insertions(+), 9 deletions(-) create mode 100644 server/src/main/java/com/genymobile/scrcpy/CameraCapture.java create mode 100644 server/src/main/java/com/genymobile/scrcpy/HandlerExecutor.java create mode 100644 server/src/main/java/com/genymobile/scrcpy/VideoSource.java diff --git a/app/src/cli.c b/app/src/cli.c index 40b8c91c..8712530b 100644 --- a/app/src/cli.c +++ b/app/src/cli.c @@ -76,12 +76,15 @@ enum { OPT_NO_VIDEO, OPT_NO_AUDIO_PLAYBACK, OPT_NO_VIDEO_PLAYBACK, + OPT_VIDEO_SOURCE, OPT_AUDIO_SOURCE, OPT_KILL_ADB_ON_CLOSE, OPT_TIME_LIMIT, OPT_PAUSE_ON_EXIT, OPT_LIST_CAMERAS, OPT_LIST_CAMERA_SIZES, + OPT_CAMERA_ID, + OPT_CAMERA_SIZE, }; struct sc_option { @@ -198,6 +201,22 @@ static const struct sc_option options[] = { .longopt = "bit-rate", .argdesc = "value", }, + { + .longopt_id = OPT_CAMERA_ID, + .longopt = "camera", + .argdesc = "id", + .text = "Specify the device camera id to mirror when using " + "--video-source=camera.\n" + "The available camera ids can be listed by:\n" + " scrcpy --list-cameras\n" + "Default is \"auto\" (the first one)", + }, + { + .longopt_id = OPT_CAMERA_SIZE, + .longopt = "camera-size", + .argdesc = "x", + .text = "Specify an explicit camera capture size.", + }, { // Not really deprecated (--codec has never been released), but without // declaring an explicit --codec option, getopt_long() partial matching @@ -696,6 +715,13 @@ static const struct sc_option options[] = { "codec provided by --video-codec).\n" "The available encoders can be listed by --list-encoders.", }, + { + .longopt_id = OPT_VIDEO_SOURCE, + .longopt = "video-source", + .argdesc = "source", + .text = "Select the video source (display or camera).\n" + "Default is display.", + }, { .shortopt = 'w', .longopt = "stay-awake", @@ -1636,6 +1662,22 @@ parse_audio_codec(const char *optarg, enum sc_codec *codec) { return false; } +static bool +parse_video_source(const char *optarg, enum sc_video_source *source) { + if (!strcmp(optarg, "display")) { + *source = SC_VIDEO_SOURCE_DISPLAY; + return true; + } + + if (!strcmp(optarg, "camera")) { + *source = SC_VIDEO_SOURCE_CAMERA; + return true; + } + + LOGE("Unsupported video source: %s (expected display or camera)", optarg); + return false; +} + static bool parse_audio_source(const char *optarg, enum sc_audio_source *source) { if (!strcmp(optarg, "mic")) { @@ -2020,6 +2062,11 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], return false; } break; + case OPT_VIDEO_SOURCE: + if (!parse_video_source(optarg, &opts->video_source)) { + return false; + } + break; case OPT_AUDIO_SOURCE: if (!parse_audio_source(optarg, &opts->audio_source)) { return false; @@ -2038,6 +2085,12 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], return false; } break; + case OPT_CAMERA_ID: + opts->camera_id = optarg; + break; + case OPT_CAMERA_SIZE: + opts->camera_size = optarg; + break; default: // getopt prints the error message on stderr return false; @@ -2131,6 +2184,30 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], opts->force_adb_forward = true; } + if (opts->video_source == SC_VIDEO_SOURCE_CAMERA) { + if (opts->lock_video_orientation != + SC_LOCK_VIDEO_ORIENTATION_UNLOCKED) { + LOGE("--lock-video-orientation is not supported for camera"); + return false; + } + + if (!opts->camera_id) { + LOGE("Camera id must be specified by --camera=ID " + "(list the available ids with --list-cameras)"); + return false; + } + + if (!opts->camera_size) { + LOGE("Camera size must be specified by --camera-size=WIDTHxHEIGHT"); + return false; + } + + if (opts->control) { + LOGI("Camera video source: control disabled"); + opts->control = false; + } + } + if (opts->record_format && !opts->record_filename) { LOGE("Record format specified without recording"); return false; diff --git a/app/src/options.c b/app/src/options.c index b633d762..22be9f36 100644 --- a/app/src/options.c +++ b/app/src/options.c @@ -11,9 +11,12 @@ const struct scrcpy_options scrcpy_options_default = { .audio_codec_options = NULL, .video_encoder = NULL, .audio_encoder = NULL, + .camera_id = NULL, + .camera_size = NULL, .log_level = SC_LOG_LEVEL_INFO, .video_codec = SC_CODEC_H264, .audio_codec = SC_CODEC_OPUS, + .video_source = SC_VIDEO_SOURCE_DISPLAY, .audio_source = SC_AUDIO_SOURCE_OUTPUT, .record_format = SC_RECORD_FORMAT_AUTO, .keyboard_input_mode = SC_KEYBOARD_INPUT_MODE_INJECT, diff --git a/app/src/options.h b/app/src/options.h index 070a2b00..af195793 100644 --- a/app/src/options.h +++ b/app/src/options.h @@ -44,6 +44,11 @@ enum sc_codec { SC_CODEC_RAW, }; +enum sc_video_source { + SC_VIDEO_SOURCE_DISPLAY, + SC_VIDEO_SOURCE_CAMERA, +}; + enum sc_audio_source { SC_AUDIO_SOURCE_OUTPUT, SC_AUDIO_SOURCE_MIC, @@ -117,9 +122,12 @@ struct scrcpy_options { const char *audio_codec_options; const char *video_encoder; const char *audio_encoder; + const char *camera_id; + const char *camera_size; enum sc_log_level log_level; enum sc_codec video_codec; enum sc_codec audio_codec; + enum sc_video_source video_source; enum sc_audio_source audio_source; enum sc_record_format record_format; enum sc_keyboard_input_mode keyboard_input_mode; diff --git a/app/src/scrcpy.c b/app/src/scrcpy.c index 5f0158f1..d51d573b 100644 --- a/app/src/scrcpy.c +++ b/app/src/scrcpy.c @@ -351,6 +351,7 @@ scrcpy(struct scrcpy_options *options) { .log_level = options->log_level, .video_codec = options->video_codec, .audio_codec = options->audio_codec, + .video_source = options->video_source, .audio_source = options->audio_source, .crop = options->crop, .port_range = options->port_range, @@ -371,6 +372,8 @@ scrcpy(struct scrcpy_options *options) { .audio_codec_options = options->audio_codec_options, .video_encoder = options->video_encoder, .audio_encoder = options->audio_encoder, + .camera_id = options->camera_id, + .camera_size = options->camera_size, .force_adb_forward = options->force_adb_forward, .power_off_on_close = options->power_off_on_close, .clipboard_autosync = options->clipboard_autosync, diff --git a/app/src/server.c b/app/src/server.c index 424c67e9..413103ef 100644 --- a/app/src/server.c +++ b/app/src/server.c @@ -76,6 +76,7 @@ sc_server_params_destroy(struct sc_server_params *params) { free((char *) params->video_encoder); free((char *) params->audio_encoder); free((char *) params->tcpip_dst); + free((char *) params->camera_id); } static bool @@ -103,6 +104,7 @@ sc_server_params_copy(struct sc_server_params *dst, COPY(video_encoder); COPY(audio_encoder); COPY(tcpip_dst); + COPY(camera_id); #undef COPY return true; @@ -247,6 +249,10 @@ execute_server(struct sc_server *server, ADD_PARAM("audio_codec=%s", sc_server_get_codec_name(params->audio_codec)); } + if (params->video_source != SC_VIDEO_SOURCE_DISPLAY) { + assert(params->video_source == SC_VIDEO_SOURCE_CAMERA); + ADD_PARAM("video_source=camera"); + } if (params->audio_source != SC_AUDIO_SOURCE_OUTPUT) { assert(params->audio_source == SC_AUDIO_SOURCE_MIC); ADD_PARAM("audio_source=mic"); @@ -274,6 +280,12 @@ execute_server(struct sc_server *server, if (params->display_id) { ADD_PARAM("display_id=%" PRIu32, params->display_id); } + if (params->camera_id) { + ADD_PARAM("camera_id=%s", params->camera_id); + } + if (params->camera_size) { + ADD_PARAM("camera_size=%s", params->camera_size); + } if (params->show_touches) { ADD_PARAM("show_touches=true"); } diff --git a/app/src/server.h b/app/src/server.h index 04955974..92c5f22e 100644 --- a/app/src/server.h +++ b/app/src/server.h @@ -26,12 +26,15 @@ struct sc_server_params { enum sc_log_level log_level; enum sc_codec video_codec; enum sc_codec audio_codec; + enum sc_video_source video_source; enum sc_audio_source audio_source; const char *crop; const char *video_codec_options; const char *audio_codec_options; const char *video_encoder; const char *audio_encoder; + const char *camera_id; + const char *camera_size; struct sc_port_range port_range; uint32_t tunnel_host; uint16_t tunnel_port; diff --git a/server/src/main/java/com/genymobile/scrcpy/CameraCapture.java b/server/src/main/java/com/genymobile/scrcpy/CameraCapture.java new file mode 100644 index 00000000..4dcd1bfb --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/CameraCapture.java @@ -0,0 +1,189 @@ +package com.genymobile.scrcpy; + +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraDevice; +import android.hardware.camera2.CaptureFailure; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.OutputConfiguration; +import android.hardware.camera2.params.SessionConfiguration; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.view.Surface; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; + +public class CameraCapture extends SurfaceCapture { + + private final String cameraId; + private final Size explicitSize; + + private HandlerThread cameraThread; + private Handler cameraHandler; + private CameraDevice cameraDevice; + private Executor cameraExecutor; + + public CameraCapture(String cameraId, Size explicitSize) { + this.cameraId = cameraId; + this.explicitSize = explicitSize; + } + + @Override + public void init() throws IOException { + cameraThread = new HandlerThread("camera"); + cameraThread.start(); + cameraHandler = new Handler(cameraThread.getLooper()); + cameraExecutor = new HandlerExecutor(cameraHandler); + + try { + cameraDevice = openCamera(cameraId); + } catch (CameraAccessException | InterruptedException e) { + throw new IOException(e); + } + } + + @Override + public void start(Surface surface) throws IOException { + try { + CameraCaptureSession session = createCaptureSession(cameraDevice, surface); + CaptureRequest.Builder requestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); + requestBuilder.addTarget(surface); + CaptureRequest request = requestBuilder.build(); + setRepeatingRequest(session, request); + } catch (CameraAccessException | InterruptedException e) { + throw new IOException(e); + } + } + + @Override + public void release() { + if (cameraDevice != null) { + cameraDevice.close(); + } + if (cameraThread != null) { + cameraThread.quitSafely(); + } + } + + @Override + public Size getSize() { + return explicitSize; + } + + @Override + public boolean setMaxSize(int size) { + return false; + } + + @SuppressLint("MissingPermission") + @TargetApi(Build.VERSION_CODES.S) + private CameraDevice openCamera(String id) throws CameraAccessException, InterruptedException { + Ln.v("Open Camera: " + id); + + CompletableFuture future = new CompletableFuture<>(); + ServiceManager.getCameraManager().openCamera(id, new CameraDevice.StateCallback() { + @Override + public void onOpened(CameraDevice camera) { + Ln.v("Open Camera Success"); + future.complete(camera); + } + + @Override + public void onDisconnected(CameraDevice camera) { + Ln.w("Camera disconnected"); + // TODO + } + + @Override + public void onError(CameraDevice camera, int error) { + int cameraAccessExceptionErrorCode; + switch (error) { + case CameraDevice.StateCallback.ERROR_CAMERA_IN_USE: + cameraAccessExceptionErrorCode = CameraAccessException.CAMERA_IN_USE; + break; + case CameraDevice.StateCallback.ERROR_MAX_CAMERAS_IN_USE: + cameraAccessExceptionErrorCode = CameraAccessException.MAX_CAMERAS_IN_USE; + break; + case CameraDevice.StateCallback.ERROR_CAMERA_DISABLED: + cameraAccessExceptionErrorCode = CameraAccessException.CAMERA_DISABLED; + break; + case CameraDevice.StateCallback.ERROR_CAMERA_DEVICE: + case CameraDevice.StateCallback.ERROR_CAMERA_SERVICE: + default: + cameraAccessExceptionErrorCode = CameraAccessException.CAMERA_ERROR; + break; + } + future.completeExceptionally(new CameraAccessException(cameraAccessExceptionErrorCode)); + } + }, cameraHandler); + + try { + return future.get(); + } catch (ExecutionException e) { + throw (CameraAccessException) e.getCause(); + } + } + + @TargetApi(Build.VERSION_CODES.S) + private CameraCaptureSession createCaptureSession(CameraDevice camera, Surface surface) throws CameraAccessException, InterruptedException { + Ln.d("Create Capture Session"); + + CompletableFuture future = new CompletableFuture<>(); + // replace by createCaptureSession(SessionConfiguration) + OutputConfiguration outputConfig = new OutputConfiguration(surface); + List outputs = Arrays.asList(outputConfig); + SessionConfiguration sessionConfig = new SessionConfiguration(SessionConfiguration.SESSION_REGULAR, outputs, cameraExecutor, + new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(CameraCaptureSession session) { + Ln.d("Create Capture Session Success"); + future.complete(session); + } + + @Override + public void onConfigureFailed(CameraCaptureSession session) { + future.completeExceptionally(new CameraAccessException(CameraAccessException.CAMERA_ERROR)); + } + }); + + camera.createCaptureSession(sessionConfig); + + try { + return future.get(); + } catch (ExecutionException e) { + throw (CameraAccessException) e.getCause(); + } + } + + @TargetApi(Build.VERSION_CODES.S) + private void setRepeatingRequest(CameraCaptureSession session, CaptureRequest request) throws CameraAccessException, InterruptedException { + CompletableFuture future = new CompletableFuture<>(); + session.setRepeatingRequest(request, new CameraCaptureSession.CaptureCallback() { + @Override + public void onCaptureStarted(CameraCaptureSession session, CaptureRequest request, long timestamp, long frameNumber) { + future.complete(null); + } + + @Override + public void onCaptureFailed(CameraCaptureSession session, CaptureRequest request, CaptureFailure failure) { + future.completeExceptionally(new CameraAccessException(CameraAccessException.CAMERA_ERROR)); + } + }, cameraHandler); + + try { + future.get(); + } catch (ExecutionException e) { + throw (CameraAccessException) e.getCause(); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/com/genymobile/scrcpy/HandlerExecutor.java b/server/src/main/java/com/genymobile/scrcpy/HandlerExecutor.java new file mode 100644 index 00000000..1f5f0a4f --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/HandlerExecutor.java @@ -0,0 +1,23 @@ +package com.genymobile.scrcpy; + +import android.os.Handler; + +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; + +// Inspired from hidden android.os.HandlerExecutor + +public class HandlerExecutor implements Executor { + private final Handler handler; + + public HandlerExecutor(Handler handler) { + this.handler = handler; + } + + @Override + public void execute(Runnable command) { + if (!handler.post(command)) { + throw new RejectedExecutionException(handler + " is shutting down"); + } + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java index 0db383a5..41f3d9fc 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Options.java +++ b/server/src/main/java/com/genymobile/scrcpy/Options.java @@ -14,6 +14,7 @@ public class Options { private int maxSize; private VideoCodec videoCodec = VideoCodec.H264; private AudioCodec audioCodec = AudioCodec.OPUS; + private VideoSource videoSource = VideoSource.DISPLAY; private AudioSource audioSource = AudioSource.OUTPUT; private int videoBitRate = 8000000; private int audioBitRate = 128000; @@ -23,6 +24,8 @@ public class Options { private Rect crop; private boolean control = true; private int displayId; + private String cameraId; + private Size cameraSize; private boolean showTouches; private boolean stayAwake; private List videoCodecOptions; @@ -75,6 +78,10 @@ public class Options { return audioCodec; } + public VideoSource getVideoSource() { + return videoSource; + } + public AudioSource getAudioSource() { return audioSource; } @@ -111,6 +118,14 @@ public class Options { return displayId; } + public String getCameraId() { + return cameraId; + } + + public Size getCameraSize() { + return cameraSize; + } + public boolean getShowTouches() { return showTouches; } @@ -244,6 +259,13 @@ public class Options { } options.audioCodec = audioCodec; break; + case "video_source": + VideoSource videoSource = VideoSource.findByName(value); + if (videoSource == null) { + throw new IllegalArgumentException("Video source " + value + " not supported"); + } + options.videoSource = videoSource; + break; case "audio_source": AudioSource audioSource = AudioSource.findByName(value); if (audioSource == null) { @@ -326,6 +348,12 @@ public class Options { case "list_camera_sizes": options.listCameraSizes = Boolean.parseBoolean(value); break; + case "camera_id": + options.cameraId = value; + break; + case "camera_size": + options.cameraSize = parseSize(value); + break; case "send_device_meta": options.sendDeviceMeta = Boolean.parseBoolean(value); break; @@ -371,4 +399,18 @@ public class Options { int y = Integer.parseInt(tokens[3]); return new Rect(x, y, x + width, y + height); } + + private static Size parseSize(String size) { + if (size.isEmpty()) { + return null; + } + // input format: "x" + String[] tokens = size.split("x"); + if (tokens.length != 2) { + throw new IllegalArgumentException("Invalid size format (expected x): \"" + size + "\""); + } + int width = Integer.parseInt(tokens[0]); + int height = Integer.parseInt(tokens[1]); + return new Size(width, height); + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/ScreenCapture.java b/server/src/main/java/com/genymobile/scrcpy/ScreenCapture.java index f5437295..0b884958 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ScreenCapture.java +++ b/server/src/main/java/com/genymobile/scrcpy/ScreenCapture.java @@ -48,8 +48,9 @@ public class ScreenCapture extends SurfaceCapture implements Device.RotationList } @Override - public void setMaxSize(int size) { + public boolean setMaxSize(int size) { device.setMaxSize(size); + return true; } @Override diff --git a/server/src/main/java/com/genymobile/scrcpy/Server.java b/server/src/main/java/com/genymobile/scrcpy/Server.java index 2c6915c1..783f0f34 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Server.java +++ b/server/src/main/java/com/genymobile/scrcpy/Server.java @@ -134,10 +134,15 @@ public final class Server { if (video) { Streamer videoStreamer = new Streamer(connection.getVideoFd(), options.getVideoCodec(), options.getSendCodecMeta(), options.getSendFrameMeta()); - ScreenCapture screenCapture = new ScreenCapture(device); - SurfaceEncoder screenEncoder = new SurfaceEncoder(screenCapture, videoStreamer, options.getVideoBitRate(), options.getMaxFps(), + SurfaceCapture surfaceCapture; + if (options.getVideoSource() == VideoSource.DISPLAY) { + surfaceCapture = new ScreenCapture(device); + } else { + surfaceCapture = new CameraCapture(options.getCameraId(), options.getCameraSize()); + } + SurfaceEncoder surfaceEncoder = new SurfaceEncoder(surfaceCapture, videoStreamer, options.getVideoBitRate(), options.getMaxFps(), options.getVideoCodecOptions(), options.getVideoEncoder(), options.getDownsizeOnError()); - asyncProcessors.add(screenEncoder); + asyncProcessors.add(surfaceEncoder); } Completion completion = new Completion(asyncProcessors.size()); diff --git a/server/src/main/java/com/genymobile/scrcpy/SurfaceCapture.java b/server/src/main/java/com/genymobile/scrcpy/SurfaceCapture.java index 8f60d2bf..ba4716fb 100644 --- a/server/src/main/java/com/genymobile/scrcpy/SurfaceCapture.java +++ b/server/src/main/java/com/genymobile/scrcpy/SurfaceCapture.java @@ -2,6 +2,7 @@ package com.genymobile.scrcpy; import android.view.Surface; +import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -31,7 +32,7 @@ public abstract class SurfaceCapture { /** * Called once before the capture starts. */ - public abstract void init(); + public abstract void init() throws IOException; /** * Called after the capture ends (if and only if {@link #init()} has been called). @@ -43,7 +44,7 @@ public abstract class SurfaceCapture { * * @param surface the surface which will be encoded */ - public abstract void start(Surface surface); + public abstract void start(Surface surface) throws IOException; /** * Return the video size @@ -57,5 +58,5 @@ public abstract class SurfaceCapture { * * @param size Maximum size */ - public abstract void setMaxSize(int size); + public abstract boolean setMaxSize(int size); } diff --git a/server/src/main/java/com/genymobile/scrcpy/SurfaceEncoder.java b/server/src/main/java/com/genymobile/scrcpy/SurfaceEncoder.java index 4af31e89..9f90115a 100644 --- a/server/src/main/java/com/genymobile/scrcpy/SurfaceEncoder.java +++ b/server/src/main/java/com/genymobile/scrcpy/SurfaceEncoder.java @@ -122,9 +122,13 @@ public class SurfaceEncoder implements AsyncProcessor { return false; } - // Retry with a smaller device size + boolean accepted = capture.setMaxSize(newMaxSize); + if (!accepted) { + return false; + } + + // Retry with a smaller size Ln.i("Retrying with -m" + newMaxSize + "..."); - capture.setMaxSize(newMaxSize); return true; } diff --git a/server/src/main/java/com/genymobile/scrcpy/VideoSource.java b/server/src/main/java/com/genymobile/scrcpy/VideoSource.java new file mode 100644 index 00000000..6a416e48 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/VideoSource.java @@ -0,0 +1,24 @@ +package com.genymobile.scrcpy; + +import android.media.MediaRecorder; + +public enum VideoSource { + DISPLAY("display"), + CAMERA("camera"); + + private final String name; + + VideoSource(String name) { + this.name = name; + } + + static VideoSource findByName(String name) { + for (VideoSource videoSource : VideoSource.values()) { + if (name.equals(videoSource.name)) { + return videoSource; + } + } + + return null; + } +}